Compare commits
17 commits
c2bfaa1d92
...
71fcf5bb94
| Author | SHA1 | Date | |
|---|---|---|---|
| 71fcf5bb94 | |||
| 8fc4d78b19 | |||
| 3c2d9fa723 | |||
| 44a474e103 | |||
| 65e635dc2b | |||
| 6188fcb517 | |||
| 0c626fb3b3 | |||
| 3b1366067b | |||
| d8e91f1041 | |||
| 1f9d7f6824 | |||
| 6f58d345e0 | |||
| 8818f1f6e9 | |||
| 890f47ab80 | |||
| 784276d9e5 | |||
| 541ca49201 | |||
| ce93f34d94 | |||
| eb03d94183 |
129 changed files with 29389 additions and 3306 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -14,3 +14,4 @@ __pycache__
|
|||
pytest-*/*
|
||||
build-*/*
|
||||
.ignore
|
||||
src/firmware/platform/pico/switch2_wake_config.h
|
||||
|
|
|
|||
|
|
@ -85,10 +85,10 @@ The Bluetooth, UART, Switch, and XInput paths now share `ControllerState`:
|
|||
| Stick sensitivity | Complete | Per-stick center calibration, inner deadzone, outer saturation, fixed-point curve, and inversion run before every output serializer. |
|
||||
| Trigger ranges | Complete for current outputs | Per-trigger deadzone, saturation, curve, and digital threshold preserve analog XInput values and configured Switch thresholds. |
|
||||
| Vibration intensity | Complete | Independent weak/strong profile scales apply to host rumble; local confirmation policy remains separate. |
|
||||
| Macros | Complete | One bounded eight-step deterministic macro per profile supports buttons, D-pad, sticks, triggers, waits, and explicit end. |
|
||||
| Turbo and Auto Burst | Complete | Fixed-point phase accumulation produces 15 activations per second with deterministic cancellation. |
|
||||
| Persistent profiles | Complete | Global fallback plus sixteen stable identities each store four fixed 256-byte profiles in a two-bank atomic database. |
|
||||
| Profile switching | Complete | PC commands and a configurable controller chord persist selection with isolated rumble/onboard/transient controller LED confirmation. |
|
||||
| Macros | Complete | Four sequences share sixteen steps and 136 sparse bytes; Once, While held, Toggle and bounded Repeat are supported. |
|
||||
| Turbo, Auto Burst and finite Burst | Complete | Configurable 1–30 Hz and 1–99% duty, shared defaults/sparse overrides, and 1–255 finite pulses use bounded phase arithmetic. |
|
||||
| Persistent profiles | Complete | Eight schema-6/384-byte profiles per global/stable identity use a two-arena indexed catalog with migration and atomic publication. |
|
||||
| Profile switching | Complete | PC activation, retained cycle chord and eight direct modifier shortcuts publish feedback only after commit. |
|
||||
| Firmware updater | Partial | UF2 updating works; version query and guided reboot/install tool are missing. |
|
||||
| Switch 2 | Unverified | Requires real-hardware qualification. |
|
||||
| Windows/SteamOS/Linux/Android compatibility | Partial | Windows XInput feasibility passed; other host/output combinations need qualification. |
|
||||
|
|
@ -323,7 +323,7 @@ Core completion evidence:
|
|||
|
||||
### Phase 3 — Mapping, tuning, profiles, and macros — Complete
|
||||
|
||||
Use four profile slots per stable controller identity. Resolve identity from Bluetooth transport, identity address, VID, and PID; use a global default when stable identity is unavailable.
|
||||
Use eight profile slots per stable controller identity. Resolve identity from Bluetooth transport, identity address, VID, and PID; use a global default when stable identity is unavailable.
|
||||
|
||||
Button mapping:
|
||||
|
||||
|
|
@ -380,7 +380,7 @@ Turbo behavior:
|
|||
Profile switching:
|
||||
|
||||
- configurable controller chord
|
||||
- one to four rumble pulses
|
||||
- one to eight rumble pulses
|
||||
- matching onboard LED count
|
||||
- controller RGB/player LED feedback when supported
|
||||
|
||||
|
|
@ -394,12 +394,13 @@ Acceptance:
|
|||
|
||||
Completion evidence:
|
||||
|
||||
- strict 256-byte profile schema and 17,696-byte fixed database support four
|
||||
- strict 256-byte profile records and a compact indexed catalog support eight
|
||||
profiles for the global fallback and each of sixteen stable identities
|
||||
- profile and adapter stores remain separate from each other and BTstack bonds;
|
||||
profile commits use one flash-safe batched inactive-bank replacement
|
||||
- schema-v1 profile databases migrate inherited trigger defaults to schema v2
|
||||
without losing custom thresholds, identities, active profiles, or other data
|
||||
profile commits append one flash-safe record and compact atomically between
|
||||
two 128 KiB arenas
|
||||
- legacy fixed profile databases migrate without losing custom thresholds,
|
||||
identities, active profiles, or other data
|
||||
- direct mapping, stick/trigger fixed-point transforms, Switch thresholds,
|
||||
XInput analog values, rumble scaling, macros, Turbo, Auto Burst, and all
|
||||
cancellation paths have deterministic native coverage
|
||||
|
|
@ -562,7 +563,250 @@ Flow:
|
|||
|
||||
Do not add a second in-application flash writer unless ROM UF2 cannot meet a concrete requirement.
|
||||
|
||||
### Phase 7 — Performance and release qualification
|
||||
### Phase 7 — Indexed profile catalog — Complete
|
||||
|
||||
Replace the fully decoded fixed database before increasing profile count.
|
||||
Initial capacity is eight profiles for the global fallback and each of sixteen
|
||||
stable controller identities; the format must support a later increase without
|
||||
another storage rewrite.
|
||||
|
||||
Storage design:
|
||||
|
||||
- reserve two 128 KiB flash arenas for append-only profile records and atomic
|
||||
compaction
|
||||
- store identity, profile index, generation, schema, payload length, and CRC
|
||||
in every record header
|
||||
- keep two independently checksummed superblocks; publish a compacted arena
|
||||
only after every live record verifies
|
||||
- retain the current four-profile bank reader for one-time migration
|
||||
- do not erase an admitted legacy bank until the new catalog and superblock
|
||||
have been read back successfully
|
||||
- maintain a compact RAM index, not a decoded copy of every profile
|
||||
- decode only the fallback and active profile for each observed identity
|
||||
- keep report-path profile access allocation-free with bounded snapshots
|
||||
|
||||
The completed AIO image uses 695,592 bytes of 4 MiB flash and reserves 256
|
||||
KiB for the two profile arenas. Total flash use plus configuration, bonds,
|
||||
and the RP2350 terminal sector is 978,216 bytes (23.32%). Linked SRAM is
|
||||
99,040 of 532,480 bytes; the profile catalog index is 1,828 bytes.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- all existing profiles 1–4 survive migration byte-for-byte at the semantic
|
||||
level
|
||||
- profiles 5–8 default independently and persist across reboot
|
||||
- interrupted append and compaction recover the last published generation
|
||||
- corrupt newest records fall back to the previous valid record
|
||||
- identity capacity remains aligned with the sixteen-entry bond store
|
||||
- only active/fallback profiles are decoded in SRAM
|
||||
- profile switching, management USB, and the graphical editor expose all
|
||||
eight slots
|
||||
|
||||
Completion evidence:
|
||||
|
||||
- the native catalog suite covers interrupted header publication, corrupt
|
||||
payload fallback, compaction, sixteen-identity capacity, and semantic
|
||||
migration of global and stable profiles 1–4
|
||||
- service tests cover on-demand selection, cached active profiles, reset-all,
|
||||
controller-originated activation, and persistence of profiles 7 and 8
|
||||
- all 109 tests pass; UART, AIO, and feasibility firmware build
|
||||
- the browser editor renders and selects all eight slots
|
||||
- Pico 2 W hardware read and activated profile 8, then restored profile 1
|
||||
|
||||
### Profile and Profile Studio enhancements — Sets A and B complete
|
||||
|
||||
The live playtest, eight-slot catalog, controller-native labels, automatic
|
||||
active-profile synchronization, and Sets A/B are implemented. Set C remains
|
||||
candidate work; the native Switch-family output plan below is separate.
|
||||
|
||||
| Priority | Candidate | Intended scope | Dependency or principal risk |
|
||||
|---|---|---|---|
|
||||
| A1 — Complete | Named and copyable profiles | Profile names and controller aliases use catalog metadata rather than input-profile fields. Copy/export/import preserve the current draft and per-section resets leave other settings unchanged. | Atomic metadata records; no report-path cost. |
|
||||
| A2 — Complete | Visual response-curve editor | Replace raw `curve_q8_8` as the primary control with a graph, named presets, fine adjustment, live raw/output markers, and “apply to other side” for sticks or triggers. Retain the exact fixed-point value as the wire representation. | Implemented entirely in Profile Studio; existing profiles round-trip unchanged. |
|
||||
| A3 — Complete | Controller aliases and Identify action | Allow names such as “Living-room DualSense”; show battery, transport, and capabilities as secondary details; provide an Identify button that briefly rumbles or lights only the selected live controller. | Implemented with catalog alias records, capability-gated live telemetry, and a bounded non-persistent Identify command. |
|
||||
| B1 — Complete | Direct profile shortcuts | Modifier plus unique face/D-pad selectors address profiles 1–8, with the cycle chord retained and feedback after commit. | Deterministic arbitration, selector rollover and held/generation transitions are covered. |
|
||||
| B2 — Complete | One Shift layer per profile | Hold/Toggle selects one alternate button map; base analog tuning, physical Turbo settings and macro definitions stay shared. | Modifier consumption and reset precedence are explicit. |
|
||||
| B3 — Complete | Macro authoring tools | Timestamped firmware recording, insert/duplicate/remove/drag/keyboard reorder, visual-only preview and live duration/byte budgets; Once/While held/Toggle/Repeat playback. | Eight steps per macro, sixteen shared steps and 136 sparse bytes remain fixed. |
|
||||
| B4 — Complete | Configurable Turbo and finite Burst | Shared rate/duty/count defaults with per-button overrides retain hold Turbo and Auto Burst. | Fixed-point timing skips elapsed cycles; UI warns about pulses narrower than host sampling. |
|
||||
| C1 | Motion calibration and tuning | Expose live gyro/accelerometer values, bias calibration, axis orientation/inversion, sensitivity, drift threshold, smoothing, and hold/toggle activation. Preserve native Switch motion units rather than introducing gyro-to-stick emulation first. | Controller-specific validation and physical motion testing; filters must not add report latency. |
|
||||
| C2 | Feedback preview and profile lighting | Add non-persistent weak/strong rumble tests, profile-switch preview, and player LED/lightbar preview. Optionally persist an RGB profile color where the controller supports it. | New bounded management command; unsupported output capabilities must be visibly disabled. |
|
||||
|
||||
Set A completion evidence:
|
||||
|
||||
- profile names and controller aliases survive catalog reload, interrupted
|
||||
writes, and arena compaction without changing the 256-byte profile schema
|
||||
- profiles copy across identities and slots through existing validated atomic
|
||||
transactions; browser JSON import/export and per-section resets operate on
|
||||
unsaved drafts
|
||||
- four response-curve graphs expose named presets, exact Q8.8 fine adjustment,
|
||||
live curve markers, and one-click linked-side application
|
||||
- live controller details expose transport, normalized battery percentage, and
|
||||
rumble/lightbar/player-LED/motion capabilities
|
||||
- Identify queues one bounded feedback pulse only for the matching live stable
|
||||
identity
|
||||
|
||||
Recommended delivery order:
|
||||
|
||||
1. A1 profile metadata, duplication, and browser backup controls.
|
||||
2. A2 curve visualization and linked-side editing.
|
||||
3. A3 controller aliases, battery/capability details, and Identify.
|
||||
4. B1 direct profile shortcuts.
|
||||
5. B3 macro recording and editing before adding new playback semantics.
|
||||
6. B2 one bounded Shift layer.
|
||||
7. B4 configurable Turbo and finite Burst.
|
||||
8. C1 motion calibration.
|
||||
9. C2 feedback preview and custom lighting.
|
||||
|
||||
Design constraints:
|
||||
|
||||
- Do not add automatic per-game switching on console paths; the Pico cannot
|
||||
reliably observe the active game.
|
||||
- Do not add arbitrary scripts or an unbounded stack of action layers.
|
||||
- Keep every report-path operation allocation-free and bounded.
|
||||
- Keep live telemetry read-only, non-overlapping, and paused while the editor
|
||||
is hidden.
|
||||
- Preserve unsaved drafts across metadata refreshes and transient USB
|
||||
disconnects.
|
||||
- Gate every controller-specific input or output by reported capabilities.
|
||||
- Add a new profile schema only when persistent runtime behavior changes;
|
||||
UI-only presets and catalog metadata must not churn the profile wire format.
|
||||
|
||||
Research basis:
|
||||
|
||||
- 8BitDo Ultimate Software: mapping, stick/trigger tuning, vibration, macros,
|
||||
profiles, and Turbo/Burst modes — https://support.8bitdo.com/ultimate/pro2.html
|
||||
- DualSense Edge profiles: names, copies, direct shortcuts, curve presets,
|
||||
linked trigger settings, and live playtest —
|
||||
https://www.playstation.com/en-us/support/hardware/set-up-edge-controller/
|
||||
- Steam Input action-set layers: temporary mapping overlays and precedence
|
||||
risks —
|
||||
https://partner.steamgames.com/doc/features/steam_controller/action_set_layers
|
||||
- Xbox Elite Shift: modifier-driven alternate mappings —
|
||||
https://support.xbox.com/en-US/help/hardware-network/controller/shift-elite-series-2
|
||||
|
||||
Set B delivery evidence:
|
||||
|
||||
- Schema 6 retains the complete 136-byte macro stream and moves the profile
|
||||
payload to 384 bytes. Catalog 2 retains 512-byte records and existing arena
|
||||
capacity; names/aliases do not grow with the profile.
|
||||
- Native regressions exercise interrupted page/arena publication, full
|
||||
identity capacity, old formats and high generation values, competing
|
||||
shortcuts, source-coordinate Shift/Turbo, finite timing and playback resets.
|
||||
- Hardware migration preserved all sixteen stored profiles, their names and
|
||||
active indices. A temporary inactive profile-8 save/readback covered the new
|
||||
fields and was restored afterward.
|
||||
- Real Profile Studio checks covered recording start/timeout/Use, a saved
|
||||
recorded step, repeat and continuous visual previews, and desktop/mobile
|
||||
layouts. The live recording run contained idle input; button-edge and
|
||||
quantization cases are covered by native regressions.
|
||||
- Recording uses management operation `0x42`, schema 1: bounded pages of
|
||||
firmware-timestamped input, explicit start/stop/run identity and visible
|
||||
capacity/time/disconnect termination. It does not write profiles until Save.
|
||||
|
||||
### Native Switch-family HD rumble — Planned
|
||||
|
||||
Standalone agent handoff: [SWITCH_FAMILY_HD_RUMBLE_PLAN.md](SWITCH_FAMILY_HD_RUMBLE_PLAN.md).
|
||||
It includes implementation locations, acceptance checks and current mixed-controller timing caveats.
|
||||
|
||||
Goal: preserve Nintendo's left/right, low/high-band commands on controllers
|
||||
that can execute them natively. This is a separate output backend from the
|
||||
DualSense PCM synthesizer, not a promise that every controller in “Switch
|
||||
mode” supports the same rumble protocol. No Switch-family native forwarding
|
||||
implementation is included in the current DualSense work.
|
||||
|
||||
Current constraints:
|
||||
|
||||
- `ControllerRumbleOutput.hd` retains decoded substeps but not the original
|
||||
eight wire bytes. Unity-gain forwarding therefore needs an explicit raw
|
||||
representation alongside the decoded, profile-scaled timeline.
|
||||
- The patched Bluepad32 Switch parser enables vibration with subcommand
|
||||
`0x48`, then implements conventional magnitudes through fixed frequencies
|
||||
and a 40 ms refresh. Preserve that hardware-tested third-party fallback.
|
||||
- `send_subcmd()` currently has a process-global four-bit packet counter;
|
||||
native ownership needs a counter per physical controller shared by every
|
||||
`0x01`/`0x10` sender. Player-LED requests currently construct zeroed rumble
|
||||
fields, so they must participate in rumble arbitration rather than silently
|
||||
overwrite the current command.
|
||||
- Joy-Cons are currently separate, horizontally mapped controllers in
|
||||
Bluepad32. A paired two-Joy-Con logical controller is not implemented.
|
||||
|
||||
Delivery order:
|
||||
|
||||
1. **Qualify protocol and models.** Start with an original genuine Switch Pro
|
||||
Controller (`057E:2009`), then original Joy-Con L/R (`057E:2006/2007`).
|
||||
Capture console USB commands and actual Bluetooth output for neutral,
|
||||
repeated, relative and multi-substep words. Verify the accepted formats,
|
||||
amplitude normalization and repeated-word semantics against real hardware;
|
||||
the older public four-byte tables alone do not establish every compressed
|
||||
command's behavior. Do not infer native support from a Pro-like parser
|
||||
type, name or VID/PID alone: ambiguous clones stay on compatibility output
|
||||
until their model is qualified. Switch 2 controllers and NSO retro models
|
||||
require separate capability/protocol qualification.
|
||||
2. **Add a generation-tagged native command path.** Extend the existing rumble
|
||||
envelope with original bytes and an explicit validity/unmodified flag;
|
||||
retain the decoded timeline for scaling, recovery and fallback. Add a
|
||||
fixed-capacity Core-0-to-Core-1 command queue and one output owner per
|
||||
physical Switch device. Use native Bluetooth report `0x10`, not PCM.
|
||||
The existing format is 11 bytes including the Bluetooth HID transaction
|
||||
byte: about 1,375 payload bytes/s at 125 reports/s, before L2CAP/HCI/radio
|
||||
overhead. Direct forwarding need not inherit the DualSense 10.667 ms PCM
|
||||
lookback, but its actual latency must be measured.
|
||||
3. **Implement fidelity, scaling and resynchronization together.** Raw
|
||||
forwarding is a fast path only when profile gain is unity and physical
|
||||
controller state is synchronized. Other gains require a bounded encoder
|
||||
from the scaled per-band timeline, preserving all representable substeps.
|
||||
Validate the inverse amplitude mapping against independent golden data;
|
||||
decoder-normalized Q15 values are not raw wire amplitude codes. Preserve
|
||||
silence and Nintendo's safe amplitude bounds. Do not apply the DualSense
|
||||
2x/0.8-power response curve to Nintendo actuators. If a scaled multi-step
|
||||
command cannot be represented in one word, qualify a bounded legal packet
|
||||
schedule/quantization policy explicitly; do not quietly collapse it to a
|
||||
peak or latest magnitude. After queue loss, feedback or reconnect, send a
|
||||
valid absolute current-state/neutral resynchronization before dependent
|
||||
relative commands; never replay an obsolete vibration backlog.
|
||||
4. **Unify LEDs, feedback and lifetime handling.** Keep the current effective
|
||||
rumble bytes in all applicable subcommand reports. Local confirmation
|
||||
temporarily overrides host output while host state continues advancing,
|
||||
then resumes the current state, not the old effect. Preserve the existing
|
||||
50 ms Switch-command expiry policy and prioritize explicit stop. Cancel
|
||||
duration, delayed-start and refresh timers when ownership changes or a
|
||||
device disconnects; old-generation callbacks must never touch a replacement.
|
||||
XInput input to this backend remains a two-magnitude, stateful effect with
|
||||
fixed carriers, not invented Nintendo frequency detail.
|
||||
5. **Add single-actuator and third-party policies.** A standalone Joy-Con
|
||||
needs an explicit mono downmix so effects addressed to either host side
|
||||
are not simply lost. Preserve each band's dominant contribution with
|
||||
deterministic frequency/tie handling and safe amplitude limits; document
|
||||
the unavoidable spatial loss. Stereo routing to a Joy-Con pair belongs
|
||||
with a separate logical-pairing feature. Keep the current 8BitDo Ultimate
|
||||
enable/fixed-frequency/refresh behavior unless that exact model passes
|
||||
native qualification; never replace it with a blanket Switch-family rule.
|
||||
6. **Qualify and promote per model.** Add independent codec/golden-packet
|
||||
regressions, scale-zero/unity/intermediate checks, channel separation,
|
||||
packet-counter wrap, backpressure/resync, LED/feedback coexistence,
|
||||
timeout/stop and disconnect/reuse cases. Run safe physical frequency and
|
||||
amplitude sweeps and captured game effects; then measure one and four
|
||||
controllers, mixed DualSense/Switch output, simultaneous input/motion and
|
||||
persistent-profile writes. Report p50/p95/p99/worst host-receipt-to-HCI
|
||||
submission, skipped/resynchronized commands and physical actuator onset
|
||||
where instrumentation is available. Enable the path only for qualified
|
||||
models; keep the UART protocol, bonds, calibration and wake configuration
|
||||
unchanged.
|
||||
|
||||
Implementation locations: `usb/switch/switch_haptics.*` and the profile
|
||||
rumble transform for representation/scaling; `input/bluepad32_input_backend.*`
|
||||
for routing/lifetimes; a bounded Switch output scheduler under `input/`;
|
||||
and `patches/bluepad32-sdl3-imu.patch` for parser integration. Patch the
|
||||
build-local Bluepad32 copy, not the upstream checkout.
|
||||
|
||||
Protocol references:
|
||||
|
||||
- Bluetooth reports, neutral values and actuator safety:
|
||||
https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/bluetooth_hid_notes.md
|
||||
- Frequency/amplitude encoding tables:
|
||||
https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/rumble_data_table.md
|
||||
|
||||
### Phase 8 — Performance and release qualification
|
||||
|
||||
Measure:
|
||||
|
||||
|
|
|
|||
188
CMakeLists.txt
188
CMakeLists.txt
|
|
@ -26,6 +26,54 @@ endif()
|
|||
option(SWITCH_PICO_LOG "Enable UART debug logging" OFF)
|
||||
option(SWITCH_PICO_ADAPTER_FEASIBILITY
|
||||
"Build the automatic Switch/XInput feasibility prototype" OFF)
|
||||
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||
set(SWITCH_PICO_NATIVE_DEFAULT ON)
|
||||
set(SWITCH_PICO_CLOCK_DEFAULT 300)
|
||||
else()
|
||||
set(SWITCH_PICO_NATIVE_DEFAULT OFF)
|
||||
set(SWITCH_PICO_CLOCK_DEFAULT 150)
|
||||
endif()
|
||||
option(SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
"Enable native DualSense haptics and transport diagnostics" ${SWITCH_PICO_NATIVE_DEFAULT})
|
||||
option(SWITCH_PICO_HAPTICS_EXPERIMENT_RAM
|
||||
"Execute the native haptics hot path from SRAM" ON)
|
||||
option(SWITCH_PICO_HD_RUMBLE
|
||||
"Auto-arm the first eligible DualSense native haptics stream" ${SWITCH_PICO_NATIVE_DEFAULT})
|
||||
set(SWITCH_PICO_SYS_CLOCK_MHZ "${SWITCH_PICO_CLOCK_DEFAULT}" CACHE STRING
|
||||
"Pico 2 W CPU clock: 300 MHz default, 150 stock or 400 opt-in")
|
||||
set_property(CACHE SWITCH_PICO_SYS_CLOCK_MHZ PROPERTY STRINGS 150 300 400)
|
||||
if(NOT SWITCH_PICO_SYS_CLOCK_MHZ MATCHES "^(150|300|400)$")
|
||||
message(FATAL_ERROR "SWITCH_PICO_SYS_CLOCK_MHZ must be 150, 300, or 400")
|
||||
endif()
|
||||
set(SWITCH_PICO_OVERCLOCK_MV "1300" CACHE STRING
|
||||
"Experimental core voltage: 1300 mV, or explicit 1400 mV at 400 MHz")
|
||||
set_property(CACHE SWITCH_PICO_OVERCLOCK_MV PROPERTY STRINGS 1300 1400)
|
||||
if(NOT SWITCH_PICO_OVERCLOCK_MV MATCHES "^(1300|1400)$")
|
||||
message(FATAL_ERROR "SWITCH_PICO_OVERCLOCK_MV must be 1300 or 1400")
|
||||
endif()
|
||||
if(SWITCH_PICO_OVERCLOCK_MV STREQUAL "1400"
|
||||
AND NOT SWITCH_PICO_SYS_CLOCK_MHZ STREQUAL "400")
|
||||
message(FATAL_ERROR "The 1400 mV experiment is restricted to 400 MHz")
|
||||
endif()
|
||||
option(SWITCH_PICO_CYW43_PACKET_READ
|
||||
"Use packet-level CYW43 receive transactions" ${SWITCH_PICO_NATIVE_DEFAULT})
|
||||
option(SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
"Batch incoming HCI credit returns" ${SWITCH_PICO_NATIVE_DEFAULT})
|
||||
if(SWITCH_PICO_HD_RUMBLE)
|
||||
set(SWITCH_PICO_HAPTICS_EXPERIMENT ON)
|
||||
endif()
|
||||
set(SWITCH_PICO_HD_PACKET_FRAMES "64" CACHE STRING
|
||||
"Native gameplay frames per packet: 64 standard, 32 single-controller experiment")
|
||||
set_property(CACHE SWITCH_PICO_HD_PACKET_FRAMES PROPERTY STRINGS 64 32)
|
||||
if(NOT SWITCH_PICO_HD_PACKET_FRAMES MATCHES "^(32|64)$")
|
||||
message(FATAL_ERROR "SWITCH_PICO_HD_PACKET_FRAMES must be 32 or 64")
|
||||
endif()
|
||||
if(SWITCH_PICO_HD_PACKET_FRAMES STREQUAL "32")
|
||||
if(NOT SWITCH_PICO_HAPTICS_EXPERIMENT OR NOT SWITCH_PICO_CYW43_PACKET_READ
|
||||
OR NOT SWITCH_PICO_HCI_CREDIT_BATCH OR SWITCH_PICO_SYS_CLOCK_MHZ LESS 300)
|
||||
message(FATAL_ERROR "32-frame qualification requires native transport, packet reads, credit batching and at least 300 MHz")
|
||||
endif()
|
||||
endif()
|
||||
set(SWITCH_PICO_INPUT_BACKEND "UART" CACHE STRING "Controller input backend")
|
||||
set_property(CACHE SWITCH_PICO_INPUT_BACKEND PROPERTY STRINGS UART BLUEPAD32)
|
||||
if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "UART"
|
||||
|
|
@ -45,6 +93,34 @@ if(SWITCH_PICO_ADAPTER_FEASIBILITY
|
|||
message(FATAL_ERROR
|
||||
"SWITCH_PICO_ADAPTER_FEASIBILITY requires the BLUEPAD32 backend")
|
||||
endif()
|
||||
if(SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
AND NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||
message(FATAL_ERROR
|
||||
"SWITCH_PICO_HAPTICS_EXPERIMENT requires the BLUEPAD32 backend")
|
||||
endif()
|
||||
if(NOT SWITCH_PICO_SYS_CLOCK_MHZ STREQUAL "150"
|
||||
AND NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||
message(FATAL_ERROR "Experimental clocks require the Pico 2 W backend")
|
||||
endif()
|
||||
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||
# Boot stage 2 must see the divider too: establish safe flash timing
|
||||
# before main raises clk_sys. Keep the CYW43 SPI bus at or below 37.5 MHz.
|
||||
math(EXPR SWITCH_PICO_FLASH_CLKDIV "(${SWITCH_PICO_SYS_CLOCK_MHZ} + 74) / 75")
|
||||
math(EXPR SWITCH_PICO_PIO_DIV256 "(${SWITCH_PICO_SYS_CLOCK_MHZ} * 256 + 74) / 75")
|
||||
math(EXPR SWITCH_PICO_PIO_DIV_INT "${SWITCH_PICO_PIO_DIV256} / 256")
|
||||
math(EXPR SWITCH_PICO_PIO_DIV_FRAC "${SWITCH_PICO_PIO_DIV256} % 256")
|
||||
add_compile_definitions(
|
||||
SWITCH_PICO_SYS_CLOCK_MHZ=${SWITCH_PICO_SYS_CLOCK_MHZ}
|
||||
SWITCH_PICO_OVERCLOCK_MV=${SWITCH_PICO_OVERCLOCK_MV}
|
||||
PICO_FLASH_SPI_CLKDIV=${SWITCH_PICO_FLASH_CLKDIV}
|
||||
CYW43_PIO_CLOCK_DIV_INT=${SWITCH_PICO_PIO_DIV_INT}
|
||||
CYW43_PIO_CLOCK_DIV_FRAC8=${SWITCH_PICO_PIO_DIV_FRAC})
|
||||
if(NOT SWITCH_PICO_SYS_CLOCK_MHZ STREQUAL "150")
|
||||
# RP2350 otherwise discards boot2 and uses the ROM's XIP setup,
|
||||
# ignoring PICO_FLASH_SPI_CLKDIV despite reporting a boot2 name.
|
||||
add_compile_definitions(PICO_EMBED_XIP_SETUP=1)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Pull in Raspberry Pi Pico SDK (must be before project)
|
||||
include(pico_sdk_import.cmake)
|
||||
|
|
@ -53,6 +129,65 @@ project(switch-pico C CXX ASM)
|
|||
|
||||
# Initialise the Raspberry Pi Pico SDK
|
||||
pico_sdk_init()
|
||||
if(SWITCH_PICO_CYW43_PACKET_READ)
|
||||
if(NOT SWITCH_PICO_HAPTICS_EXPERIMENT)
|
||||
message(FATAL_ERROR "Packet-level CYW43 reads require the transport experiment")
|
||||
endif()
|
||||
find_package(Git REQUIRED)
|
||||
set(CYW43_BUS_DIR ${PICO_SDK_PATH}/src/rp2_common/pico_cyw43_driver/cybt_shared_bus)
|
||||
set(CYW43_LOCAL_DIR ${CMAKE_CURRENT_BINARY_DIR}/cyw43-packet-read)
|
||||
set(CYW43_STATUS_PATCH ${CMAKE_CURRENT_LIST_DIR}/patches/cyw43-packet-read-status.patch)
|
||||
file(MAKE_DIRECTORY ${CYW43_LOCAL_DIR})
|
||||
configure_file(${CYW43_BUS_DIR}/cybt_shared_bus_driver.c
|
||||
${CYW43_LOCAL_DIR}/cybt_shared_bus_driver.c COPYONLY)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CYW43_STATUS_PATCH})
|
||||
execute_process(
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
GIT_CEILING_DIRECTORIES=${CMAKE_CURRENT_BINARY_DIR}
|
||||
${GIT_EXECUTABLE} apply --no-index ${CYW43_STATUS_PATCH}
|
||||
WORKING_DIRECTORY ${CYW43_LOCAL_DIR}
|
||||
RESULT_VARIABLE CYW43_PATCH_RESULT ERROR_VARIABLE CYW43_PATCH_ERROR)
|
||||
if(NOT CYW43_PATCH_RESULT EQUAL 0)
|
||||
message(FATAL_ERROR "Cannot prepare packet-read driver: ${CYW43_PATCH_ERROR}")
|
||||
endif()
|
||||
get_target_property(CYW43_BUS_SOURCES cybt_shared_bus INTERFACE_SOURCES)
|
||||
list(FIND CYW43_BUS_SOURCES ${CYW43_BUS_DIR}/cybt_shared_bus_driver.c CYW43_DRIVER_INDEX)
|
||||
if(CYW43_DRIVER_INDEX EQUAL -1)
|
||||
message(FATAL_ERROR "SDK shared-bus source layout changed")
|
||||
endif()
|
||||
list(REMOVE_ITEM CYW43_BUS_SOURCES ${CYW43_BUS_DIR}/cybt_shared_bus_driver.c)
|
||||
list(APPEND CYW43_BUS_SOURCES ${CYW43_LOCAL_DIR}/cybt_shared_bus_driver.c)
|
||||
set_property(TARGET cybt_shared_bus PROPERTY INTERFACE_SOURCES ${CYW43_BUS_SOURCES})
|
||||
endif()
|
||||
if(SWITCH_PICO_HCI_CREDIT_BATCH)
|
||||
if(NOT SWITCH_PICO_HAPTICS_EXPERIMENT)
|
||||
message(FATAL_ERROR "HCI credit batching requires the transport experiment")
|
||||
endif()
|
||||
find_package(Git REQUIRED)
|
||||
set(HCI_LOCAL_ROOT ${CMAKE_CURRENT_BINARY_DIR}/btstack-credit-batch)
|
||||
set(HCI_LOCAL_SOURCE ${HCI_LOCAL_ROOT}/lib/btstack/src/hci.c)
|
||||
set(HCI_BATCH_PATCH ${CMAKE_CURRENT_LIST_DIR}/patches/btstack-credit-batch.patch)
|
||||
file(MAKE_DIRECTORY ${HCI_LOCAL_ROOT}/lib/btstack/src)
|
||||
configure_file(${PICO_BTSTACK_PATH}/src/hci.c ${HCI_LOCAL_SOURCE} COPYONLY)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${HCI_BATCH_PATCH})
|
||||
execute_process(
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
GIT_CEILING_DIRECTORIES=${CMAKE_CURRENT_BINARY_DIR}
|
||||
${GIT_EXECUTABLE} apply --no-index ${HCI_BATCH_PATCH}
|
||||
WORKING_DIRECTORY ${HCI_LOCAL_ROOT}
|
||||
RESULT_VARIABLE HCI_PATCH_RESULT ERROR_VARIABLE HCI_PATCH_ERROR)
|
||||
if(NOT HCI_PATCH_RESULT EQUAL 0)
|
||||
message(FATAL_ERROR "Cannot prepare credit-batch HCI: ${HCI_PATCH_ERROR}")
|
||||
endif()
|
||||
get_target_property(HCI_SOURCES pico_btstack_base INTERFACE_SOURCES)
|
||||
list(FIND HCI_SOURCES ${PICO_BTSTACK_PATH}/src/hci.c HCI_SOURCE_INDEX)
|
||||
if(HCI_SOURCE_INDEX EQUAL -1)
|
||||
message(FATAL_ERROR "SDK HCI source layout changed")
|
||||
endif()
|
||||
list(REMOVE_ITEM HCI_SOURCES ${PICO_BTSTACK_PATH}/src/hci.c)
|
||||
list(APPEND HCI_SOURCES ${HCI_LOCAL_SOURCE})
|
||||
set_property(TARGET pico_btstack_base PROPERTY INTERFACE_SOURCES ${HCI_SOURCES})
|
||||
endif()
|
||||
|
||||
# Configure BLUEPAD32 input backend if selected
|
||||
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||
|
|
@ -111,7 +246,10 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
|||
${SWITCH_PICO_SOURCE_DIR}/configuration/configuration_transaction.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/core/controller_identity.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/input/bluepad32_input_backend.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/input/controller_macro_capture.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/input/switch2_wake.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/platform/pico/bootsel_pairing_button.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/platform/pico/system_clock.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/platform/pico/pico_configuration_storage.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/platform/pico/pico_profile_storage.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/profile/controller_profile.cpp
|
||||
|
|
@ -124,13 +262,59 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
|||
${SWITCH_PICO_SOURCE_DIR}/usb/usb_configuration_management.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/usb/xinput/xinput_driver.cpp
|
||||
)
|
||||
set(SWITCH2_WAKE_CONFIG
|
||||
${SWITCH_PICO_SOURCE_DIR}/platform/pico/switch2_wake_config.h)
|
||||
set_property(
|
||||
DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||
${SWITCH2_WAKE_CONFIG})
|
||||
if(EXISTS ${SWITCH2_WAKE_CONFIG})
|
||||
set(SWITCH2_WAKE_CONFIGURED_VALUE 1)
|
||||
else()
|
||||
set(SWITCH2_WAKE_CONFIGURED_VALUE 0)
|
||||
endif()
|
||||
# The experiment yields after one received packet; its transport wrapper
|
||||
# explicitly reschedules remaining input so timers run between packets.
|
||||
target_compile_definitions(switch-pico PRIVATE
|
||||
SWITCH_PICO_BLUEPAD32=1
|
||||
SWITCH_PICO_HID_INSTANCE_COUNT=4
|
||||
SWITCH_PICO_USB_OUTPUT_MODES=1
|
||||
PICO_FLASH_ASSUME_CORE1_SAFE=0
|
||||
PICO_BTSTACK_CYW43_MAX_HCI_PROCESS_LOOP_COUNT=16
|
||||
PICO_STACK_SIZE=4096
|
||||
PICO_BTSTACK_CYW43_MAX_HCI_PROCESS_LOOP_COUNT=$<IF:$<BOOL:${SWITCH_PICO_HAPTICS_EXPERIMENT}>,1,16>
|
||||
SWITCH2_WAKE_CONFIGURED=${SWITCH2_WAKE_CONFIGURED_VALUE}
|
||||
)
|
||||
if(SWITCH_PICO_CYW43_PACKET_READ)
|
||||
target_sources(switch-pico PRIVATE
|
||||
${SWITCH_PICO_SOURCE_DIR}/platform/pico/cyw43_packet_transport.c)
|
||||
target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_CYW43_PACKET_READ=1)
|
||||
target_link_options(switch-pico PRIVATE
|
||||
"LINKER:--wrap=cyw43_btbus_read" "LINKER:--wrap=cyw43_btbus_init")
|
||||
endif()
|
||||
if(SWITCH_PICO_HCI_CREDIT_BATCH)
|
||||
target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HCI_CREDIT_BATCH=1)
|
||||
endif()
|
||||
if(SWITCH_PICO_HAPTICS_EXPERIMENT)
|
||||
target_sources(switch-pico PRIVATE
|
||||
${SWITCH_PICO_SOURCE_DIR}/input/haptics_experiment.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/input/switch_hd_rumble_synth.cpp
|
||||
${SWITCH_PICO_SOURCE_DIR}/input/haptics_transport_probe.cpp)
|
||||
target_compile_definitions(switch-pico PRIVATE
|
||||
SWITCH_PICO_HAPTICS_EXPERIMENT=1
|
||||
SWITCH_PICO_HD_PACKET_FRAMES=${SWITCH_PICO_HD_PACKET_FRAMES}
|
||||
SWITCH_PICO_HAPTICS_EXPERIMENT_RAM=$<BOOL:${SWITCH_PICO_HAPTICS_EXPERIMENT_RAM}>)
|
||||
target_compile_definitions(bluepad32 PRIVATE
|
||||
SWITCH_PICO_HAPTICS_EXPERIMENT=1)
|
||||
# These boundaries are defined in different SDK translation units from
|
||||
# their callers. GNU ld --wrap intercepts unresolved symbol references;
|
||||
# an LTO build must verify actual caller edges, not just wrapper symbols.
|
||||
target_link_options(switch-pico PRIVATE
|
||||
"LINKER:--wrap=cyw43_bluetooth_hci_write"
|
||||
"LINKER:--wrap=cyw43_bluetooth_hci_read"
|
||||
"LINKER:--wrap=btstack_run_loop_base_poll_data_sources")
|
||||
endif()
|
||||
if(SWITCH_PICO_HD_RUMBLE)
|
||||
target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HD_RUMBLE=1)
|
||||
endif()
|
||||
else()
|
||||
target_compile_definitions(switch-pico PRIVATE
|
||||
SWITCH_PICO_HID_INSTANCE_COUNT=1
|
||||
|
|
@ -163,7 +347,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
|||
pico_multicore
|
||||
pico_flash
|
||||
)
|
||||
target_link_libraries(switch-pico hardware_watchdog)
|
||||
target_link_libraries(switch-pico hardware_watchdog hardware_adc hardware_vreg hardware_powman)
|
||||
endif()
|
||||
|
||||
if (SWITCH_PICO_LOG)
|
||||
|
|
|
|||
313
HAPTICS_EXPERIMENT.md
Normal file
313
HAPTICS_EXPERIMENT.md
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
# DualSense native HD rumble and transport experiment
|
||||
|
||||
## Goal and evidence
|
||||
|
||||
Native Nintendo HD-rumble decoding and stateful XInput motor strengths feed the DualSense Bluetooth PCM backend. Standard AIO/XInput builds enable the qualified 300 MHz/1.3 V transport by default and auto-arm the first eligible DualSense that becomes ready, in any slot. One native stream is selected at a time; other controllers retain compatibility rumble. The deterministic transport fixture remains available. Console gameplay was user-tested; precise actuator-onset latency and perceptual equivalence to Nintendo hardware are not claimed.
|
||||
|
||||
The user observed 1–2 seconds of gameplay-to-haptics delay in OMP session `01a06fa9-cdc7-72de-ac0e-7de08c355f06`. Both a DS5Dongle-style 0x39 stream and a short 0x32 stream failed after continuous silence, latest-state replacement and can-send callbacks were tried. Do not repeat those changes as newly discovered fixes or attribute the observed delay to profile feedback.
|
||||
|
||||
Recovered source and retained object identify three concrete issues:
|
||||
|
||||
- `request_can_send()` armed `send_requested` after `l2cap_request_can_send_now_event()`. This BTstack can deliver the event synchronously; the callback discarded it and left a pending flag with no notification. An isolated reproduction produced zero sends / pending=true, versus one send / pending=false when armed before requesting.
|
||||
- The final short report used haptic descriptor 0xd2. SAxense uses 0x92 for its single 64-byte block. The extra bit is undocumented; the mismatch is established, its contribution to the physical delay is not.
|
||||
- Relative Pico BTstack timers add one millisecond tick. Rescheduling `{11,11,10}` relative to each callback does not implement an exact 3 kHz clock and accumulates callback lateness.
|
||||
|
||||
The original diagnostic captures were not from a live native-haptic stream. CPU saturation, ACL-credit starvation, and controller-internal latency were not measured.
|
||||
|
||||
Sources:
|
||||
|
||||
- https://github.com/egormanga/SAxense/blob/master/SAxense.c
|
||||
- https://github.com/awalol/DS5Dongle/blob/master/src/audio.cpp
|
||||
- https://github.com/awalol/DS5Dongle/blob/master/src/bt.cpp
|
||||
- https://github.com/awalol/DS5Dongle/blob/master/CMakeLists.txt
|
||||
- Local SDK `lib/btstack/src/l2cap.c`, `src/rp2_common/pico_btstack/btstack_run_loop_async_context.c`.
|
||||
- Frequency reference: https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/rumble_data_table.md
|
||||
- Reconstructed substep reference: https://github.com/HandHeldLegend/NS-LIB-HID/blob/becc24f0841bbb875da24ea622cc1ada00cb8492/docs/hd-rumble-implementation-guide.md
|
||||
- Eight-millisecond playback-window reference: https://github.com/HandHeldLegend/HOJA-LIB-RP2040/blob/238f66d1c4aae87fc320d94d8abd38229e7da2d0/src/utilities/pcm.c
|
||||
|
||||
## Implementation contract
|
||||
|
||||
1. AIO/XInput defaults enable `SWITCH_PICO_HAPTICS_EXPERIMENT`, `SWITCH_PICO_HD_RUMBLE`, packet-level CYW43 reads and bounded HCI credit batching at 300 MHz/1.3 V. UART is unchanged. Preserve wake identity, pairing storage and USB modes. Incoming flow control and FIFO capacities remain unchanged; the controller's advertised outgoing capacity is eight ACL packets on this hardware.
|
||||
2. One selected Sony DualSense/DualSense Edge, Bluetooth Classic, sufficient negotiated MTU. Auto-arm chooses the first eligible ready controller, not necessarily slot 0, and later controllers do not steal an active stream. The fixture requires explicit start. Idle native output remains silent; other models and unselected slots use their controller-specific compatibility paths.
|
||||
3. Report 0x32 plus A2 remains a 143-byte L2CAP SDU. The first report selects native mode with sized state block 0x90/63 and one silent 0x92/64 haptic block. Subsequent reports use compact controls `{0x91,3,0x62,16,counter}`. Standard gameplay and the fixture carry two blocks (64 stereo frames, descriptor 0xd2). Explicit `SWITCH_PICO_HD_PACKET_FRAMES=32` carries one 64-byte block (descriptor 0x92) for single-controller qualification only. The counter advances by the number of blocks. Padding and Bluetooth CRC remain deterministic. No speaker, microphone, USB audio endpoint, Opus or resampler.
|
||||
4. At 3 kHz, 32/64 stereo frames require 93.75/46.875 reports/s. Absolute rational deadlines preserve fractional time and skip obsolete packets after stalls rather than burst-replaying them. Timer wakeups account for SDK +1 tick. Can-send permission and audio deadlines remain separate; flags are armed before requests and synchronous callbacks cannot recursively generate a stream.
|
||||
5. The deterministic fixture remains a finite 288-report / 6.144-second sequence: 48 priming intervals, four cycles of left 100 Hz / silence / right 200 Hz / silence (12 reports = 256 ms per phase), then 48 trailing-silence reports. Its peak remains 32/127. Gameplay is continuous, has no one-second priming pattern, and uses timestamped Switch commands instead. Stop restores compatibility output; disconnect cancels without stale-pointer use.
|
||||
6. No historical PCM FIFO. Generate only the current due block when transmission is permitted; bounded control mailbox across cores. Record packet counts, skipped blocks, failed sends, synchronous callbacks, generation cost, send gaps, lateness, request wait and first-tone timestamps. HCI submission is not physical actuator onset.
|
||||
7. Host `haptics-experiment start`, `gameplay`, `status`, `stop`, and `profile` retain USB management framing. AIO builds enable these operations; explicitly disabled/UART builds do not. Operation 0x40 uses schema 5 and transport profiling uses schema 3. Update firmware and host tools together.
|
||||
8. Regression coverage must include synchronous callback delivery, rational clock and late wakeups, reference packet interpretation, finite completion/stop, disconnect/reconnect and compatibility restoration. Native probes cannot prove controller acceptance or physical latency.
|
||||
|
||||
## Gameplay mode
|
||||
|
||||
Build using the provisioned Pico SDK/toolchain environment:
|
||||
|
||||
```sh
|
||||
cmake -S . -B build-hd-rumble -DPICO_BOARD=pico2_w \
|
||||
-DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32 \
|
||||
-DSWITCH_PICO_HD_RUMBLE=ON \
|
||||
-DSWITCH_PICO_SYS_CLOCK_MHZ=300 -DSWITCH_PICO_OVERCLOCK_MV=1300 \
|
||||
-DSWITCH_PICO_CYW43_PACKET_READ=ON -DSWITCH_PICO_HCI_CREDIT_BATCH=ON \
|
||||
-DSWITCH_PICO_HD_PACKET_FRAMES=64 \
|
||||
-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=ON -DSWITCH_PICO_LOG=OFF
|
||||
cmake --build build-hd-rumble
|
||||
```
|
||||
|
||||
Load `build-hd-rumble/switch-pico.elf` or `.uf2`, or use the standard `build.py --aio` entry point and `firmware/switch-pico-aio.uf2`. Both use the optimized native path. Reconnect a DualSense with PS if needed. Manual selection uses `haptics-experiment gameplay --slot N` after stopping any active run; CLI slots are zero-based, so the second controller is `--slot 1`. Manual arming does not persist across power cycles. `stop` disarms the native stream and restores compatibility output. A new eligible connection may auto-arm; there is no periodic re-arm that defeats an explicit stop.
|
||||
|
||||
The decoder preserves each actuator's one-to-three ordered substeps and frequency indices. Amplitudes become linear Q0.15 values via precomputed lookups; compatibility magnitudes retain their previous mapping. Profile strong/weak scales apply to the low/high bands of both actuators without discarding substeps.
|
||||
|
||||
The synthesizer has independent left/right low/high phase accumulators. Frequencies are `40 * 2^(index/32)` and `80 * 2^(index/32)` Hz. Each Switch command occupies an 8 ms window, split into 24/12/8 PCM samples per substep for counts 1/2/3. New reports supersede unplayed old substeps; identical compressed words hold final state rather than replaying deltas. Each Switch-updated side expires after 50 ms, matching the existing conservative timeout policy.
|
||||
|
||||
Standard gameplay uses 21.333 ms causal lookback; the explicit 32-frame experiment uses 10.667 ms. Fixed 16-entry cross-core and synthesis histories contain commands, not PCM. Overflow is counted and obsolete sample intervals are not replayed. XInput holds use a distinct persistent command: strong/low magnitude drives the left 160 Hz band, weak/high magnitude drives the right 320 Hz band, until a new command or zero stop. They do not fake refreshes to evade the 50 ms Switch watchdog. Retained XInput state is seeded once per native run, including manual re-arming after compatibility output.
|
||||
|
||||
Native gameplay uses balanced **2x low/high gain after profile scaling**, followed by a gentle **0.8-power curve** on the combined amplitude. This lifts quiet and medium effects while retaining their low/high ratio. The curve is a 257-entry lookup with integer interpolation, not per-sample floating-point math. Combined weights are capped at 65535 to avoid overflow and clipping. Zero remains zero. The amplitude curve does not alter carrier frequencies or local-confirmation gain; packet timing follows the transport configuration above. This response replaced the initial 1.5x and low-band-only experiments after user comparison.
|
||||
|
||||
Local confirmation remains a transient overlay and resumes the current host state. USB reset/unmount/suspend clears held XInput output. In Auto mode, an unmount intentionally watchdog-reboots to Switch probe; this includes a reset that clears TinyUSB's configured/mounted state. A libusb reset can consequently report “Entity not found” while the device re-enumerates. Persistent manual XInput mode is exempt from that Auto-mode reboot policy.
|
||||
|
||||
The gameplay stream continues with silence while idle. It stops on disconnect, explicit stop, or a stalled send-permission watchdog. Existing LED feedback can drain without switching the controller out of native haptics. Continuous idle streaming trades power for avoiding repeated audio-mode startup.
|
||||
|
||||
## Building and running
|
||||
|
||||
Use the repository's provisioned Pico SDK/toolchain environment. These CMake commands only build; they do not flash the adapter:
|
||||
|
||||
```sh
|
||||
cmake -S . -B build-haptics -DPICO_BOARD=pico2_w \
|
||||
-DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32 \
|
||||
-DSWITCH_PICO_HAPTICS_EXPERIMENT=ON \
|
||||
-DSWITCH_PICO_HD_RUMBLE=OFF \
|
||||
-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=ON \
|
||||
-DSWITCH_PICO_LOG=OFF
|
||||
cmake --build build-haptics
|
||||
```
|
||||
|
||||
The fixture artifacts are `build-haptics/switch-pico.elf` and `.uf2`; disabling auto-arm lets `start` run without first stopping gameplay. For a same-clock flash comparison, use a separate directory and `SWITCH_PICO_HAPTICS_EXPERIMENT_RAM=OFF`. Standard AIO/XInput `build.py` entry points explicitly enable the qualified defaults even with an old CMake cache; UART entry points explicitly disable them.
|
||||
|
||||
After loading the chosen image and connecting a DualSense:
|
||||
|
||||
```sh
|
||||
uv run switch-pico-config haptics-experiment status
|
||||
uv run switch-pico-config haptics-experiment start --slot 0 --watch --json
|
||||
uv run switch-pico-config haptics-experiment profile --json
|
||||
uv run switch-pico-config haptics-experiment stop --slot 0
|
||||
```
|
||||
|
||||
For the deterministic fixture only, the first tone is intentionally scheduled 1.024 seconds after start. Gameplay instead renders the timestamped host timeline with one report interval of lookback. First-tone fields identify the logical first nonsilent sample and the containing report's submission, not actual actuator onset.
|
||||
|
||||
## Protocol
|
||||
|
||||
USB vendor operation 0x40: OUT `{action, slot}` (0=stop, 1=finite fixture, 2=gameplay; slots 0..3), using the existing envelope. IN is schema 5, 84 bytes.
|
||||
|
||||
- Seventeen little-endian u32 fields: run_id, connection_generation, start_us, generated_packets, sent_packets, skipped_packets, send_failures, can_send_requests, synchronous_callbacks, max_generate_us, max_send_gap_us, max_lateness_us, max_request_wait_us, first_tone_due_us, first_tone_sent_us, last_sent_us, elapsed_us.
|
||||
- Four u8 fields: state, slot, last_error, reserved (zero).
|
||||
- Byte 72: mode (0=fixture, 1=gameplay); byte 73: actual stereo frames per packet (32 or 64); byte 74: whether the last successfully submitted PCM packet was nonzero (0/1); byte 75: zero reserved. Nonzero PCM is firmware output evidence, not measured actuator motion.
|
||||
- Little-endian u32 at 76: `host_updates`; at 80: `dropped_updates`.
|
||||
- State: idle=0, pending=1, running=2, completed=3, stopped=4, disconnected=5, unsupported=6, error=7. Disabled build reports unsupported.
|
||||
- Microsecond timestamps are low 32 bits of Pico uptime; use unsigned modular differences for this bounded experiment. Host receipt time is not a hardware onset measurement.
|
||||
- Error: none=0, unsupported controller=1, insufficient MTU=2, disconnected=3, timeout=4, transport failure=5, queued conventional output=6. The fixture rejects a queued start. Gameplay allows a bounded startup interval for prior output to drain; it does not discard LED/control reports.
|
||||
|
||||
### Transport timing probe
|
||||
|
||||
Operation `0x41` is IN-only. Schema 3 contains 44 little-endian 32-bit fields in `HapticsTransportProbe` declaration order (176 bytes). The first 32 retain the prior counters and raw advertised ACL capacity. Appended fields report requested/measured system clock, measured USB clock, regulator setting, flash divider, CYW43 PIO divider in 1/256 units, estimated temperature (signed milli-Celsius), credit/ACL/other HCI write counts, write failures, and packet-read optimization. Regulator voltage is a setting, not a multimeter reading; temperature assumes a 3.3 V ADC reference and is uncalibrated. Counters correlate by run ID/generation; snapshots are not an atomic cross-operation instant.
|
||||
|
||||
```sh
|
||||
uv run switch-pico-config haptics-experiment profile --json
|
||||
```
|
||||
|
||||
The probe measures scheduled timer lateness before choosing a current packet, permission wait before generating/sending, full synchronous `l2cap_send` duration, CYW43 write/read duration, data-source polling duration/gaps, selected-handle Number Of Completed Packets events, and observed ACL credit extrema. The first successful tone's send-return timestamp complements the original pre-send timestamp. Neither is radio transmission or actuator onset.
|
||||
|
||||
Timing totals are inclusive and overlap: a data-source poll can dispatch a completion callback that sends a packet, and a synchronous send can call the CYW43 writer. Do not add those totals as independent CPU costs. Read/write/poll measurements cover all controller traffic during the selected run; completion counts and outstanding packets are filtered to the selected connection. Free ACL slots come from the shared controller pool. Extrema are observations, not a complete occupancy trace.
|
||||
|
||||
The instrumentation stores bounded counters in memory, not per-packet UART logs. GNU linker wrappers preserve each transport call's arguments, return values and call count. After a bounded poll returns nonempty input, the wrapper marks future receive work pending rather than draining recursively; this fairness behavior runs even while measurement is inactive. Verify wrapper call sites in the actual ELF and nonzero live counters; native tests alone cannot establish that a differently optimized SDK build retained the boundaries.
|
||||
|
||||
## SRAM relocation (not RAM replacement)
|
||||
|
||||
DS5Dongle executes selected hot code from SRAM instead of external XIP flash. Its full audio build also relocates roughly 220 KB of Opus code/data; that does not belong in this haptics-only experiment.
|
||||
|
||||
Start by placing this experiment's packet synthesis/send callback and small waveform constants in SRAM using the Pico SDK's time-critical sections; verify symbol placement and SRAM cost in the linked ELF. Do not claim that this alone fixes transport latency. Keep a build switch for flash-versus-SRAM comparison without changing the protocol or clock.
|
||||
|
||||
Only after timing evidence, consider selected L2CAP/HCI/CYW43 and USB hot call chains. Relocating only a wrapper leaves callees in flash. Broad object-section rewriting is SDK/compiler sensitive, consumes SRAM needed by stacks and buffers, and must be checked in the map. Do not wholesale replace memcpy/memset or move the entire stack without measurements. Shared flash-safe operations and BOOTSEL sampling remain legitimate jitter sources to measure, not disable unsafely.
|
||||
|
||||
## Optional overclocking
|
||||
|
||||
The initial investigation used stock 150 MHz. The qualified AIO default is now **300 MHz at 1.3 V**. CPU clocks do not increase Bluetooth air rate or repair protocol/scheduling errors.
|
||||
|
||||
The first 300 MHz image stopped at its flash-divider guard: RP2350 had discarded optional boot2/XIP setup, so `PICO_FLASH_SPI_CLKDIV` was not applied. `PICO_EMBED_XIP_SETUP=1` fixed that boot path; its linked copy/execute path was checked. The corrected 300 MHz/1.3 V image booted and passed input-plus-rumble tests. On this board 400 MHz/1.3 V did not boot; the user-requested 400 MHz/1.4 V variant booted and passed a short run but was not faster in the measured workload.
|
||||
|
||||
An explicit `SWITCH_PICO_SYS_CLOCK_MHZ=400` plus `SWITCH_PICO_OVERCLOCK_MV=1400` permits that experiment; it alone lifts the regulator's 1.3 V limit. Lower-voltage builds restore the limit after lowering voltage, including warm reboot. Stock 150 MHz is an explicit option, not the default AIO image. USB remains 48 MHz. Flash/PIO dividers are 4/4 at 300 MHz and 6/(5+86/256) at 400 MHz, keeping flash at 75/66.67 MHz and CYW43 SPI at about 37.5 MHz.
|
||||
|
||||
[Pimoroni's measurements](https://learn.pimoroni.com/article/overclocking-the-pico-2) are useful experimental evidence, not a stability or lifetime guarantee for every Pico 2 W. Do not generalize their extreme-voltage/cooling experiments. Keep a recovery image that understands the current profile schema; old pre-migration firmware does not understand catalog 2/schema 6.
|
||||
|
||||
## Verification and acceptance
|
||||
|
||||
Build normal and opt-in firmware, run focused regressions, verify time-critical symbols and retained wake configuration. Exercise the actual USB CLI and connected controller. Capture live counters during the finite run. The current two-block format requires 46.875 reports/s and nominal HID+A2 traffic of 6,703.125 bytes/s, before L2CAP/HCI/radio overhead. Acceptance is 288/288 submissions with zero skipped slots and failures, repeated runs, bounded stop and continuing controller input.
|
||||
|
||||
Physical acceptance requires correlating first-tone scheduling/sending with actuator onset using an accelerometer/contact microphone or a synchronized observation. Record controller model/firmware, packet gaps and CPU generation time. A successful `l2cap_send` is not an acknowledgement of playback. Only a measured low-latency result permits integration with Nintendo's per-side, per-band timeline.
|
||||
|
||||
## Results
|
||||
|
||||
### Initial single-block baseline (superseded)
|
||||
|
||||
- Normal all-in-one, experimental SRAM, and experimental flash variants built successfully for Pico 2 W / RP2350 Arm, using the provisioned Pico SDK and GNU Arm 15.2.1 toolchain.
|
||||
- 99 focused host-control, USB-management, build-helper and Bluepad32-preparation tests passed. Both native sender variants passed, including independent zlib validation of all 576 generated reports.
|
||||
- The first hardware start exposed an additional integration defect: the Classic path leaves Bluepad32's cached `conn.protocol` unset. Eligibility now uses `gap_get_connection_type(handle)`, matching the existing backend identity code. Modeling the real unset field made the native test fail before this correction and pass afterward; the corrected hardware accepted the run.
|
||||
- The initial SRAM image put the callback at `0x20000174` and waveform at `0x20000910`, with 2,024 bytes extra SRAM versus its flash counterpart. Final-image placement is recorded below.
|
||||
- At this initial stage no wake capture, pairing reset, clock, voltage, FIFO-size or HCI-credit-limit change was performed. Wake transmission itself was not exercised.
|
||||
|
||||
### Initial connected-controller run
|
||||
|
||||
The SRAM variant was flashed and exercised over the actual USB management endpoint with a reconnected Sony DualSense (`054c:0ce6`) in slot 0, using the configured stock 150 MHz clock and unchanged regulator settings. Controller firmware revision and physical actuator onset were not measured.
|
||||
|
||||
```sh
|
||||
uv run switch-pico-config --timeout 15 haptics-experiment start --slot 0 --watch --json
|
||||
```
|
||||
|
||||
The host captured 63 snapshots during run 1:
|
||||
|
||||
| Measurement | Result |
|
||||
| --- | ---: |
|
||||
| Scheduled packet slots | 576 |
|
||||
| Successful PCM submissions | 113 |
|
||||
| Obsolete packet slots skipped | 463 |
|
||||
| Send failures | 0 |
|
||||
| CAN_SEND_NOW requests | 113 |
|
||||
| Synchronous callbacks | 58 |
|
||||
| Maximum packet generation time | 55 us |
|
||||
| Maximum permission wait | 72,231 us |
|
||||
| Maximum submission gap | 108,791 us |
|
||||
| First tone submission after scheduled first tone | 15,618 us |
|
||||
| Terminal elapsed time, including restoration | 6,264,335 us |
|
||||
|
||||
The finite lifecycle reached `completed`, but **the PCM cadence acceptance criterion failed**: 113 submissions over a 6.144-second pattern window is about 18.4 packets/s, versus the required 93.75. `completed` means the finite run and compatibility restoration finished, not that every packet was delivered or low-latency playback was proved. There was no historical PCM queue replay; old sample slots were discarded.
|
||||
|
||||
Run 2 exercised early stop: firmware-confirmed `stopped`, 37 submissions, 142 skipped slots, zero send failures. Controller input remained live afterward. Configuration generation 9 / CRC `b740995b` matched the pre-flash baseline.
|
||||
|
||||
This established that synthesis was inexpensive but did not separate synchronous I/O, receive-loop work and credit wait. The follow-on measurements below did so. The flash-resident comparison was built, not flashed or timed; no SRAM latency improvement was established by this baseline.
|
||||
|
||||
### Transport diagnosis and corrections
|
||||
|
||||
1. **Instrumented sixteen-packet baseline:** 106/576 submissions, 470 skipped slots. Every poll consumed all 16 reads: 2,624 reads across 164 polls. Maximum poll duration 37,834 us; timer lateness 38,544 us. In contrast, synchronous send peaked at 1,429 us and CYW43 write at 1,090 us. Nested timing totals must not be summed.
|
||||
2. **Receive fairness:** cap each experimental poll at one packet and explicitly reschedule further work after nonempty reads. Maximum timer lateness fell to 4,126 us. However, the three-slot outgoing cap still filled and permission waits reached 127,578 us; this alone did not sustain PCM.
|
||||
3. **Negotiated credits:** capture the controller's raw initialization response: **8 ACL packets of 1,021 bytes**. Remove only the experimental software clamp, allowing BTstack to use the advertised capacity. Keep controller-to-host flow control enabled. The single-block sender improved to 356/576 submissions but still skipped 220 slots.
|
||||
4. **Native mode handoff:** the user felt no vibration. Compatibility restoration had explicitly selected rumble; audio reports did not clear that selection. Sending the sized 0x10 native-state block first produced user-confirmed vibration, albeit weak and still interrupted.
|
||||
5. **Compact two-block format:** [DS5Dongle's audio implementation](https://github.com/awalol/DS5Dongle/blob/master/src/audio.cpp) documents that audio controls can be reduced to a buffer-length value and packet counter. With microphone streaming disabled, mask 0x62 and length 3 leave room for two 64-byte blocks in the same 143-byte SDU. Buffer length 16 is the reference configuration's minimum. The counter advances by two. This halves required report cadence without reducing the 3 kHz sample rate or changing the audible pattern.
|
||||
|
||||
### Final live acceptance
|
||||
|
||||
Four consecutive six-second runs on the connected DualSense completed with **288/288 reports, zero skipped slots and zero send failures in every run**. Across those runs:
|
||||
|
||||
| Measurement | Worst observed value |
|
||||
| --- | ---: |
|
||||
| Packet generation | 57 us |
|
||||
| Send-permission wait | 191 us |
|
||||
| Report gap (nominal 21,333.33 us) | 24,081 us |
|
||||
| Submission lateness | 2,988 us |
|
||||
| First tone send-return lateness | 3,095 us |
|
||||
|
||||
The user confirmed **alternating pulses** and described them as still a bit weak. Peak 32/127 is intentional; it remained unchanged across packing comparisons. This establishes native haptic acceptance and recognizable channel alternation, not a precise physical latency measurement.
|
||||
|
||||
Early-stop run 5 reached `stopped` with 72 submissions, zero skipped slots and zero failures. The host observed stop confirmation in approximately 111 ms, including USB querying/restoration. During that check 605 controller reports arrived, and the live input state advanced by 565 generations while remaining connected. Button-to-Switch latency and actuator-stop latency were not measured.
|
||||
|
||||
Final verification: **122 focused tests passed**; normal, experimental SRAM and experimental flash images built. The SRAM callback is at `0x20000174`, waveform at `0x20000920`; the flash variant places them at `0x10014624` and `0x10068534`. SRAM relocation adds **2,056 bytes** versus the final flash variant. Real SDK call sites were verified to enter the transport wrappers.
|
||||
|
||||
Configuration remains generation 9 / CRC `b740995b`; wake configuration remains included. Clock stays at the configured stock 150 MHz, voltage unchanged. No FIFO enlargement, incoming flow-control removal, speaker/microphone stream or broad stack relocation was needed.
|
||||
|
||||
### Gameplay integration verification
|
||||
|
||||
The initial gameplay image passed 513 real Switch-format USB OUT reports through the decoder, profile scaling, cross-core history and native sender: 513 observed host updates, zero dropped updates, zero Bluetooth skips and zero send failures. A stronger-command repeat also passed; the user confirmed correct alternating left/right effects. A simultaneous local-profile identification test retained all 513 updates, dispatched local confirmation, and stayed in native gameplay with no skips or failures.
|
||||
|
||||
The user then tested an actual Switch game and reported that it **worked well but needed more strength**. Gameplay gain was increased to 1.5x with joint headroom limiting, then built, flashed and exercised again: all 513 commands arrived, 200 Bluetooth reports were submitted during the test, zero command drops/skips/send failures, and 1,443 controller input reports continued. Maximum observed packet-generation time was 680 us, permission wait 292 us, and report gap 24,343 us. These are firmware/transport measurements, not a physical latency bound.
|
||||
|
||||
Stop and re-arm were also exercised: stop confirmation was observed in about 19.5 ms; the new run resumed continuous silence and controller input. That re-arm recorded one skipped silent startup slot, with no host commands or send failures; the active USB-driven tests above had no skipped slots.
|
||||
|
||||
Final focused verification: **148 tests passed**, covering decoder fidelity, frequency/phase behavior, substeps, gain/headroom, watchdogs, overflow, startup/stop, profile gain/feedback, host controls, existing backend lifecycle, UART and build helpers. HD gameplay, normal all-in-one, deterministic experiment, and Pico/UART firmware builds succeeded. Configuration remained generation 9 / CRC `b740995b`; wake identity, clock and voltage were not changed.
|
||||
|
||||
The HD gameplay image auto-arms on connection. This is a translation to DualSense actuators, not a promise of identical Nintendo force response. Physical onset still needs synchronized measurement. Follow-on IMU and rumble-response refinements are recorded below.
|
||||
|
||||
### IMU scheduling correction
|
||||
|
||||
After HD integration was committed as `6188fcb`, the user reported a small aiming lag. The USB scheduler had two avoidable delays: unsuccessful motion sends advanced the shared timer, and control replies reset the same timer used for motion. It also mutated quaternion/timestamp state before successful USB submission.
|
||||
|
||||
The scheduler now uses an independent logical 15 ms motion clock, checks endpoint readiness before integrating, retries overdue data at the next ready opportunity, and rolls back motion state if queuing fails. Successful sends advance the logical clock rather than drifting with 8 ms USB polling. Long stalls skip missed periods instead of replaying a catch-up burst. Control replies and overdue motion get bounded service without one starving the other. No gyro scaling or smoothing was changed.
|
||||
|
||||
New regressions failed on the old implementation and passed with the correction. Live USB checks measured about 15 ms average motion-report spacing, including during control-reply traffic; four deliberate backpressure trials recovered with successive-read gaps of about 7.96–7.98 ms. The user retested aiming on the Switch and reported it was “pretty good now.”
|
||||
|
||||
### Accepted rumble response
|
||||
|
||||
The user described the 1.5x mix as thin/hollow and lacking body. A 2x low / 1.5x high comparison was still insufficient; the user requested more high-band response and selected a fuller amplitude curve. The final balanced 2x / 0.8-power response was tested on the device and in the same game, and the user selected **“Fuller and good.”**
|
||||
|
||||
The final USB-driven test delivered all **513 host commands**, with **zero dropped updates**, **zero Bluetooth skips**, and **zero send failures**. Controller input continued with 1,408 reports during the test. Maximum observed packet generation was 687 us and report gap 23,925 us. Local-feedback gain, frequencies, the 50 ms watchdog, the 21.333 ms lookback, and controller buffering remain unchanged.
|
||||
|
||||
Final checks: **151 focused tests passed**. HD gameplay, deterministic experiment, normal all-in-one, and Pico/UART builds succeeded. The accepted response and IMU scheduler correction are flashed; image files are `build-hd-rumble/switch-pico.elf` and `.uf2`. The stream remains armed. These follow-on refinements are separate from the committed HD integration.
|
||||
|
||||
### Qualified lower-latency transport and standard-image cutover
|
||||
|
||||
The following comparisons used real 8 ms USB rumble output while continuously
|
||||
reading USB motion reports, not only an idle PCM fixture. Read/poll means
|
||||
include empty calls and nested work and must not be interpreted as additive
|
||||
CPU utilization.
|
||||
|
||||
| Configuration | Mean read call | Mean poll call | Result |
|
||||
|---|---:|---:|---|
|
||||
| Stock 150 MHz | 971 us | 2,494 us | 2,050 commands received; six skipped audio slots |
|
||||
| 300 MHz / 1.3 V | 665 us | 1,370 us | 2,050 commands; zero drops/skips/send failures |
|
||||
| 400 MHz / 1.4 V | 730 us | 1,485 us | 2,050 commands; zero drops/skips/send failures |
|
||||
| 300 MHz + packet-level reads | 421 us | 1,139 us | 2,050 commands; zero drops/skips/send failures |
|
||||
| 300 MHz + packet reads + credit batching | 274 us | 572 us | 2,050 commands; zero drops/skips/send failures |
|
||||
|
||||
Packet-level reads use one published ring-index snapshot and one consumer
|
||||
publication/notification per complete packet. Partial packets stay
|
||||
unconsumed; a build-local SDK patch propagates SPI read errors. Credit
|
||||
batching retains controller-to-host flow control and three host ACL credits,
|
||||
returning at two completed packets or a bounded timer deadline. SCO remains
|
||||
immediate. Lifecycle cancellation and synchronous callbacks have regressions.
|
||||
|
||||
The packet-read stress run passed 8,194 commands with zero drops/skips/failures.
|
||||
The subsequent 32-frame run also passed all 8,194 commands over about 65 s,
|
||||
submitted 6,165 audio reports, and had a worst observed report gap of
|
||||
12,594 us (nominal 10,666.67 us). No physical actuator-onset bound is claimed.
|
||||
|
||||
The user described the controlled test effects as notably good. Preserve this
|
||||
reference separately from actual game effects: alternate 256 ms of left word
|
||||
`0x68402100` / neutral right, then neutral left / right word `0x4840a100`,
|
||||
refreshing every 8 ms. Neutral is `0x40400100`; words are little-endian in the
|
||||
eight-byte rumble payload of USB report `0x10`. These are fixed 160/320 Hz
|
||||
bands with primary/secondary amplitude codes 80/16, using the normal profile
|
||||
scaling and 2x/0.8 response—not an extra test-only boost. Sustained reference
|
||||
effects do not establish that short or swept game effects will feel identical.
|
||||
|
||||
Native XInput was exercised with the DualSense on API slot 1 while a Switch
|
||||
Pro occupied slot 0. The Pro received two compatibility dispatches without
|
||||
entering the native stream; four DualSense commands held left/right output
|
||||
for 700 ms and explicit zeros stopped it. The stream stayed selected on the
|
||||
DualSense and configuration generation remained 9. The former slot-0-only
|
||||
auto-arm condition was removed and a mixed-controller regression verifies
|
||||
first-eligible selection without a later controller stealing ownership.
|
||||
|
||||
Final integrated verification includes 228 passing repository tests, standard
|
||||
AIO/XInput builds with the native defaults, the manual fixture and unchanged
|
||||
Pico/UART builds. The profile catalog was migrated and all sixteen stored
|
||||
profiles were compared with the pre-migration backup; the temporary editor
|
||||
profile and name were restored. No configuration, bond, or wake-identity reset
|
||||
was part of the transport work.
|
||||
|
||||
### Mixed-controller cadence limit
|
||||
|
||||
Final testing with a Switch Pro plus a DualSense and continuous USB motion
|
||||
reads changed the cadence decision. Rumble commands targeted only the
|
||||
DualSense; the Pro supplied concurrent input traffic, not a Pro rumble
|
||||
qualification. The 32-frame stream received all 2,050
|
||||
host commands but skipped **80 audio slots in 16.6 s**. Maximum permission
|
||||
wait reached **17,180 us**, exceeding its 10,667 us interval, with all eight
|
||||
outgoing credits observed in use. CPU clock remained 300 MHz/1.3 V.
|
||||
|
||||
The standard build therefore uses **64 frames / 46.875 reports per second**,
|
||||
without reverting its CPU or transport improvements. The same 2,050-command
|
||||
mixed-controller comparison passed with zero drops/skips/send failures and
|
||||
778 audio reports. Its worst observed report gap was 26,588 us. A subsequent
|
||||
roughly 65-second mixed-controller stress run received all **8,194 commands**
|
||||
and submitted **3,082 audio reports**, with **zero drops, skipped audio slots,
|
||||
or send failures**. It processed 41,738 input reports and its worst observed
|
||||
audio report gap was 26,655 us.
|
||||
|
||||
The 32-frame path remains an explicit single-controller experiment and is
|
||||
covered by the same native protocol/lifecycle tests; it is not advertised as
|
||||
sustainable for mixed/four-controller operation.
|
||||
201
README.md
201
README.md
|
|
@ -66,6 +66,107 @@ The default `python3 build.py` command and `firmware/switch-pico.*` artifacts re
|
|||
|
||||
Both `build.py --aio` and direct AIO CMake configuration copy the pinned Bluepad32 source into the active build directory and apply `patches/bluepad32-sdl3-imu.patch` there before compiling. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. The `external/bluepad32` submodule remains pristine; patch or source-revision drift fails configuration.
|
||||
|
||||
### Switch 2 wake from L + R + Home, PS, or Xbox
|
||||
|
||||
The AIO firmware can wake a sleeping Switch 2 when a connected controller's
|
||||
physical **L + R + System** chord becomes held: L + R + Home on
|
||||
Nintendo-style controllers, L1 + R1 + PS on PlayStation controllers, or
|
||||
LB + RB + Xbox on Xbox controllers. Plain Home, PS, or Xbox remains a normal
|
||||
button and does not start wake advertising. Setup needs one wake advertisement
|
||||
captured from a Joy-Con 2 already paired with that Switch 2. The generated
|
||||
configuration is console-specific and is intentionally ignored by Git.
|
||||
|
||||
The implementation replays the captured, unencrypted Switch 2 BLE wake
|
||||
advertisement for two seconds at a 20 ms base interval. The configured AIO
|
||||
firmware adopts the captured Joy-Con's public Bluetooth address once during
|
||||
startup, before Bluepad32 admits controller connections. Wake bursts then
|
||||
require no identity change and do not disconnect the input controller. This
|
||||
follows the packet format documented by
|
||||
[`ndeadly/switch2_controller_research`](https://github.com/ndeadly/switch2_controller_research/blob/master/bluetooth_interface.md)
|
||||
and the capture/replay approach demonstrated by
|
||||
[`alexvnesta/switch2controller`](https://github.com/alexvnesta/switch2controller)
|
||||
and the MIT-licensed
|
||||
[`Switch2-Wake-Beacon-ESPHome`](https://github.com/sickyj/Switch2-Wake-Beacon-ESPHome).
|
||||
|
||||
Back up the complete Pico flash before replacing the AIO firmware with the
|
||||
temporary capture image:
|
||||
|
||||
```sh
|
||||
picotool save -a -v switch-pico-before-wake-capture.uf2
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
1. Connect the Pico 2 W to the computer and build/flash the one-shot capture
|
||||
firmware:
|
||||
|
||||
```sh
|
||||
python3 build.py --wake-capture
|
||||
```
|
||||
|
||||
This also publishes `firmware/switch-pico-wake-capture.elf` and
|
||||
`firmware/switch-pico-wake-capture.uf2`.
|
||||
|
||||
2. Start the configuration tool. It auto-detects a single Pico USB serial
|
||||
port; use `--port /dev/ttyACM0` when more than one Pico is attached:
|
||||
|
||||
```sh
|
||||
python3 tools/configure_switch2_wake.py
|
||||
```
|
||||
|
||||
3. Detach a Joy-Con 2 that is already paired with the target Switch 2, put the
|
||||
console to sleep, and press that Joy-Con's Home button. Do not press its
|
||||
sync button. The capture firmware accepts the first public `ADV_IND` packet
|
||||
with Nintendo's Switch 2 wake flag and nonzero console address, stops
|
||||
scanning automatically, lights the onboard LED solid, and repeats the
|
||||
captured record until the tool receives it.
|
||||
|
||||
4. The tool validates the packet and atomically writes
|
||||
`src/firmware/platform/pico/switch2_wake_config.h`.
|
||||
|
||||
5. Restore the AIO firmware with the generated wake configuration:
|
||||
|
||||
```sh
|
||||
python3 build.py --aio
|
||||
```
|
||||
|
||||
6. The Pico now has a new stable Bluetooth host address. Clear its old
|
||||
Bluepad32 bonds by holding BOOTSEL for ten seconds, then open a pairing
|
||||
window and pair each input controller again. This is a one-time re-pair.
|
||||
|
||||
|
||||
The capture tool also accepts a saved serial log:
|
||||
|
||||
```sh
|
||||
python3 tools/configure_switch2_wake.py --input switch2-joycon-capture.log
|
||||
```
|
||||
|
||||
With the configured AIO firmware powered while the console sleeps, first turn
|
||||
on the paired input controller with Home, PS, or Xbox and let it reconnect to
|
||||
the Pico. Then hold L + R and press its system button to send one wake burst.
|
||||
Holding the chord does not retrigger it; release at least one chord button
|
||||
before another attempt. Plain Home, PS, or Xbox is forwarded normally and does
|
||||
not disturb the radio. Because the wake identity is stable from startup,
|
||||
the wake code itself does not disconnect the input controller.
|
||||
|
||||
Hardware testing found that the Switch 2 can briefly remove USB power while
|
||||
entering or leaving sleep. A Pico powered only by that port necessarily
|
||||
reboots, resetting the CYW43439 and dropping every controller link regardless
|
||||
of the wake implementation. Continuous controller connectivity therefore
|
||||
requires a properly isolated powered USB hub or another power arrangement
|
||||
that keeps the Pico powered without backfeeding the console. Do not use an
|
||||
unisolated USB Y-cable.
|
||||
|
||||
The configured Pico continuously owns the captured Joy-Con's public Bluetooth
|
||||
address, so keep that Joy-Con inactive while the AIO firmware is running to
|
||||
avoid two radios using one address.
|
||||
|
||||
To target another Switch 2, repeat the capture and configuration steps. To
|
||||
disable wake, delete the generated `switch2_wake_config.h`, rebuild the AIO
|
||||
firmware, clear the Pico's bonds, and pair the input controllers to its restored
|
||||
factory address. Restore the full-flash backup only if you need to recover the
|
||||
exact pre-capture firmware and persistent state.
|
||||
|
||||
### Pairing up to four controllers
|
||||
|
||||
1. Flash and connect the Pico 2 W to the Switch.
|
||||
|
|
@ -122,6 +223,7 @@ uv run switch-pico-config mode xinput
|
|||
uv run switch-pico-config mode dinput
|
||||
uv run switch-pico-config mode mac
|
||||
uv run switch-pico-config profiles list
|
||||
uv run switch-pico-config profiles edit
|
||||
uv run switch-pico-config profiles export 1 profile.json --identity 0
|
||||
uv run switch-pico-config profiles import 2 profile.json --identity 0
|
||||
uv run switch-pico-config profiles activate 2 --identity 0
|
||||
|
|
@ -130,40 +232,62 @@ uv run switch-pico-config pairings list
|
|||
uv run switch-pico-config pairings clear --yes
|
||||
```
|
||||
|
||||
Adapter configuration records use version, size, generation, and CRC fields in two dedicated flash sectors. Profiles use a separate two-bank atomic store before the adapter and Bluepad32 bond regions. Profile writes are chunked, verified before the new bank is committed, recover the previous generation after interruption or corruption, skip unchanged data, and are rate-limited.
|
||||
Adapter configuration records use version, size, generation, and CRC fields in two dedicated flash sectors. Profiles use separate append-only arenas before the adapter and Bluepad32 bond regions. Profile writes are individually checksummed, recover the previous record after interruption or corruption, compact atomically between arenas, skip unchanged data, and are rate-limited.
|
||||
|
||||
Output mode is selected before TinyUSB starts and never changes while mounted. A mode command atomically stores the selection, resets synthetic input, reboots, follows the same physical USB port through re-enumeration, and verifies requested versus active mode. `auto` uses the verified Switch probe → Windows XInput transition; manual modes bypass probing. The controller chord **L + R + Select + Start + System** held for three seconds cycles `auto → switch → xinput → dinput → mac → auto`. The destructive ten-second BOOTSEL pairing reset also restores `auto` before reboot, providing physical recovery.
|
||||
|
||||
Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and `CAFE:4021` (Mac). DInput and Mac expose four input-only generic HID interfaces and no rumble. Mac uses X/Y/Z/Rx sticks plus Simulation Brake/Accelerator triggers. Switch reports input, rumble, and motion capability; XInput reports input and rumble.
|
||||
|
||||
`profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns four persistent profiles and one active index. Exported JSON contains direct logical button mappings, independent stick and trigger calibration/curves, digital trigger thresholds, weak/strong rumble scales, a profile-switching chord, one bounded eight-step macro, and per-button Turbo modes. Profile numbers shown to users are `1` through `4`; `--identity` uses the zero-based index from `profiles list`.
|
||||
`profiles edit` starts a local-only browser editor at `http://127.0.0.1:8765/`. It exposes every profile field: all 16 buttons plus the L2/R2 analog triggers can be remapped to any button or trigger output; both sticks and triggers retain independent deadzone/saturation/curve settings; and rumble, confirmation, Turbo/Auto Burst, built-in action chords, and four custom macro sequences are editable. Its live playtest compares current raw stick and trigger input with the unsaved draft, shows deadzone/saturation boundaries and digital thresholds, and highlights pressed physical controls. Select a controller identity and one of its eight profile slots, use **Start from defaults** for a new draft, then **Save to Pico**. The backend validates the complete profile before using the existing chunked atomic transaction; invalid drafts never reach flash. Use `profiles edit --no-browser` for a printed URL or `profiles edit --port PORT` to choose another local port.
|
||||
|
||||
The editor selects Switch Pro, DualSense, or Xbox artwork from the connected controller's USB VID/PID and places each remappable control directly over the matching physical button. Controller artwork is from [AL2009man/Gamepad-Asset-Pack](https://github.com/AL2009man/Gamepad-Asset-Pack) under its MIT license; the bundled license and source revision are recorded beside the assets.
|
||||
|
||||
Profile names and controller aliases are stored as independently checksummed
|
||||
catalog metadata. Runtime profiles use schema 6 and 384-byte records; names remain separate. The
|
||||
editor can rename and copy profiles across controllers and slots, import or
|
||||
export JSON backups, and reset one section without discarding the rest of the
|
||||
draft. Its response-curve cards provide named presets, exact Q8.8 fine
|
||||
adjustment, live curve markers, and one-click application to the opposite
|
||||
stick or trigger. Connected-controller details include transport, battery,
|
||||
and supported feedback/motion capabilities; **Identify** sends one bounded
|
||||
rumble/light pulse only to the selected live controller.
|
||||
|
||||
`profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns eight persistent profiles and one active index. The JSON export/import commands remain available for version-controlled or scripted profiles. Profile numbers shown to users are `1` through `8`; `--identity` uses the zero-based index from `profiles list`.
|
||||
|
||||
`pairings list` refreshes and prints stored Bluetooth Classic and BLE addresses. `pairings clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device.
|
||||
|
||||
`diagnostics` reports Bluetooth initialization stage, real BTstack timer
|
||||
callbacks, controller report traffic, host/local rumble requests and
|
||||
dispatches, active/rumble-capable slot counts, and pending feedback. The AIO
|
||||
build bounds each CYW43 HCI drain to 16 packets so continuous multi-controller
|
||||
traffic returns to BTstack timers instead of starving rumble stop/refresh,
|
||||
configuration, pairing, and profile work.
|
||||
build services one CYW43 packet per poll and explicitly reschedules remaining
|
||||
input. Packet-level ring reads and bounded incoming-credit batching reduce
|
||||
bus work without disabling flow control. `haptics-experiment profile --json`
|
||||
adds transport timings, clock/voltage settings and packet-size diagnostics.
|
||||
|
||||
### Per-controller profiles
|
||||
|
||||
The default profile-switching chord is **L + R + Select + Start**. On DualSense, use **L1 + R1 + Create + Options**. A profile can replace this chord with any nonzero logical-button mask; a stored zero selects the default chord.
|
||||
The profile editor lists **Cycle active profile**, **Toggle motion**, and **Run custom macro** as separate editable actions. Every action chord can contain any combination of the 16 buttons and the L2/R2 analog triggers. The default profile-switching chord is **L + R + Select + Start**; on DualSense, use **L1 + R1 + Create + Options**. A stored empty chord selects that default.
|
||||
|
||||
- The chord cycles persistent profiles `1 → 2 → 3 → 4 → 1`.
|
||||
- The chord cycles persistent profiles `1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 1`.
|
||||
- Chord buttons are consumed locally and are not forwarded to the host.
|
||||
- The new profile applies only after its atomic flash commit completes.
|
||||
- Confirmation uses one to four 75 ms pulses matching the active profile number.
|
||||
- Confirmation uses one to eight 75 ms pulses matching the active profile number.
|
||||
- The profile policy independently enables rumble and LED feedback.
|
||||
- On connection and profile changes, RGB/player LEDs briefly show the active profile color/count, then return to the persistent USB slot color/player number.
|
||||
- Each controller identity and each of the four active USB slots remain isolated.
|
||||
|
||||
Profile input processing is deterministic: physical input is mapped and tuned first, Turbo or Auto Burst gates configured buttons second, and active macro overrides apply last. Turbo runs at 15 activations per second while held. Auto Burst starts on one press and stops on a second press or the configured macro-cancel button. Macros contain at most eight state steps plus an explicit end and are cancelled on disconnect, profile/output-mode change, configured cancellation, or adapter configuration reset.
|
||||
Profile hotkeys use physical controls, with trigger thresholds from the base profile. Reserved output-mode handling runs first, followed by direct profile shortcuts, profile cycling, and motion toggle. Shift consumes its modifier, macro bindings use unshifted controls, Turbo gates physical button sources, the selected button map and base analog transforms produce output, and explicit macro overrides apply last.
|
||||
|
||||
- **Direct shortcuts:** assign one modifier and unique face/D-pad selectors to profiles 1–8. Chord controls are consumed; holding or changing a profile cannot retrigger the same press. Activation and feedback wait for the atomic commit. The existing one-second minimum commit interval still applies to rapid successive changes.
|
||||
- **Shift:** one alternate button-only map, enabled while held or toggled on a fresh modifier press. It does not layer analog tuning, Turbo settings, or macro definitions. Toggle state resets with connection, profile, mode, and configuration changes.
|
||||
- **Turbo/Burst:** shared settings with optional per-button overrides; 1–30 Hz, 1–99% duty, and 1–255 finite Burst pulses. Hold Turbo follows the button, Auto Burst toggles continuous repetition, and Burst runs its configured count after a press. Defaults remain 15 Hz/50%. Counts describe scheduled ON windows; narrow phases can be missed by host report sampling, so the editor warns instead of silently changing settings.
|
||||
- **Macros:** four independently triggered sequences, up to eight steps each, sharing sixteen decoded steps and a **136-byte sparse stream**. Wait/button/all-field steps cost 3/5/17 bytes. Playback supports Once, While held, Toggle, and bounded Repeat (1–255 cycles). Repeating zero-duration sequences are rejected; clock gaps skip elapsed cycles rather than replay a backlog.
|
||||
- **Authoring:** insert, duplicate, remove, drag-reorder, or move steps with keyboard controls. Duration and byte budgets update on every edit. The visual preview shows overridden versus passthrough fields and never injects controller output.
|
||||
- **Recording:** Record input captures timestamped firmware-side changes before profile mapping, not the editor's 75 ms snapshots. Choose channels and explicit analog quantization; the initial state consumes one entry and long holds split at ten seconds. The UI limits capture to the remaining 8/16/136 budget, visibly reports capacity/time/disconnect endings, and retains data for review. **Use recorded steps** changes only the unsaved macro; **Save to Pico** is still separate. Input-report and millisecond playback precision remain real limits.
|
||||
|
||||
### Per-controller motion toggle
|
||||
|
||||
Press **D-pad Up + R + Start** together to disable or re-enable motion for one controller. On DualSense, use **D-pad Up + R1 + Options**.
|
||||
The default motion action is **D-pad Up + R + Start**; on DualSense, use **D-pad Up + R1 + Options**. Each profile can replace it with any button/trigger chord from the graphical editor; a stored empty chord selects the default.
|
||||
|
||||
- A longer rumble confirms motion disabled.
|
||||
- A shorter rumble confirms motion enabled.
|
||||
|
|
@ -171,7 +295,7 @@ Press **D-pad Up + R + Start** together to disable or re-enable motion for one c
|
|||
- Other controller slots are unaffected.
|
||||
- Motion returns to enabled after disconnect or reboot.
|
||||
|
||||
Edit `controller_hotkey_config.h` to change the chord, default state, or feedback patterns.
|
||||
Edit `src/firmware/input/controller_hotkey_config.h` only to change the default motion-enabled state or its feedback patterns.
|
||||
|
||||
### Per-slot controller colors
|
||||
|
||||
|
|
@ -199,7 +323,15 @@ Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and
|
|||
|
||||
### Rumble per controller
|
||||
|
||||
Rumble effects are per-slot and independent. The Switch sends rumble commands to a specific USB interface, and the Pico routes each command to the Bluetooth controller in the matching slot. Each slot has a critical-section-protected latest-value mailbox tagged with its connection generation; a newer pending command replaces the older one, and disconnect invalidates commands from the prior controller.
|
||||
Commands remain bound to a USB slot and Bluetooth connection generation. Compatibility output uses a latest-value mailbox; native output keeps a bounded timestamped command history instead of collapsing substeps.
|
||||
|
||||
The standard AIO and XInput builds now use **300 MHz at 1.3 V**, packet-level CYW43 reads, bounded HCI credit returns, and native DualSense haptics by default. The first eligible DualSense/DualSense Edge that becomes ready can occupy the one native stream, in any slot; later controllers do not steal it. Other rumble-capable controllers retain their tested parser-specific output. To change the selected native controller manually, stop the current run and use `haptics-experiment gameplay --slot N` (API slots are zero-based).
|
||||
|
||||
In Switch mode, that stream preserves decoded left/right, low/high-band HD commands. In XInput mode, strong/low magnitude drives the left 160 Hz carrier and weak/high drives the right 320 Hz carrier; these commands stay active until changed or stopped. XInput does not supply Nintendo frequency/substep detail. USB reset, unmount, and suspend stop held host rumble. Auto-mode XInput additionally reboots to Switch probe after unmount, by the existing one-attachment policy; manual XInput is exempt.
|
||||
|
||||
Standard native gameplay uses **64 stereo frames at 3 kHz** per Bluetooth report (46.875 reports/s), with 21.333 ms causal lookback. The 32-frame mode passed single-controller tests but skipped audio slots under mixed Pro/DualSense load, so it is an explicit experiment: `SWITCH_PICO_HD_PACKET_FRAMES=32` requires the optimized transport and at least 300 MHz. It uses 93.75 reports/s and 10.667 ms lookback but is not the mixed-controller default. Native streaming continues silence while idle; no physical actuator-onset bound is claimed. See [HAPTICS_EXPERIMENT.md](HAPTICS_EXPERIMENT.md).
|
||||
|
||||
400 MHz is an explicit experiment: use `SWITCH_PICO_SYS_CLOCK_MHZ=400` and `SWITCH_PICO_OVERCLOCK_MV=1400`. This board did not boot at 400 MHz/1.3 V; 1.4 V booted and passed a short run but did not outperform 300 MHz in the comparison. USB stays at 48 MHz and flash/radio bus dividers remain bounded. UART builds are unchanged; a stock-clock AIO build is an explicit recovery/compatibility option, not the normal default.
|
||||
|
||||
### Hardware validation
|
||||
|
||||
|
|
@ -223,7 +355,7 @@ swift tools/Test-SwitchPicoMac.swift
|
|||
|
||||
The diagnostic matches only `CAFE:4021`, identifies each of the four interfaces by interface and location, and prints changed axes, hats, and buttons with their HID usage and logical range. The four signed stick axes remain `X`/`Y`/`Z`/`Rx`. Move each analog trigger slowly and confirm output such as `LeftBrake page=0x02 usage=0xC5 logical=0...65535 value=32768` and `RightAccelerator page=0x02 usage=0xC4 logical=0...65535 value=32768`; each trigger should traverse intermediate values across `0...65535`, not only the endpoints. The diagnostic continues through hot-plug events until Ctrl-C. If opening a device fails, allow the terminal (or the app launching Swift) under **System Settings → Privacy & Security → Input Monitoring**, then rerun it.
|
||||
|
||||
On the tested Linux host, all four HID interfaces enumerated, but `hid-nintendo` timed out (`-110`) while requesting controller information from the composite device and removed the transient hidraw nodes. This is an observed, undiagnosed composite interoperability limitation; its root cause has not been established. The timeout was not observed on the Switch, so successful `hid-nintendo` binding is not the release criterion for the four-interface AIO firmware. The pairing CLI uses vendor control transfers on endpoint 0 and does not depend on those hidraw nodes.
|
||||
On the tested Linux host, all four HID interfaces enumerate immediately at the USB layer, but `auto` initially presents them as a composite Nintendo Pro Controller while probing the host. Linux binds `hid-nintendo` to each interface and performs synchronous handshake and calibration requests with retries; incomplete composite interoperability causes `-110` timeouts and can accumulate into a 15–30 second user-visible delay before the transient hidraw nodes are removed. The timeout is not observed on the Switch. For a Linux laptop, persist `dinput` for immediate generic-HID enumeration (`uv run switch-pico-config mode dinput`) or `xinput` when rumble is required, then restore `auto` or `switch` before console use. Profile management uses endpoint-zero vendor transfers and does not depend on `hid-nintendo`.
|
||||
|
||||
Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry Pi's BTstack license.
|
||||
|
||||
|
|
@ -231,7 +363,7 @@ Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry
|
|||
|
||||
## Limitations
|
||||
- No NFC/amiibo/IR support.
|
||||
- Rumble is best-effort: the UART build depends on SDL3 haptics; the AIO build depends on the connected controller's Bluepad32 rumble implementation.
|
||||
- Rumble is controller-specific: UART uses SDL3 haptics; AIO uses the selected DualSense native PCM backend or the controller's existing Bluepad32 rumble implementation. Native Switch-family forwarding remains planned.
|
||||
- The UART firmware requires a host computer running the bridge. The Pico 2 W AIO firmware does not; it hosts controllers over Bluetooth, not USB.
|
||||
- In XInput output mode, Home/System is carried in the raw XUSB Guide bit `0x0400`, and Capture is carried in the de-facto Share/reserved bit `0x0800` used by modern open XUSB stacks. The standard Microsoft XInput headers define neither Guide nor Share for `XINPUT_GAMEPAD.wButtons`, so `XInputGetState` does not expose either button portably. Guide may be reserved or intercepted by the OS, while Share/Capture support depends on the installed driver or consumers such as GameInput and Steam; qualify the intended controller, driver, and application on real Windows hardware.
|
||||
|
||||
|
|
@ -263,7 +395,7 @@ RUMBLE (force feedback)
|
|||
|
||||
### HD rumble translation
|
||||
|
||||
Nintendo sends two stateful four-byte HD-rumble actuator words. Each word can carry full or relative high/low frequency and amplitude commands with up to three subsamples; amplitude uses a logarithmic curve. The Pico decodes both words once in `SwitchHapticsDecoder`, retains actuator state across packets, and reduces the result to conventional low/strong and high/weak motor magnitudes. SDL3 and Bluepad32 cannot reproduce the original linear-actuator frequencies or left/right spatial effects, but they receive the correct nonlinear band amplitudes.
|
||||
Nintendo sends two stateful four-byte HD-rumble actuator words with full/relative low/high-band commands and up to three substeps. `SwitchHapticsDecoder` retains this timeline as well as conventional strong/weak magnitudes. The selected DualSense's native PCM backend uses the timeline; ordinary controller-parser and UART/SDL paths use the magnitudes. Preserving frequency intent is not a claim of identical force response across actuators. Native forwarding for genuine Switch-family controllers is [planned separately](ADAPTER_PARITY_PLAN.md#native-switch-family-hd-rumble--planned), not enabled by the DualSense implementation.
|
||||
|
||||
The UART return frame carries the decoded result rather than raw HD-rumble bytes:
|
||||
|
||||
|
|
@ -589,6 +721,45 @@ After changing any IMU conversion, calibration, timing, or report packing:
|
|||
5. Inject a known single-axis gyro rate and decode the packed quaternion. The corresponding component must change smoothly with the expected sign.
|
||||
6. Perform the decisive end-to-end check: genuine Pro Controller → SDL3 bridge → UART → emulated Pico → Zelda. This path was confirmed correct after the mode-2 fix.
|
||||
|
||||
## Firmware resource usage
|
||||
|
||||
The Pico 2 W AIO build is measured from `build-aio/switch-pico.elf` and its
|
||||
linked binary, not from the larger debug-bearing ELF or UF2 transport file:
|
||||
|
||||
| Resource | Used or reserved | Device capacity |
|
||||
|---|---:|---:|
|
||||
| Executable flash image | 746,568 bytes | 4 MiB |
|
||||
| Indexed profile arenas | 256 KiB | 4 MiB flash |
|
||||
| Adapter configuration | 8 KiB | 4 MiB flash |
|
||||
| BTstack bonds | 8 KiB | 4 MiB flash |
|
||||
| RP2350 terminal sector | 4 KiB | 4 MiB flash |
|
||||
| Allocated/reserved SRAM, including heap and stacks | 130,784 bytes | 520 KiB |
|
||||
|
||||
The executable plus persistent reservations consume 1,029,192 bytes of flash,
|
||||
leaving 3,165,112 bytes. Allocated SRAM sections leave 401,696 bytes of link-time
|
||||
headroom; this is not a runtime heap high-water measurement. Core 0 has a
|
||||
4 KiB stack, and Core 1 uses a dedicated 16 KiB stack in main SRAM for nested
|
||||
catalog migration/compaction rather than overflowing its 4 KiB scratch bank.
|
||||
|
||||
Profiles use two 128 KiB append-only arenas and retain 248 physical record
|
||||
slots. Catalog 2 uses a 128-byte header plus a 384-byte profile in the same
|
||||
512-byte stride. The second page is programmed before the header-containing
|
||||
first page, and records are read back before publication. Profile names
|
||||
remain 256-byte metadata payloads and aliases remain 32 bytes. The compact
|
||||
index stores locations and generations; active/fallback profiles for observed
|
||||
identities and the selected profile are decoded, not the entire database.
|
||||
|
||||
The catalog supports eight profiles for the global fallback and each of 16
|
||||
stable identities. Missing records resolve to defaults, so profiles 5–8 do
|
||||
not consume flash until changed. When an arena fills, the latest indexed
|
||||
records are compacted into its peer and the new superblock is published last.
|
||||
Interrupted or corrupt appends leave the previous valid record available.
|
||||
Catalog 1 and retired four-profile banks migrate through the alternate arena;
|
||||
the old published data is retained until all copies and the new superblock
|
||||
verify. Schema 1–5 profiles retain their meaning when decoded as schema 6.
|
||||
Keep a profile export before downgrading: older firmware cannot read the new
|
||||
catalog/profile format.
|
||||
|
||||
## References
|
||||
- GP2040-CE (controller firmware ecosystem): https://github.com/OpenStickCommunity/GP2040-CE
|
||||
- nxbt (Switch controller research/tools): https://github.com/Brikwerk/nxbt
|
||||
|
|
|
|||
153
SWITCH_FAMILY_HD_RUMBLE_PLAN.md
Normal file
153
SWITCH_FAMILY_HD_RUMBLE_PLAN.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Switch-family native HD-rumble implementation handoff
|
||||
|
||||
Status: **planned, not implemented**. This document is self-contained for another coding agent. The shorter project-roadmap version is in `ADAPTER_PARITY_PLAN.md` under “Native Switch-family HD rumble — Planned”.
|
||||
|
||||
## Goal
|
||||
|
||||
Deliver native Nintendo rumble commands to qualified Switch-family Bluetooth controllers without collapsing left/right and low/high-band information to conventional motor magnitudes. This is a separate backend from DualSense PCM synthesis.
|
||||
|
||||
Start with a genuine original Switch Pro Controller, then original standalone Joy-Con L/R. Keep third-party Switch-mode controllers on their existing tested fallback until individually qualified. Do not implement Joy-Con logical pairing or assume Switch 2/NSO controllers share the same capabilities.
|
||||
|
||||
## Current project state and coordination
|
||||
|
||||
- Branch at handoff: `feature/controller-profiles`.
|
||||
- `44a474e`: roadmap plan for native Switch-family HD rumble.
|
||||
- `3c2d9fa`: Set B profiles, catalog migration, recording, optimized transport and native DualSense/XInput work.
|
||||
- Other transport qualification/artifact work may still be in progress. Coordinate with the active agent before editing shared files; do not reset, stash, or overwrite its changes. Re-read current code rather than relying on line numbers here.
|
||||
- Standard AIO/XInput builds now use 300 MHz/1.3 V and the optimized CYW43 transport. The first eligible DualSense may own one native PCM stream, regardless of slot. Other models retain their controller-specific rumble path.
|
||||
- Profiles are schema 6 / 384 bytes; catalog 2 keeps a 512-byte record stride and two 128 KiB arenas. Preserve migration, identity keys, names, active indices and atomic publication.
|
||||
- Preserve Bluetooth bonds, calibration, the UART wire protocol and the private `src/firmware/platform/pico/switch2_wake_config.h`. Do not expose that file's contents or change the configured wake identity.
|
||||
|
||||
### Important timing qualification caveat
|
||||
|
||||
Do not generalize single-controller DualSense results to mixed-controller loads. A 32-frame/93.75-packet-per-second run passed roughly 65 seconds with one controller, but a later Switch Pro + DualSense test with continuous USB motion reads recorded **80 skipped audio slots over 16.6 seconds**, despite receiving all 2,050 USB commands with no command drops or send failures. Maximum permission wait was 17,180 us and the eight outgoing ACL credits were observed exhausted. The standard native cadence is consequently **64 frames / 46.875 packets per second** at the same 300 MHz/1.3 V, with 32 frames an explicit experiment. Preserve the current cadence choice and coordinate before changing it as part of this Nintendo backend task.
|
||||
|
||||
The Nintendo path should not need PCM packets at all. Its small native commands have a different bandwidth budget, which still needs real multi-controller measurement.
|
||||
|
||||
## Read these code paths first
|
||||
|
||||
| Area | Files / symbols | Relevant facts |
|
||||
|---|---|---|
|
||||
| Switch host decoder | `src/firmware/usb/switch/switch_haptics.h/.cpp`, `SwitchHapticsDecoder`, `ControllerRumbleOutput`, `SwitchHapticsFrame` | Two sides, each with up to three decoded low/high frequency/amplitude substeps. Original eight wire bytes are not retained in the output envelope. |
|
||||
| Intensity scaling | `src/firmware/profile/controller_profile_transform.cpp`, `controller_profile_scale_host_rumble` | Strong scales low band and weak scales high band on both sides. Q15 amplitudes are decoder-normalized values, not raw Nintendo amplitude codes. |
|
||||
| Routing and lifetime | `src/firmware/input/bluepad32_input_backend.cpp` | Generation-tagged slots, compatibility mailbox, native submission, local/profile feedback, controller ready/disconnect events. Preserve slot isolation. |
|
||||
| Existing Nintendo output | `patches/bluepad32-sdl3-imu.patch`; generated `build-aio/_deps/bluepad32-src/src/components/bluepad32/parser/uni_hid_parser_switch.c` | Modify the patch/build-local copy, not the upstream checkout or arbitrary SDK files. |
|
||||
| Parser functions | `send_subcmd`, `switch_encode_rumble`, `switch_send_dual_rumble_now`, `switch_stop_rumble_now`, `set_led`, `fsm_enable_rumble` | Existing conventional path enables vibration with 0x48, uses fixed frequencies and a 40 ms refresh. |
|
||||
| Transport | `src/firmware/input/haptics_transport_probe.*`, `src/firmware/platform/pico/cyw43_packet_transport.c`, `patches/btstack-credit-batch.patch` | Bounded receive fairness, packet-level reads, real per-handle credit accounting. Do not remove incoming flow control or invent extra controller credits. |
|
||||
| Prior evidence | `HAPTICS_EXPERIMENT.md` | Distinguishes measured submission timing from physical actuator onset and preserves the accepted controlled-effect reference. |
|
||||
|
||||
### Existing parser issues the new owner must resolve
|
||||
|
||||
1. `send_subcmd()` uses a process-global four-bit packet counter. Move sequence state to each physical parser/device instance and share it across that device's rumble and subcommand reports.
|
||||
2. Player-LED requests are built with zeroed rumble fields. All applicable subcommands must carry the current effective rumble state while native rumble is active.
|
||||
3. The parser has duration, delayed-start and refresh timers. A second independent native writer cannot safely coexist with those timers or their stale callbacks.
|
||||
4. `switch_encode_rumble()` currently takes one amplitude for both bands of an actuator. It is not a complete encoder for independent low/high amplitudes or all compressed multi-substep forms.
|
||||
5. Bluepad32 explicitly treats Joy-Cons as separate, horizontally mapped controllers. There is no existing two-Joy-Con logical pair to route stereo output into.
|
||||
|
||||
## Model policy
|
||||
|
||||
- Initial target: original genuine Pro Controller `057E:2009`.
|
||||
- Next: original Joy-Con L `057E:2006`, Joy-Con R `057E:2007`.
|
||||
- A Pro-like name, VID/PID or parser type is not sufficient proof of full native compatibility; clones can present the same identity. Use qualified model/firmware evidence and conservative handling of ambiguous devices.
|
||||
- Preserve the 8BitDo Ultimate's tested enable/fixed-frequency/refresh behavior until that exact model passes native qualification.
|
||||
- Do not send DualSense 0x32 PCM packets to Nintendo devices.
|
||||
- Do not apply the DualSense 2x/0.8-power response curve to Nintendo actuators.
|
||||
- Switch 2 controllers and NSO/retro models require separate protocol and actuator qualification. Some Nintendo-family devices have no HD-rumble actuators.
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
### 1. Establish protocol truth with a genuine Pro Controller
|
||||
|
||||
Capture real console USB rumble words and Bluetooth reports for:
|
||||
|
||||
- neutral and explicit stop;
|
||||
- each actuator independently;
|
||||
- low and high bands independently and together;
|
||||
- repeated words, absolute and relative commands;
|
||||
- all observed one/two/three-substep forms;
|
||||
- frequency sweeps and safe amplitude changes.
|
||||
|
||||
Validate accepted Bluetooth encodings, byte order, amplitude normalization and repeated-word behavior. The older public fixed-state tables do not prove every compressed command's behavior. Use independent golden data and real controller acceptance, not only `decode(encode(x))` against the same implementation.
|
||||
|
||||
### 2. Preserve command information and add bounded delivery
|
||||
|
||||
Extend the existing rumble envelope with original eight-byte data and explicit validity/unmodified state while retaining the decoded, profile-scaled timeline. Conventional XInput/UART/local feedback must not accidentally acquire valid raw Nintendo bytes.
|
||||
|
||||
Use a fixed-capacity, generation-tagged Core-0-to-Core-1 command queue. Add one native output owner per physical Switch device, integrated with the existing parser's sender. No heap allocation, PCM FIFO or unbounded catch-up loop.
|
||||
|
||||
Nintendo rumble-only Bluetooth output is report `0x10`: transaction byte, report ID, four-bit counter and eight rumble bytes, **11 bytes before L2CAP/HCI/radio overhead**. At 125 reports/s that is about 1,375 payload bytes/s. If the original command can be sent directly, it need not inherit DualSense's PCM lookback. Measure actual queue/submission delay before making latency claims.
|
||||
|
||||
### 3. Implement scaling and state recovery, not just unity passthrough
|
||||
|
||||
Raw forwarding is a fast path only when profile gain is unity and the physical controller's command state is synchronized. For changed gain, encode the scaled low/high-band timeline using verified Nintendo encodings and safe amplitude limits.
|
||||
|
||||
- Preserve exact silence and valid unity behavior.
|
||||
- Quantize against the supported Nintendo amplitude/frequency representation; do not treat normalized Q15 amplitudes as wire codes.
|
||||
- Retain all representable substeps.
|
||||
- If scaling makes a multi-step sequence impossible to encode in one word, explicitly qualify a bounded legal packet schedule or documented quantization policy. Do not silently replace it with peak/latest magnitude output.
|
||||
- After overflow, dropped history, feedback or reconnect, establish a legal absolute current-state/neutral baseline before forwarding commands that depend on prior state.
|
||||
- Never replay an obsolete vibration backlog to catch up.
|
||||
|
||||
### 4. Centralize LEDs, local feedback, stop and disconnect
|
||||
|
||||
The same per-device owner must serialize rumble-only reports and subcommands. Piggyback current effective rumble into applicable `0x01` reports rather than interrupting an effect with default/zero fields.
|
||||
|
||||
Local confirmation temporarily overrides host output, while the host timeline keeps advancing. After confirmation, resume the current host state, not an expired effect.
|
||||
|
||||
Preserve the existing 50 ms Switch-command expiry policy. Prioritize explicit stop. Cancel all parser rumble timers when transferring ownership or disconnecting; callbacks and queued commands from an old generation must never touch a replacement device. Preserve ordinary setup/calibration and subcommand replies.
|
||||
|
||||
For XInput input, use two-magnitude stateful effects with fixed native carriers and explicit zero/host-lifetime stop. XInput does not contain Nintendo frequency or substep detail.
|
||||
|
||||
### 5. Add explicit single-actuator and third-party behavior
|
||||
|
||||
A standalone Joy-Con has one actuator but is currently exposed as a standalone controller. Do not simply discard whichever host side is absent. Define a deterministic mono downmix preserving each band's dominant contribution, with a documented frequency/tie rule and safe amplitude limits. Spatial fidelity is necessarily lost.
|
||||
|
||||
Routing a stereo left/right pair belongs with a separate logical Joy-Con pairing feature; do not bundle that input-topology change into this task.
|
||||
|
||||
Third-party models remain on their proven compatibility policy unless their native behavior is independently qualified. Retain useful existing rumble rather than broadly enabling an unverified protocol.
|
||||
|
||||
### 6. Qualify and enable per model
|
||||
|
||||
Permanent regression cases should defend observable behavior:
|
||||
|
||||
- independent golden packet/codec vectors and neutral values;
|
||||
- zero, unity and intermediate profile intensity;
|
||||
- band/actuator isolation and substep order;
|
||||
- per-device counter wrap and multiple devices;
|
||||
- LED/subcommand coexistence during effects;
|
||||
- local feedback resume, timeout and explicit stop;
|
||||
- queue pressure, loss/resynchronization, disconnect/reuse;
|
||||
- compatibility fallback and unsupported models.
|
||||
|
||||
Run existing relevant suites, then the complete repository suite:
|
||||
|
||||
```sh
|
||||
uv run pytest -q tests/test_switch_haptics_native.py \
|
||||
tests/test_controller_profile_transform_native.py \
|
||||
tests/test_bluepad32_backend_lifecycle_native.py \
|
||||
tests/test_prepare_bluepad32.py tests/test_usb_output_driver_native.py
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
Build affected AIO, XInput and UART configurations. `build.py` flashes by default; use CMake for build-only checks and coordinate hardware access before flashing.
|
||||
|
||||
Hardware acceptance: safe frequency/amplitude sweeps; actual captured game effects; one versus four controllers; mixed Nintendo/DualSense traffic; simultaneous motion/input, LEDs and profile writes; reconnect and stop. Report p50/p95/p99/worst host-receipt-to-HCI submission, lost/resynchronized commands and physical actuator onset where instrumentation exists. HCI acceptance is not proof of playback.
|
||||
|
||||
Enable native output only for models that pass qualification. Keep the fallback available for ambiguous/unsupported devices. Do not claim full Switch-family support from testing a single Pro Controller.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Nintendo controller firmware updating or modifying calibration flash.
|
||||
- Bond resets or wake-identity changes.
|
||||
- USB host support, audio/headphone output, NFC/IR or adaptive triggers.
|
||||
- New per-game detection, scripts or unbounded action layers.
|
||||
- Re-tuning DualSense gain or general overclock experiments as a side task.
|
||||
- Pretending unmeasured physical latency or cross-actuator force equivalence.
|
||||
|
||||
## Protocol references
|
||||
|
||||
- Reports, neutral values and safe amplitude notes: https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/bluetooth_hid_notes.md
|
||||
- Frequency/amplitude encoding tables: https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/rumble_data_table.md
|
||||
- Current implementation patches and investigation: `patches/bluepad32-sdl3-imu.patch`, `HAPTICS_EXPERIMENT.md`, `ADAPTER_PARITY_PLAN.md`.
|
||||
|
||||
Treat community reverse-engineering references as evidence to validate, not an official guarantee. Preserve applicable source licenses and do not copy noncommercial reference implementations into this project without resolving their licensing.
|
||||
|
|
@ -56,9 +56,13 @@
|
|||
#define MAX_NR_WHITELIST_ENTRIES 16
|
||||
#define MAX_NR_LE_DEVICE_DB_ENTRIES 16
|
||||
|
||||
// Keep controller buffers and controller-to-host flow control enabled to avoid
|
||||
// overrunning the shared CYW43 bus.
|
||||
// Retain the normal firmware's conservative outgoing credit cap. The PCM
|
||||
// experiment uses the controller's advertised ACL capacity (8 on CYW43439);
|
||||
// BTstack still clamps to the real HCI Read Buffer Size response.
|
||||
#ifndef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
#define MAX_NR_CONTROLLER_ACL_BUFFERS 3
|
||||
#endif
|
||||
// Incoming flow control remains enabled; do not overrun the shared CYW43 bus.
|
||||
#define MAX_NR_CONTROLLER_SCO_PACKETS 3
|
||||
#define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL
|
||||
#define HCI_HOST_ACL_PACKET_LEN 1024
|
||||
|
|
|
|||
110
build.py
Normal file → Executable file
110
build.py
Normal file → Executable file
|
|
@ -1,5 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build and flash the project with optional grip color overrides."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import random
|
||||
|
|
@ -15,17 +16,17 @@ CONFIG_FILE = FIRMWARE_SOURCE_DIR / "platform" / "pico" / "controller_color_conf
|
|||
BUILD_DIR = SCRIPT_DIR / "build"
|
||||
AIO_BUILD_DIR = SCRIPT_DIR / "build-aio"
|
||||
FEASIBILITY_BUILD_DIR = SCRIPT_DIR / "build-feasibility"
|
||||
WAKE_CAPTURE_SOURCE_DIR = SCRIPT_DIR / "tools" / "switch2_wake_capture"
|
||||
WAKE_CAPTURE_BUILD_DIR = SCRIPT_DIR / "build-wake-capture"
|
||||
FIRMWARE_DIR = SCRIPT_DIR / "firmware"
|
||||
FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico.elf"
|
||||
FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico.uf2"
|
||||
AIO_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-aio.elf"
|
||||
AIO_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-aio.uf2"
|
||||
FEASIBILITY_FIRMWARE_ELF_PATH = (
|
||||
FIRMWARE_DIR / "switch-pico-adapter-feasibility.elf"
|
||||
)
|
||||
FEASIBILITY_FIRMWARE_UF2_PATH = (
|
||||
FIRMWARE_DIR / "switch-pico-adapter-feasibility.uf2"
|
||||
)
|
||||
FEASIBILITY_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-adapter-feasibility.elf"
|
||||
FEASIBILITY_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-adapter-feasibility.uf2"
|
||||
WAKE_CAPTURE_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-wake-capture.elf"
|
||||
WAKE_CAPTURE_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-wake-capture.uf2"
|
||||
|
||||
ELF_PATH = Path(os.environ.get("ELF_PATH", BUILD_DIR / "switch-pico.elf")).expanduser()
|
||||
UF2_PATH = Path(os.environ.get("UF2_PATH", BUILD_DIR / "switch-pico.uf2")).expanduser()
|
||||
|
|
@ -38,11 +39,14 @@ MACROS = tuple(
|
|||
|
||||
CMAKE_CACHE_PATHS = tuple(
|
||||
build_dir / "CMakeCache.txt"
|
||||
for build_dir in (BUILD_DIR, AIO_BUILD_DIR, FEASIBILITY_BUILD_DIR)
|
||||
)
|
||||
TOOLCHAIN_COMPILER = (
|
||||
"arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc"
|
||||
for build_dir in (
|
||||
BUILD_DIR,
|
||||
AIO_BUILD_DIR,
|
||||
FEASIBILITY_BUILD_DIR,
|
||||
WAKE_CAPTURE_BUILD_DIR,
|
||||
)
|
||||
)
|
||||
TOOLCHAIN_COMPILER = "arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc"
|
||||
|
||||
|
||||
class BuildEnvironmentError(RuntimeError):
|
||||
|
|
@ -253,15 +257,14 @@ def configure_pico_environment(
|
|||
detected.append(("PICO_SDK_PATH", sdk_path, sdk_source))
|
||||
if explicit_toolchain is None and compiler_on_path is None:
|
||||
updates["PICO_TOOLCHAIN_PATH"] = str(toolchain_path)
|
||||
detected.append(
|
||||
("PICO_TOOLCHAIN_PATH", toolchain_path, toolchain_source)
|
||||
)
|
||||
detected.append(("PICO_TOOLCHAIN_PATH", toolchain_path, toolchain_source))
|
||||
|
||||
environ.update(updates)
|
||||
for variable, path, source in detected:
|
||||
print(f"Auto-detected {variable}={path} ({source})")
|
||||
return updates
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build and flash the project, optionally setting grip colors.",
|
||||
|
|
@ -279,6 +282,11 @@ def parse_args():
|
|||
action="store_true",
|
||||
help="Build and flash the Pico 2 W automatic Switch/XInput prototype.",
|
||||
)
|
||||
mode_group.add_argument(
|
||||
"--wake-capture",
|
||||
action="store_true",
|
||||
help="Build and flash the automatic Switch 2 wake capture firmware.",
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(
|
||||
"--random-grip-color",
|
||||
|
|
@ -290,16 +298,22 @@ def parse_args():
|
|||
metavar="RRGGBB",
|
||||
help="Set every emulated controller slot to the provided hex color.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
args = parser.parse_args()
|
||||
if args.wake_capture and (args.random_grip_color or args.grip_color):
|
||||
parser.error("wake capture firmware does not use grip-color options")
|
||||
return args
|
||||
|
||||
|
||||
def random_hex_color():
|
||||
return "".join(f"{random.randrange(256):02X}" for _ in range(3))
|
||||
|
||||
|
||||
def validate_custom_color(value):
|
||||
if not re.fullmatch(r"[0-9A-Fa-f]{6}", value):
|
||||
raise ValueError("Color must be a 6-digit hex value like FF8800.")
|
||||
return value
|
||||
|
||||
|
||||
def update_grip_colors(rgb_hex):
|
||||
if not CONFIG_FILE.exists():
|
||||
sys.stderr.write(f"Error: Cannot find {CONFIG_FILE}\n")
|
||||
|
|
@ -331,6 +345,7 @@ def update_grip_colors(rgb_hex):
|
|||
sys.stderr.write(f"Error writing {CONFIG_FILE}: {exc}\n")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_cmd(command):
|
||||
try:
|
||||
subprocess.run(command, cwd=SCRIPT_DIR, check=True)
|
||||
|
|
@ -340,12 +355,15 @@ def run_cmd(command):
|
|||
except subprocess.CalledProcessError as exc:
|
||||
sys.exit(exc.returncode)
|
||||
|
||||
|
||||
def resolve_picotool():
|
||||
env_val = os.environ.get("PICOTOOL_PATH")
|
||||
if env_val:
|
||||
env_path = Path(env_val).expanduser()
|
||||
if not env_path.exists():
|
||||
sys.stderr.write(f"Error: PICOTOOL_PATH set to {env_path}, but it does not exist.\n")
|
||||
sys.stderr.write(
|
||||
f"Error: PICOTOOL_PATH set to {env_path}, but it does not exist.\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
return env_path
|
||||
|
||||
|
|
@ -353,9 +371,12 @@ def resolve_picotool():
|
|||
if found:
|
||||
return Path(found)
|
||||
|
||||
sys.stderr.write("Error: picotool not found. Put it on your PATH or set PICOTOOL_PATH.\n")
|
||||
sys.stderr.write(
|
||||
"Error: picotool not found. Put it on your PATH or set PICOTOOL_PATH.\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def build(
|
||||
aio,
|
||||
adapter_feasibility,
|
||||
|
|
@ -379,6 +400,20 @@ def build(
|
|||
"-DPICO_BOARD=pico",
|
||||
"-DSWITCH_PICO_INPUT_BACKEND=UART",
|
||||
]
|
||||
# AIO and XInput images use the qualified native transport by default.
|
||||
# UART remains unchanged; explicit flags also prevent stale cache choices.
|
||||
native = "ON" if aio or adapter_feasibility else "OFF"
|
||||
definitions.extend(
|
||||
[
|
||||
f"-DSWITCH_PICO_HAPTICS_EXPERIMENT={native}",
|
||||
f"-DSWITCH_PICO_HD_RUMBLE={native}",
|
||||
f"-DSWITCH_PICO_SYS_CLOCK_MHZ={300 if native == 'ON' else 150}",
|
||||
"-DSWITCH_PICO_OVERCLOCK_MV=1300",
|
||||
"-DSWITCH_PICO_HD_PACKET_FRAMES=64",
|
||||
f"-DSWITCH_PICO_CYW43_PACKET_READ={native}",
|
||||
f"-DSWITCH_PICO_HCI_CREDIT_BATCH={native}",
|
||||
]
|
||||
)
|
||||
|
||||
run_cmd(
|
||||
[
|
||||
|
|
@ -392,12 +427,12 @@ def build(
|
|||
)
|
||||
run_cmd(["cmake", "--build", str(build_dir)])
|
||||
|
||||
missing_artifacts = [
|
||||
path for path in (elf_path, uf2_path) if not path.is_file()
|
||||
]
|
||||
missing_artifacts = [path for path in (elf_path, uf2_path) if not path.is_file()]
|
||||
if missing_artifacts:
|
||||
missing = ", ".join(str(path) for path in missing_artifacts)
|
||||
sys.stderr.write(f"Error: Build did not produce required artifact(s): {missing}\n")
|
||||
sys.stderr.write(
|
||||
f"Error: Build did not produce required artifact(s): {missing}\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
FIRMWARE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(elf_path, firmware_elf_path)
|
||||
|
|
@ -409,6 +444,35 @@ def build(
|
|||
print(f"Copied UF2: {firmware_uf2_path}")
|
||||
|
||||
|
||||
def build_wake_capture():
|
||||
elf_path = WAKE_CAPTURE_BUILD_DIR / "switch2-wake-capture.elf"
|
||||
uf2_path = WAKE_CAPTURE_BUILD_DIR / "switch2-wake-capture.uf2"
|
||||
run_cmd(
|
||||
[
|
||||
"cmake",
|
||||
"-S",
|
||||
str(WAKE_CAPTURE_SOURCE_DIR),
|
||||
"-B",
|
||||
str(WAKE_CAPTURE_BUILD_DIR),
|
||||
"-DPICO_BOARD=pico2_w",
|
||||
]
|
||||
)
|
||||
run_cmd(["cmake", "--build", str(WAKE_CAPTURE_BUILD_DIR)])
|
||||
missing_artifacts = [path for path in (elf_path, uf2_path) if not path.is_file()]
|
||||
if missing_artifacts:
|
||||
missing = ", ".join(str(path) for path in missing_artifacts)
|
||||
sys.stderr.write(f"Error: Wake capture build did not produce: {missing}\n")
|
||||
sys.exit(1)
|
||||
FIRMWARE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(elf_path, WAKE_CAPTURE_FIRMWARE_ELF_PATH)
|
||||
shutil.copy2(uf2_path, WAKE_CAPTURE_FIRMWARE_UF2_PATH)
|
||||
print(f"Built wake capture ELF: {elf_path}")
|
||||
print(f"Built wake capture UF2: {uf2_path}")
|
||||
print(f"Copied ELF: {WAKE_CAPTURE_FIRMWARE_ELF_PATH}")
|
||||
print(f"Copied UF2: {WAKE_CAPTURE_FIRMWARE_UF2_PATH}")
|
||||
return elf_path
|
||||
|
||||
|
||||
def flash(elf_path, allow_elf_override):
|
||||
picotool = resolve_picotool()
|
||||
if not elf_path.exists():
|
||||
|
|
@ -421,6 +485,7 @@ def flash(elf_path, allow_elf_override):
|
|||
sys.exit(1)
|
||||
run_cmd([str(picotool), "load", str(elf_path), "-fx"])
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
try:
|
||||
|
|
@ -428,6 +493,10 @@ def main():
|
|||
except BuildEnvironmentError as exc:
|
||||
sys.stderr.write(f"Error: {exc}\n")
|
||||
sys.exit(1)
|
||||
if args.wake_capture:
|
||||
wake_capture_elf = build_wake_capture()
|
||||
flash(wake_capture_elf, allow_elf_override=False)
|
||||
return
|
||||
|
||||
color = None
|
||||
|
||||
|
|
@ -477,5 +546,6 @@ def main():
|
|||
allow_elf_override=not args.aio and not args.adapter_feasibility,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
firmware/switch-pico-wake-capture.elf
Executable file
BIN
firmware/switch-pico-wake-capture.elf
Executable file
Binary file not shown.
BIN
firmware/switch-pico-wake-capture.uf2
Normal file
BIN
firmware/switch-pico-wake-capture.uf2
Normal file
Binary file not shown.
|
|
@ -102,7 +102,13 @@ index a22ef26..3d5ecef 100644
|
|||
ins->gyro_calib_data[2].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S;
|
||||
ins->gyro_calib_data[2].sens_denom =
|
||||
abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias);
|
||||
@@ -622,7 +622,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r
|
||||
@@ -618,11 +618,11 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) {
|
||||
if (r->buttons[2] & 0x01)
|
||||
ctl->gamepad.misc_buttons |= MISC_BUTTON_SYSTEM; // PS
|
||||
- if (r->buttons[2] & 0x04)
|
||||
- ctl->gamepad.misc_buttons |= MISC_BUTTON_CAPTURE; // "mute" button
|
||||
+ if (r->buttons[2] & 0x02)
|
||||
+ ctl->gamepad.misc_buttons |= MISC_BUTTON_CAPTURE; // Touchpad click
|
||||
|
||||
// Gyro
|
||||
for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) {
|
||||
|
|
@ -578,3 +584,30 @@ index 67841e8..9fe7134 100644
|
|||
d->report_parser.set_lightbar_color = uni_hid_parser_psmove_set_lightbar_color;
|
||||
d->report_parser.play_dual_rumble = uni_hid_parser_psmove_play_dual_rumble;
|
||||
logi("Device detected as PS Move: 0x%02x\n", type);
|
||||
diff --git a/src/components/bluepad32/bt/uni_bt.c b/src/components/bluepad32/bt/uni_bt.c
|
||||
--- a/src/components/bluepad32/bt/uni_bt.c
|
||||
+++ b/src/components/bluepad32/bt/uni_bt.c
|
||||
@@ -73,6 +73,12 @@
|
||||
#include "uni_log.h"
|
||||
#include "uni_property.h"
|
||||
|
||||
+#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
+// The opt-in PCM sender owns its CAN_SEND_NOW event, not the generic HID FIFO.
|
||||
+extern bool uni_platform_on_l2cap_can_send_now(uni_hid_device_t* device,
|
||||
+ uint16_t local_cid);
|
||||
+#endif
|
||||
+
|
||||
// globals
|
||||
bd_addr_t uni_local_bd_addr;
|
||||
|
||||
@@ -473,6 +479,10 @@ void uni_bt_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t* pack
|
||||
if (device == NULL) {
|
||||
loge("--->>> CANNOT FIND DEVICE");
|
||||
} else {
|
||||
+#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
+ if (uni_platform_on_l2cap_can_send_now(device, local_cid))
|
||||
+ break;
|
||||
+#endif
|
||||
uni_hid_device_send_queued_reports(device);
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
296
patches/btstack-credit-batch.patch
Normal file
296
patches/btstack-credit-batch.patch
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
--- a/lib/btstack/src/hci.c
|
||||
+++ b/lib/btstack/src/hci.c
|
||||
@@ -93,6 +93,12 @@
|
||||
#endif
|
||||
#endif
|
||||
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+#if !defined(ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL) || (HCI_HOST_ACL_PACKET_NUM != 3)
|
||||
+#error "SWITCH_PICO_HCI_CREDIT_BATCH requires controller-to-host flow control with three ACL credits"
|
||||
+#endif
|
||||
+#endif
|
||||
+
|
||||
#ifndef MAX_NR_CONTROLLER_ACL_BUFFERS
|
||||
#define MAX_NR_CONTROLLER_ACL_BUFFERS 255
|
||||
#endif
|
||||
@@ -273,6 +279,74 @@
|
||||
#endif
|
||||
static hci_stack_t * hci_stack = NULL;
|
||||
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+static btstack_timer_source_t hci_credit_batch_timer;
|
||||
+static bool hci_credit_batch_armed;
|
||||
+static bool hci_credit_batch_due;
|
||||
+static bool hci_credit_batch_enabled;
|
||||
+static uint32_t hci_credit_batch_generation;
|
||||
+
|
||||
+static void hci_credit_batch_reset(void){
|
||||
+ if (hci_credit_batch_armed){
|
||||
+ btstack_run_loop_remove_timer(&hci_credit_batch_timer);
|
||||
+ hci_credit_batch_armed = false;
|
||||
+ }
|
||||
+ hci_credit_batch_due = false;
|
||||
+}
|
||||
+
|
||||
+static void hci_credit_batch_stop(void){
|
||||
+ hci_credit_batch_enabled = false;
|
||||
+ hci_credit_batch_generation++;
|
||||
+ hci_credit_batch_reset();
|
||||
+}
|
||||
+
|
||||
+static void hci_credit_batch_timeout(btstack_timer_source_t * timer);
|
||||
+
|
||||
+static void hci_credit_batch_arm(void){
|
||||
+ if (!hci_credit_batch_enabled || hci_credit_batch_armed || (hci_stack == NULL)) return;
|
||||
+ if (hci_stack->state != HCI_STATE_WORKING) return;
|
||||
+ btstack_run_loop_set_timer_handler(&hci_credit_batch_timer, hci_credit_batch_timeout);
|
||||
+ // Pico adds one millisecond to its truncated clock: 1 ms requests expire within 2 ms.
|
||||
+ btstack_run_loop_set_timer(&hci_credit_batch_timer, 1);
|
||||
+ hci_credit_batch_armed = true;
|
||||
+ btstack_run_loop_add_timer(&hci_credit_batch_timer);
|
||||
+}
|
||||
+
|
||||
+static bool hci_credit_batch_ready(void){
|
||||
+ uint32_t pending = 0;
|
||||
+ bool sco_pending = false;
|
||||
+ btstack_linked_item_t * it;
|
||||
+ for (it = (btstack_linked_item_t *) hci_stack->connections; it; it = it->next){
|
||||
+ hci_connection_t * connection = (hci_connection_t *) it;
|
||||
+ pending += connection->num_packets_completed;
|
||||
+ if ((connection->address_type == BD_ADDR_TYPE_SCO) && (connection->num_packets_completed != 0)){
|
||||
+ sco_pending = true;
|
||||
+ }
|
||||
+ }
|
||||
+ if (pending == 0){
|
||||
+ hci_stack->host_completed_packets = 0;
|
||||
+ hci_credit_batch_reset();
|
||||
+ return false;
|
||||
+ }
|
||||
+ // Three advertised ACL credits leave one in reserve at the two-completion low water mark.
|
||||
+ return hci_credit_batch_due || sco_pending || (pending >= 2);
|
||||
+}
|
||||
+
|
||||
+static void hci_credit_batch_timeout(btstack_timer_source_t * timer){
|
||||
+ UNUSED(timer);
|
||||
+ if (!hci_credit_batch_armed) return;
|
||||
+ hci_credit_batch_armed = false;
|
||||
+ if (!hci_credit_batch_enabled || (hci_stack == NULL)) return;
|
||||
+ if (hci_stack->state != HCI_STATE_WORKING) return;
|
||||
+ hci_credit_batch_due = true;
|
||||
+ if (!hci_credit_batch_ready()) return;
|
||||
+ // Retry at a future tick if the transport/buffer is busy, never spin. Arm before hci_run:
|
||||
+ // a synchronous send may close/reinitialize HCI, so do not touch its state afterwards.
|
||||
+ hci_credit_batch_arm();
|
||||
+ hci_run();
|
||||
+}
|
||||
+#endif
|
||||
+
|
||||
#ifdef ENABLE_CLASSIC
|
||||
// default name
|
||||
static const char * default_classic_name = "BTstack 00:00:00:00:00:00";
|
||||
@@ -1195,6 +1269,9 @@
|
||||
#ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL
|
||||
hci_stack->host_completed_packets = 1;
|
||||
conn->num_packets_completed++;
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ hci_credit_batch_arm();
|
||||
+#endif
|
||||
#endif
|
||||
|
||||
// handle different packet types
|
||||
@@ -1269,8 +1346,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
+#ifndef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
// execute main loop
|
||||
hci_run();
|
||||
+#endif
|
||||
}
|
||||
|
||||
static void hci_connection_stop_timer(hci_connection_t * conn){
|
||||
@@ -1296,6 +1375,9 @@
|
||||
|
||||
btstack_linked_list_remove(&hci_stack->connections, (btstack_linked_item_t *) conn);
|
||||
btstack_memory_hci_connection_free( conn );
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ (void) hci_credit_batch_ready();
|
||||
+#endif
|
||||
|
||||
// now it's gone
|
||||
hci_emit_nr_connections_changed();
|
||||
@@ -4314,6 +4396,12 @@
|
||||
|
||||
conn = hci_connection_for_handle(handle);
|
||||
if (!conn) break;
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ // The controller reclaims disconnected credits. Clear them before pairing or
|
||||
+ // disconnection callbacks can re-enter hci_run, including handle reuse.
|
||||
+ conn->num_packets_completed = 0;
|
||||
+ (void) hci_credit_batch_ready();
|
||||
+#endif
|
||||
#ifdef ENABLE_CLASSIC
|
||||
// pairing failed if it was ongoing
|
||||
hci_pairing_complete(conn, ERROR_CODE_REMOTE_USER_TERMINATED_CONNECTION);
|
||||
@@ -4780,6 +4868,10 @@
|
||||
#ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL
|
||||
conn->num_packets_completed++;
|
||||
hci_stack->host_completed_packets = 1;
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ // SCO is never deferred, but needs the same transport-busy retry guarantee.
|
||||
+ hci_credit_batch_arm();
|
||||
+#endif
|
||||
hci_run();
|
||||
#endif
|
||||
}
|
||||
@@ -4814,6 +4906,10 @@
|
||||
break;
|
||||
case HCI_ACL_DATA_PACKET:
|
||||
acl_handler(packet, size);
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ // Also service credits for malformed fragments that return early after accounting.
|
||||
+ if (hci_stack != NULL) hci_run();
|
||||
+#endif
|
||||
break;
|
||||
#ifdef ENABLE_CLASSIC
|
||||
case HCI_SCO_DATA_PACKET:
|
||||
@@ -4869,6 +4965,11 @@
|
||||
#endif
|
||||
|
||||
static void hci_state_reset(void){
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ hci_credit_batch_reset();
|
||||
+ hci_credit_batch_generation++;
|
||||
+ hci_stack->host_completed_packets = 0;
|
||||
+#endif
|
||||
// no connections yet
|
||||
hci_stack->connections = NULL;
|
||||
|
||||
@@ -4953,6 +5054,10 @@
|
||||
#endif
|
||||
|
||||
void hci_init(const hci_transport_t *transport, const void *config){
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ hci_credit_batch_stop();
|
||||
+ hci_credit_batch_enabled = true;
|
||||
+#endif
|
||||
|
||||
#ifdef HAVE_MALLOC
|
||||
if (!hci_stack) {
|
||||
@@ -5082,6 +5187,9 @@
|
||||
}
|
||||
|
||||
void hci_deinit(void){
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ hci_credit_batch_stop();
|
||||
+#endif
|
||||
btstack_run_loop_remove_timer(&hci_stack->timeout);
|
||||
#ifdef HAVE_MALLOC
|
||||
if (hci_stack) {
|
||||
@@ -5138,6 +5246,9 @@
|
||||
}
|
||||
|
||||
void hci_close(void){
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ hci_credit_batch_stop();
|
||||
+#endif
|
||||
|
||||
#ifdef ENABLE_CLASSIC
|
||||
// close remote device db
|
||||
@@ -5264,6 +5375,10 @@
|
||||
// HCI_STATE_FALLING_ASLEEP on open
|
||||
|
||||
static int hci_power_control_on(void){
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ hci_credit_batch_stop();
|
||||
+ hci_credit_batch_enabled = true;
|
||||
+#endif
|
||||
|
||||
// power on
|
||||
int err = 0;
|
||||
@@ -5300,6 +5415,9 @@
|
||||
}
|
||||
|
||||
static void hci_power_control_off(void){
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ hci_credit_batch_stop();
|
||||
+#endif
|
||||
|
||||
log_info("hci_power_control_off");
|
||||
|
||||
@@ -5319,6 +5437,9 @@
|
||||
}
|
||||
|
||||
static void hci_power_control_sleep(void){
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ hci_credit_batch_stop();
|
||||
+#endif
|
||||
|
||||
log_info("hci_power_control_sleep");
|
||||
|
||||
@@ -5363,6 +5484,10 @@
|
||||
}
|
||||
|
||||
static void hci_power_enter_initializing_state(void){
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ hci_credit_batch_stop();
|
||||
+ hci_credit_batch_enabled = true;
|
||||
+#endif
|
||||
// set up state machine
|
||||
hci_stack->num_cmd_packets = 1; // assume that one cmd can be sent
|
||||
hci_stack->hci_packet_buffer_reserved = false;
|
||||
@@ -5543,6 +5668,10 @@
|
||||
}
|
||||
|
||||
int hci_power_control(HCI_POWER_MODE power_mode){
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ // Repeated POWER_ON while working must not restart a pending credit's deadline.
|
||||
+ if (power_mode != HCI_POWER_ON) hci_credit_batch_stop();
|
||||
+#endif
|
||||
log_info("hci_power_control: %d, current mode %u", power_mode, hci_stack->state);
|
||||
btstack_run_loop_remove_timer(&hci_stack->timeout);
|
||||
int err = 0;
|
||||
@@ -5834,6 +5963,11 @@
|
||||
|
||||
#ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL
|
||||
static void hci_host_num_completed_packets(void){
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ // Clear batching state before any transport/application callback can add new credits.
|
||||
+ hci_credit_batch_reset();
|
||||
+ uint32_t generation = hci_credit_batch_generation;
|
||||
+#endif
|
||||
|
||||
// create packet manually as arrays are not supported and num_commands should not get reduced
|
||||
hci_reserve_packet_buffer();
|
||||
@@ -5868,6 +6002,9 @@
|
||||
|
||||
hci_dump_packet(HCI_COMMAND_DATA_PACKET, 0, packet, size);
|
||||
hci_stack->hci_transport->send_packet(HCI_COMMAND_DATA_PACKET, packet, size);
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ if ((hci_stack == NULL) || (generation != hci_credit_batch_generation)) return;
|
||||
+#endif
|
||||
|
||||
// release packet buffer for synchronous transport implementations
|
||||
if (hci_transport_synchronous()){
|
||||
@@ -7664,12 +7801,21 @@
|
||||
#endif
|
||||
|
||||
#ifdef ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL
|
||||
+#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
+ // A single deferred ACL credit must not block ordinary HCI commands.
|
||||
+ if (hci_stack->host_completed_packets && hci_credit_batch_ready()){
|
||||
+ if (!hci_can_send_command_packet_transport()) return;
|
||||
+ hci_host_num_completed_packets();
|
||||
+ return;
|
||||
+ }
|
||||
+#else
|
||||
// send host num completed packets next as they don't require num_cmd_packets > 0
|
||||
if (!hci_can_send_command_packet_transport()) return;
|
||||
if (hci_stack->host_completed_packets){
|
||||
hci_host_num_completed_packets();
|
||||
return;
|
||||
}
|
||||
+#endif
|
||||
#endif
|
||||
|
||||
if (!hci_can_send_command_packet_now()) return;
|
||||
27
patches/cyw43-packet-read-status.patch
Normal file
27
patches/cyw43-packet-read-status.patch
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
--- a/cybt_shared_bus_driver.c
|
||||
+++ b/cybt_shared_bus_driver.c
|
||||
@@ -501,8 +501,11 @@
|
||||
cybt_result_t cybt_get_bt_buf_index(cybt_fw_membuf_index_t *p_buf_index) {
|
||||
uint32_t buf[4];
|
||||
|
||||
- cybt_mem_read(H2B_BUF_IN_ADDR, (uint8_t *) buf, sizeof(buf));
|
||||
+ cybt_result_t result = cybt_mem_read(H2B_BUF_IN_ADDR, (uint8_t *) buf, sizeof(buf));
|
||||
+ if (result != CYBT_SUCCESS) {
|
||||
+ return result;
|
||||
+ }
|
||||
|
||||
p_buf_index->host2bt_in_val = buf[0];
|
||||
p_buf_index->host2bt_out_val = buf[1];
|
||||
p_buf_index->bt2host_in_val = buf[2];
|
||||
@@ -596,7 +599,10 @@
|
||||
if ((mem_addr & 0xFFF) + transfer_size > 0x1000) {
|
||||
transfer_size = 0x1000 - (mem_addr & 0xFFF);
|
||||
}
|
||||
- cyw43_ll_read_backplane_mem(cyw43_ll, mem_addr, transfer_size, p_data);
|
||||
+ int result = cyw43_ll_read_backplane_mem(cyw43_ll, mem_addr, transfer_size, p_data);
|
||||
+ if (result != 0) {
|
||||
+ return CYBT_ERR_HCI_READ_FAILED;
|
||||
+ }
|
||||
cybt_debug(" read_mem addr 0x%08lx len %ld\n", mem_addr, transfer_size);
|
||||
DUMP_BYTES(p_data, transfer_size);
|
||||
data_len -= transfer_size;
|
||||
|
|
@ -31,4 +31,4 @@ where = ["src"]
|
|||
include = ["switch_pico_bridge*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
switch_pico_bridge = ["controller_db/gamecontrollerdb.txt"]
|
||||
switch_pico_bridge = ["controller_db/gamecontrollerdb.txt", "web/*", "web/assets/*"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
#include "input/bluepad32_input_backend.h"
|
||||
#include "input/controller_hotkey_config.h"
|
||||
#include "input/switch2_wake.h"
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
#include "input/haptics_experiment.h"
|
||||
#endif
|
||||
#include "configuration/configuration_service.h"
|
||||
#include "profile/profile_service.h"
|
||||
#include <limits.h>
|
||||
|
|
@ -52,13 +56,11 @@ constexpr SwitchRgbColor kProfileLightbarPalette[CONTROLLER_PROFILE_COUNT] = {
|
|||
{0x00, 0xcc, 0x66},
|
||||
{0xff, 0xaa, 0x00},
|
||||
{0xcc, 0x33, 0xff},
|
||||
{0xff, 0x44, 0x44},
|
||||
{0x00, 0xdd, 0xdd},
|
||||
{0xff, 0x66, 0xbb},
|
||||
{0xcc, 0xff, 0x33},
|
||||
};
|
||||
constexpr uint32_t kMotionHotkeyDpadMask =
|
||||
SWITCH_MOTION_HOTKEY_DPAD_MASK;
|
||||
constexpr uint32_t kMotionHotkeyButtonMask =
|
||||
SWITCH_MOTION_HOTKEY_BUTTON_MASK;
|
||||
constexpr uint32_t kMotionHotkeyMiscMask =
|
||||
SWITCH_MOTION_HOTKEY_MISC_MASK;
|
||||
constexpr bool kDefaultMotionEnabled =
|
||||
SWITCH_MOTION_DEFAULT_ENABLED != 0;
|
||||
constexpr uint16_t kMotionDisabledFeedbackDurationMs =
|
||||
|
|
@ -75,10 +77,7 @@ constexpr uint8_t kMotionEnabledFeedbackStrongMagnitude =
|
|||
SWITCH_MOTION_ENABLED_FEEDBACK_STRONG_MAGNITUDE;
|
||||
|
||||
static_assert(kProfileFeedbackPhaseDurationMs == 75);
|
||||
static_assert(CONTROLLER_PROFILE_COUNT == 4);
|
||||
static_assert(kMotionHotkeyDpadMask != 0);
|
||||
static_assert(kMotionHotkeyButtonMask != 0);
|
||||
static_assert(kMotionHotkeyMiscMask != 0);
|
||||
static_assert(CONTROLLER_PROFILE_COUNT == 8);
|
||||
static_assert(kMotionDisabledFeedbackDurationMs > 0);
|
||||
static_assert(kMotionEnabledFeedbackDurationMs > 0);
|
||||
|
||||
|
|
@ -105,6 +104,9 @@ struct RumbleEnvelope {
|
|||
uint32_t connection_generation;
|
||||
ControllerRumbleOutput rumble;
|
||||
uint16_t duration_ms;
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
uint64_t received_us = 0;
|
||||
#endif
|
||||
};
|
||||
struct FeedbackEnvelope {
|
||||
uint32_t connection_generation;
|
||||
|
|
@ -153,7 +155,6 @@ struct BackendSlot {
|
|||
bool active;
|
||||
bool rumble_pending;
|
||||
bool motion_enabled;
|
||||
bool motion_hotkey_latched;
|
||||
bool feedback_pending;
|
||||
uint32_t feedback_until_ms;
|
||||
uint8_t pending_profile_feedback_count;
|
||||
|
|
@ -168,6 +169,10 @@ struct BackendSlot {
|
|||
|
||||
critical_section_t g_state_lock;
|
||||
BackendSlot g_slots[kSlotCount];
|
||||
ControllerMacroCapture g_macro_capture;
|
||||
// Catalog migration/compaction needs more than the 4 KiB scratch bank.
|
||||
// Supply a dedicated static stack in main SRAM rather than overflowing it.
|
||||
alignas(8) uint32_t g_core1_stack[4096];
|
||||
BleIdentityMapping g_ble_identity_mappings[kSlotCount]{};
|
||||
|
||||
// These acknowledgement generations and request producers are only used by
|
||||
|
|
@ -205,6 +210,9 @@ uint32_t g_controller_reports = 0;
|
|||
uint32_t g_host_rumble_requests = 0;
|
||||
uint32_t g_local_feedback_requests = 0;
|
||||
uint32_t g_rumble_dispatches = 0;
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
uint32_t g_seeded_native_run_id = 0;
|
||||
#endif
|
||||
|
||||
uint16_t host_rumble_duration_ms() {
|
||||
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
|
||||
|
|
@ -506,6 +514,8 @@ ConnectionStatus compute_connection_status() {
|
|||
: ConnectionStatus::Scanning;
|
||||
}
|
||||
|
||||
|
||||
|
||||
void publish_device_state(uint8_t slot, uni_hid_device_t* device,
|
||||
uint16_t pre_hotkey_button_mask,
|
||||
const ControllerState& state) {
|
||||
|
|
@ -515,6 +525,8 @@ void publish_device_state(uint8_t slot, uni_hid_device_t* device,
|
|||
target.state = state;
|
||||
target.pre_hotkey_button_mask = pre_hotkey_button_mask;
|
||||
++target.state_generation;
|
||||
g_macro_capture.observe(slot, target.connection_generation,
|
||||
time_us_32(), state);
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
}
|
||||
|
|
@ -638,6 +650,21 @@ constexpr uint16_t logical_button_bit(
|
|||
return static_cast<uint16_t>(
|
||||
1u << static_cast<uint8_t>(button));
|
||||
}
|
||||
bool wake_chord_rising_edge(uint8_t slot, uni_hid_device_t* device,
|
||||
uint16_t button_mask) {
|
||||
const uint16_t chord =
|
||||
logical_button_bit(ControllerProfileLogicalButton::kLeftShoulder) |
|
||||
logical_button_bit(ControllerProfileLogicalButton::kRightShoulder) |
|
||||
logical_button_bit(ControllerProfileLogicalButton::kSystem);
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
const BackendSlot& previous = g_slots[slot];
|
||||
const bool rising =
|
||||
previous.active && previous.device == device &&
|
||||
(button_mask & chord) == chord &&
|
||||
(previous.pre_hotkey_button_mask & chord) != chord;
|
||||
critical_section_exit(&g_state_lock);
|
||||
return rising;
|
||||
}
|
||||
|
||||
constexpr uint16_t logical_button_mask(
|
||||
uint32_t dpad, uint32_t buttons, uint32_t misc_buttons) {
|
||||
|
|
@ -708,10 +735,6 @@ constexpr uint16_t logical_button_mask(
|
|||
: 0u));
|
||||
}
|
||||
|
||||
constexpr uint16_t kMotionHotkeyLogicalButtonMask =
|
||||
logical_button_mask(
|
||||
kMotionHotkeyDpadMask, kMotionHotkeyButtonMask,
|
||||
kMotionHotkeyMiscMask);
|
||||
|
||||
uint16_t logical_button_mask(const uni_gamepad_t& gamepad) {
|
||||
return logical_button_mask(
|
||||
|
|
@ -810,10 +833,6 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad,
|
|||
}
|
||||
struct HotkeyDecision {
|
||||
bool motion_enabled;
|
||||
uint32_t suppress_dpad;
|
||||
uint32_t suppress_buttons;
|
||||
uint32_t suppress_misc_buttons;
|
||||
uint16_t suppress_logical_buttons;
|
||||
};
|
||||
|
||||
void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms,
|
||||
|
|
@ -826,8 +845,9 @@ void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms,
|
|||
__atomic_add_fetch(&g_local_feedback_requests, 1, __ATOMIC_RELAXED);
|
||||
}
|
||||
void reset_slot_hotkeys(BackendSlot& slot) {
|
||||
g_macro_capture.disconnect(static_cast<uint8_t>(&slot - g_slots),
|
||||
slot.connection_generation, time_us_32());
|
||||
slot.motion_enabled = kDefaultMotionEnabled;
|
||||
slot.motion_hotkey_latched = false;
|
||||
slot.pre_hotkey_button_mask = 0;
|
||||
slot.feedback_pending = false;
|
||||
slot.feedback_until_ms = 0;
|
||||
|
|
@ -844,44 +864,12 @@ void reset_slot_hotkeys(BackendSlot& slot) {
|
|||
|
||||
|
||||
HotkeyDecision update_controller_hotkeys(
|
||||
uint8_t slot_index, uni_hid_device_t* device,
|
||||
const uni_gamepad_t& gamepad) {
|
||||
const bool motion_pressed =
|
||||
(gamepad.dpad & kMotionHotkeyDpadMask) ==
|
||||
kMotionHotkeyDpadMask &&
|
||||
(gamepad.buttons & kMotionHotkeyButtonMask) ==
|
||||
kMotionHotkeyButtonMask &&
|
||||
(gamepad.misc_buttons & kMotionHotkeyMiscMask) ==
|
||||
kMotionHotkeyMiscMask;
|
||||
HotkeyDecision decision{
|
||||
kDefaultMotionEnabled, 0, 0, 0, 0};
|
||||
|
||||
uint8_t slot_index, uni_hid_device_t* device) {
|
||||
HotkeyDecision decision{kDefaultMotionEnabled};
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
BackendSlot& slot = g_slots[slot_index];
|
||||
const BackendSlot& slot = g_slots[slot_index];
|
||||
if (slot.active && slot.device == device) {
|
||||
if (motion_pressed && !slot.motion_hotkey_latched) {
|
||||
slot.motion_enabled = !slot.motion_enabled;
|
||||
if (slot.motion_enabled) {
|
||||
queue_local_feedback(
|
||||
slot, kMotionEnabledFeedbackDurationMs,
|
||||
kMotionEnabledFeedbackWeakMagnitude,
|
||||
kMotionEnabledFeedbackStrongMagnitude);
|
||||
} else {
|
||||
queue_local_feedback(
|
||||
slot, kMotionDisabledFeedbackDurationMs,
|
||||
kMotionDisabledFeedbackWeakMagnitude,
|
||||
kMotionDisabledFeedbackStrongMagnitude);
|
||||
}
|
||||
}
|
||||
slot.motion_hotkey_latched = motion_pressed;
|
||||
decision.motion_enabled = slot.motion_enabled;
|
||||
if (motion_pressed) {
|
||||
decision.suppress_dpad |= kMotionHotkeyDpadMask;
|
||||
decision.suppress_buttons |= kMotionHotkeyButtonMask;
|
||||
decision.suppress_misc_buttons |= kMotionHotkeyMiscMask;
|
||||
decision.suppress_logical_buttons |=
|
||||
kMotionHotkeyLogicalButtonMask;
|
||||
}
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
return decision;
|
||||
|
|
@ -1105,6 +1093,7 @@ void process_pairing_snapshot_request() {
|
|||
}
|
||||
|
||||
void apply_connection_policy();
|
||||
void recompute_connection_status();
|
||||
|
||||
void process_clear_pairings(uint32_t now_ms) {
|
||||
uni_hid_device_t* devices[kSlotCount]{};
|
||||
|
|
@ -1281,17 +1270,79 @@ void process_configuration_timer(btstack_timer_source_t* timer) {
|
|||
profile_service_task_on_storage_core(now_ms);
|
||||
}
|
||||
|
||||
void dispatch_rumble(uni_hid_device_t* device, uint16_t duration_ms,
|
||||
uint8_t weak, uint8_t strong) {
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
if (haptics_experiment_feedback(device, strong, weak, duration_ms)) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
device->report_parser.play_dual_rumble(device, 0, duration_ms, weak, strong);
|
||||
}
|
||||
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
void seed_native_host_rumble() {
|
||||
HapticsExperimentDiagnostics native;
|
||||
haptics_experiment_snapshot(&native);
|
||||
if (native.mode != 1 || native.slot >= kSlotCount ||
|
||||
native.run_id == g_seeded_native_run_id ||
|
||||
(native.state != HapticsExperimentState::kPending &&
|
||||
native.state != HapticsExperimentState::kRunning)) {
|
||||
return;
|
||||
}
|
||||
g_seeded_native_run_id = native.run_id;
|
||||
RumbleEnvelope retained{};
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
BackendSlot& slot = g_slots[native.slot];
|
||||
const bool valid = slot.active && slot.device != nullptr &&
|
||||
slot.retained_host_rumble_valid &&
|
||||
slot.connection_generation == native.connection_generation &&
|
||||
slot.retained_host_rumble.connection_generation ==
|
||||
native.connection_generation &&
|
||||
slot.retained_host_rumble.slot == native.slot &&
|
||||
slot.retained_host_rumble.duration_ms ==
|
||||
kXInputHostRumbleDurationMs;
|
||||
if (valid) {
|
||||
retained = slot.retained_host_rumble;
|
||||
// Arming cancels compatibility output even when its mailbox was
|
||||
// already consumed. Keep that held state available for the next Stop.
|
||||
slot.pending_rumble = retained;
|
||||
slot.rumble_pending = true;
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
if (valid) {
|
||||
// Replay once on arm, not on a watchdog cadence. The original timestamp
|
||||
// keeps a raced newer USB command authoritative in the host timeline.
|
||||
haptics_experiment_submit_rumble(
|
||||
native.slot, native.connection_generation, retained.received_us,
|
||||
retained.rumble.low_frequency_magnitude,
|
||||
retained.rumble.high_frequency_magnitude);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void process_rumble_timer(btstack_timer_source_t* timer) {
|
||||
__atomic_add_fetch(&g_rumble_timer_ticks, 1, __ATOMIC_RELAXED);
|
||||
const uint32_t now_ms = btstack_run_loop_get_time_ms();
|
||||
|
||||
process_clear_pairings(now_ms);
|
||||
process_pairing_snapshot_request();
|
||||
if (update_pairing_window(now_ms)) {
|
||||
const bool wake_identity_ready =
|
||||
switch2_wake_ready_for_connections();
|
||||
if (g_connection_policy_state ==
|
||||
ConnectionPolicyState::Uninitialized &&
|
||||
wake_identity_ready) {
|
||||
recompute_connection_status();
|
||||
}
|
||||
if (update_pairing_window(now_ms) && wake_identity_ready) {
|
||||
apply_connection_policy();
|
||||
}
|
||||
const bool xinput_host_mode =
|
||||
host_rumble_duration_ms() == kXInputHostRumbleDurationMs;
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
if (xinput_host_mode) seed_native_host_rumble();
|
||||
haptics_experiment_poll();
|
||||
#endif
|
||||
|
||||
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
|
||||
RumbleEnvelope envelope{};
|
||||
|
|
@ -1319,6 +1370,16 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
|
|||
slot.retained_host_rumble_valid = false;
|
||||
slot.retained_host_rumble = {};
|
||||
}
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
if (haptics_experiment_owns(slot.device) &&
|
||||
!haptics_experiment_gameplay_owns(slot.device)) {
|
||||
// Fixture/startup/restoration exclusively own output. Preserve
|
||||
// stateful XInput requests until compatibility restoration ends.
|
||||
if (!xinput_host_mode) slot.rumble_pending = false;
|
||||
critical_section_exit(&g_state_lock);
|
||||
continue;
|
||||
}
|
||||
#endif
|
||||
if (slot.profile_feedback.active &&
|
||||
slot.profile_feedback.connection_generation !=
|
||||
slot.connection_generation) {
|
||||
|
|
@ -1425,11 +1486,17 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
|
|||
static_cast<int32_t>(
|
||||
now_ms - slot.feedback_until_ms) < 0;
|
||||
if (!profile_rumble_dispatch && !feedback_dispatch &&
|
||||
!local_feedback_active && slot.rumble_pending) {
|
||||
!local_feedback_active && slot.rumble_pending
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
&& !(xinput_host_mode &&
|
||||
haptics_experiment_gameplay_owns(slot.device))
|
||||
#endif
|
||||
) {
|
||||
envelope = slot.pending_rumble;
|
||||
slot.rumble_pending = false;
|
||||
host_dispatch =
|
||||
envelope.slot == slot_index && slot.active &&
|
||||
envelope.duration_ms == host_rumble_duration_ms() &&
|
||||
slot.device != nullptr &&
|
||||
envelope.connection_generation ==
|
||||
slot.connection_generation;
|
||||
|
|
@ -1438,6 +1505,14 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
|
|||
}
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
if (host_dispatch && haptics_experiment_gameplay_owns(device)) {
|
||||
// Switch commands have already entered the timestamped timeline.
|
||||
// Consume their finite fallback, never turn it into a PCM overlay
|
||||
// or emit compatibility reports while gameplay owns the device.
|
||||
host_dispatch = false;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (profile_lighting_restore &&
|
||||
lighting_target_is_current(
|
||||
|
|
@ -1458,15 +1533,14 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
|
|||
device->report_parser.play_dual_rumble != nullptr) {
|
||||
__atomic_add_fetch(
|
||||
&g_rumble_dispatches, 1, __ATOMIC_RELAXED);
|
||||
device->report_parser.play_dual_rumble(
|
||||
device, 0, kProfileFeedbackPhaseDurationMs,
|
||||
kProfileFeedbackWeakMagnitude,
|
||||
kProfileFeedbackStrongMagnitude);
|
||||
dispatch_rumble(
|
||||
device, kProfileFeedbackPhaseDurationMs,
|
||||
kProfileFeedbackWeakMagnitude, kProfileFeedbackStrongMagnitude);
|
||||
} else if (feedback_dispatch) {
|
||||
__atomic_add_fetch(
|
||||
&g_rumble_dispatches, 1, __ATOMIC_RELAXED);
|
||||
device->report_parser.play_dual_rumble(
|
||||
device, 0, feedback.duration_ms,
|
||||
dispatch_rumble(
|
||||
device, feedback.duration_ms,
|
||||
feedback.weak_magnitude, feedback.strong_magnitude);
|
||||
} else if (host_dispatch &&
|
||||
device->report_parser.play_dual_rumble != nullptr) {
|
||||
|
|
@ -1475,8 +1549,8 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
|
|||
const bool stop =
|
||||
envelope.rumble.low_frequency_magnitude == 0 &&
|
||||
envelope.rumble.high_frequency_magnitude == 0;
|
||||
device->report_parser.play_dual_rumble(
|
||||
device, 0, stop ? 0 : envelope.duration_ms,
|
||||
dispatch_rumble(
|
||||
device, stop ? 0 : envelope.duration_ms,
|
||||
envelope.rumble.high_frequency_magnitude,
|
||||
envelope.rumble.low_frequency_magnitude);
|
||||
}
|
||||
|
|
@ -1509,6 +1583,7 @@ void platform_on_init_complete() {
|
|||
g_identity_event_callback.callback = handle_btstack_event;
|
||||
sm_add_event_handler(&g_identity_event_callback);
|
||||
hci_add_event_handler(&g_pairing_event_callback);
|
||||
switch2_wake_initialize();
|
||||
refresh_pairing_snapshot();
|
||||
// Keep Bluepad32 autoconnect active whenever at least one slot is free.
|
||||
btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer);
|
||||
|
|
@ -1520,7 +1595,9 @@ void platform_on_init_complete() {
|
|||
&g_configuration_timer, kConfigurationPollIntervalMs);
|
||||
btstack_run_loop_add_timer(&g_configuration_timer);
|
||||
__atomic_store_n(&g_initialization_stage, 6, __ATOMIC_RELEASE);
|
||||
recompute_connection_status();
|
||||
if (switch2_wake_ready_for_connections()) {
|
||||
recompute_connection_status();
|
||||
}
|
||||
}
|
||||
|
||||
uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name,
|
||||
|
|
@ -1574,6 +1651,9 @@ void platform_on_device_connected(uni_hid_device_t* device) {
|
|||
}
|
||||
|
||||
void platform_on_device_disconnected(uni_hid_device_t* device) {
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
haptics_experiment_detach(device);
|
||||
#endif
|
||||
const int slot_index = slot_for_device(device);
|
||||
if (slot_index < 0) {
|
||||
return;
|
||||
|
|
@ -1642,6 +1722,17 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
|
|||
return UNI_ERROR_NO_SLOTS;
|
||||
}
|
||||
if (became_active) {
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
haptics_experiment_attach(
|
||||
static_cast<uint8_t>(slot_index), lighting_generation, device);
|
||||
#ifdef SWITCH_PICO_HD_RUMBLE
|
||||
if (connection_identity.vendor_id == 0x054c &&
|
||||
(connection_identity.product_id == 0x0ce6 ||
|
||||
connection_identity.product_id == 0x0df2)) {
|
||||
haptics_experiment_request(2, static_cast<uint8_t>(slot_index));
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
if (lighting_target_is_current(
|
||||
static_cast<uint8_t>(slot_index),
|
||||
lighting_generation, device)) {
|
||||
|
|
@ -1671,13 +1762,14 @@ void platform_on_controller_data(uni_hid_device_t* device,
|
|||
uni_gamepad_t gamepad = controller->gamepad;
|
||||
const uint16_t pre_hotkey_button_mask =
|
||||
logical_button_mask(gamepad);
|
||||
if (wake_chord_rising_edge(
|
||||
static_cast<uint8_t>(slot_index), device,
|
||||
pre_hotkey_button_mask)) {
|
||||
switch2_wake_request();
|
||||
}
|
||||
const HotkeyDecision hotkeys = update_controller_hotkeys(
|
||||
static_cast<uint8_t>(slot_index), device, gamepad);
|
||||
gamepad.dpad &= ~hotkeys.suppress_dpad;
|
||||
gamepad.buttons &= ~hotkeys.suppress_buttons;
|
||||
gamepad.misc_buttons &= ~hotkeys.suppress_misc_buttons;
|
||||
const uint16_t output_button_mask = static_cast<uint16_t>(
|
||||
pre_hotkey_button_mask & ~hotkeys.suppress_logical_buttons);
|
||||
static_cast<uint8_t>(slot_index), device);
|
||||
const uint16_t output_button_mask = pre_hotkey_button_mask;
|
||||
publish_device_state(
|
||||
static_cast<uint8_t>(slot_index), device,
|
||||
pre_hotkey_button_mask,
|
||||
|
|
@ -1750,6 +1842,64 @@ uni_platform* get_platform() {
|
|||
|
||||
} // namespace
|
||||
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
extern "C" bool uni_platform_on_l2cap_can_send_now(
|
||||
uni_hid_device_t* device, uint16_t cid) {
|
||||
return haptics_experiment_on_can_send_now(device, cid);
|
||||
}
|
||||
#endif
|
||||
|
||||
bool bluepad32_input_backend_capture_start(
|
||||
uint8_t slot, uint32_t connection_generation, const CaptureOptions& options) {
|
||||
if (!g_initialized || slot >= kSlotCount) return false;
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
const BackendSlot& current = g_slots[slot];
|
||||
const bool accepted = current.active &&
|
||||
current.connection_generation == connection_generation &&
|
||||
g_macro_capture.start(slot, connection_generation, options,
|
||||
time_us_32(), current.state);
|
||||
critical_section_exit(&g_state_lock);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
bool bluepad32_input_backend_capture_stop(uint32_t run_id) {
|
||||
if (!g_initialized || run_id == 0) return false;
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
const bool matches = run_id == g_macro_capture.run_id();
|
||||
if (matches) g_macro_capture.stop(time_us_32());
|
||||
critical_section_exit(&g_state_lock);
|
||||
return matches;
|
||||
}
|
||||
|
||||
bool bluepad32_input_backend_capture_page(
|
||||
uint32_t run_id, uint16_t first_index, Bluepad32CaptureSnapshot* output) {
|
||||
if (!g_initialized || output == nullptr) return false;
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
g_macro_capture.tick(time_us_32());
|
||||
if ((run_id != 0 && run_id != g_macro_capture.run_id()) ||
|
||||
first_index > g_macro_capture.event_count()) {
|
||||
critical_section_exit(&g_state_lock);
|
||||
return false;
|
||||
}
|
||||
*output = {};
|
||||
output->run_id = g_macro_capture.run_id();
|
||||
output->connection_generation = g_macro_capture.generation();
|
||||
output->elapsed_us = g_macro_capture.elapsed_us(time_us_32());
|
||||
output->slot = g_macro_capture.slot();
|
||||
output->state = g_macro_capture.state();
|
||||
output->options = g_macro_capture.options();
|
||||
output->total_events = g_macro_capture.event_count();
|
||||
output->first_index = first_index;
|
||||
const uint16_t remaining = output->total_events - first_index;
|
||||
output->event_count = remaining < BLUEPAD32_CAPTURE_PAGE_EVENTS
|
||||
? remaining : BLUEPAD32_CAPTURE_PAGE_EVENTS;
|
||||
for (uint8_t index = 0; index < output->event_count; ++index) {
|
||||
g_macro_capture.event(first_index + index, &output->events[index]);
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
return true;
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_init() {
|
||||
if (g_initialized) {
|
||||
return;
|
||||
|
|
@ -1758,6 +1908,9 @@ void bluepad32_input_backend_init() {
|
|||
critical_section_init(&g_state_lock);
|
||||
configuration_service_prepare();
|
||||
profile_service_prepare();
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
haptics_experiment_prepare();
|
||||
#endif
|
||||
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
|
||||
BackendSlot& slot = g_slots[slot_index];
|
||||
slot = {};
|
||||
|
|
@ -1810,7 +1963,8 @@ void bluepad32_input_backend_start() {
|
|||
|
||||
|
||||
g_started = true;
|
||||
multicore_launch_core1(core1_main);
|
||||
multicore_launch_core1_with_stack(
|
||||
core1_main, g_core1_stack, sizeof(g_core1_stack));
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_open_pairing_window() {
|
||||
|
|
@ -1939,6 +2093,63 @@ void bluepad32_input_backend_snapshot(uint8_t slot_index,
|
|||
g_last_snapshot_generation[slot_index] = state_generation;
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_playtest_snapshot(
|
||||
uint8_t slot_index, Bluepad32PlaytestSnapshot* out) {
|
||||
if (out == nullptr) {
|
||||
return;
|
||||
}
|
||||
*out = {};
|
||||
if (!valid_slot(slot_index) || !g_initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
const BackendSlot& slot = g_slots[slot_index];
|
||||
out->active = slot.active;
|
||||
out->connection_generation = slot.connection_generation;
|
||||
out->state_generation = slot.state_generation;
|
||||
out->identity = slot.identity;
|
||||
out->physical_button_mask = slot.pre_hotkey_button_mask;
|
||||
out->state = slot.state;
|
||||
if (slot.device != nullptr) {
|
||||
out->battery = slot.device->controller.battery;
|
||||
out->capabilities =
|
||||
(slot.device->report_parser.play_dual_rumble != nullptr ? 1u : 0u) |
|
||||
(slot.device->report_parser.set_lightbar_color != nullptr ? 2u : 0u) |
|
||||
(slot.device->report_parser.set_player_leds != nullptr ? 4u : 0u) |
|
||||
(slot.state.motion_sample_count != 0 ? 8u : 0u);
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
}
|
||||
|
||||
bool bluepad32_input_backend_toggle_motion(
|
||||
uint8_t slot_index, uint32_t connection_generation) {
|
||||
if (!g_initialized || !valid_slot(slot_index)) {
|
||||
return false;
|
||||
}
|
||||
bool toggled = false;
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
BackendSlot& slot = g_slots[slot_index];
|
||||
if (slot.active &&
|
||||
slot.connection_generation == connection_generation) {
|
||||
slot.motion_enabled = !slot.motion_enabled;
|
||||
if (slot.motion_enabled) {
|
||||
queue_local_feedback(
|
||||
slot, kMotionEnabledFeedbackDurationMs,
|
||||
kMotionEnabledFeedbackWeakMagnitude,
|
||||
kMotionEnabledFeedbackStrongMagnitude);
|
||||
} else {
|
||||
queue_local_feedback(
|
||||
slot, kMotionDisabledFeedbackDurationMs,
|
||||
kMotionDisabledFeedbackWeakMagnitude,
|
||||
kMotionDisabledFeedbackStrongMagnitude);
|
||||
}
|
||||
toggled = true;
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
return toggled;
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_report_sent(uint8_t slot_index) {
|
||||
if (!g_initialized || !valid_slot(slot_index)) {
|
||||
return;
|
||||
|
|
@ -1952,13 +2163,26 @@ void bluepad32_input_backend_queue_rumble(
|
|||
return;
|
||||
}
|
||||
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
const uint64_t received_us = time_us_64();
|
||||
uint32_t native_generation = 0;
|
||||
bool native_candidate = false;
|
||||
#endif
|
||||
const uint16_t duration_ms = host_rumble_duration_ms();
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
BackendSlot& slot = g_slots[slot_index];
|
||||
if (slot.active && slot.device != nullptr) {
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
native_generation = slot.connection_generation;
|
||||
native_candidate = true;
|
||||
#endif
|
||||
const RumbleEnvelope envelope{
|
||||
slot_index, slot.connection_generation, rumble,
|
||||
duration_ms};
|
||||
duration_ms
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
, received_us
|
||||
#endif
|
||||
};
|
||||
slot.pending_rumble = envelope;
|
||||
slot.rumble_pending = true;
|
||||
__atomic_add_fetch(
|
||||
|
|
@ -1972,6 +2196,49 @@ void bluepad32_input_backend_queue_rumble(
|
|||
}
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
if (native_candidate) {
|
||||
if (duration_ms == kXInputHostRumbleDurationMs) {
|
||||
haptics_experiment_submit_rumble(
|
||||
slot_index, native_generation, received_us,
|
||||
rumble.low_frequency_magnitude, rumble.high_frequency_magnitude);
|
||||
} else {
|
||||
haptics_experiment_submit(
|
||||
slot_index, native_generation, received_us, rumble.hd);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool bluepad32_input_backend_identify(
|
||||
const ControllerIdentity& identity) {
|
||||
if (!g_initialized || !identity.stable ||
|
||||
controller_identity_is_global(identity)) {
|
||||
return false;
|
||||
}
|
||||
bool queued = false;
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
for (BackendSlot& slot : g_slots) {
|
||||
if (!slot.active ||
|
||||
!controller_identity_equal(slot.identity, identity)) {
|
||||
continue;
|
||||
}
|
||||
const ProfileFeedbackEnvelope feedback{
|
||||
slot.connection_generation, 1,
|
||||
ControllerProfileConfirmationPolicy::kRumbleAndLed};
|
||||
if (slot.pending_profile_feedback_count <
|
||||
kProfileFeedbackQueueCapacity) {
|
||||
slot.pending_profile_feedback[
|
||||
slot.pending_profile_feedback_count++] = feedback;
|
||||
} else {
|
||||
slot.pending_profile_feedback[
|
||||
kProfileFeedbackQueueCapacity - 1u] = feedback;
|
||||
}
|
||||
queued = true;
|
||||
break;
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
return queued;
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_queue_profile_feedback(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
#include "core/controller_identity.h"
|
||||
#include "profile/controller_profile.h"
|
||||
#include "core/controller_state.h"
|
||||
#include "input/controller_macro_capture.h"
|
||||
#include "usb/switch/switch_haptics.h"
|
||||
|
||||
constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 4;
|
||||
|
|
@ -62,6 +63,19 @@ struct Bluepad32SlotSnapshot {
|
|||
ControllerState state;
|
||||
};
|
||||
|
||||
// Side-effect-free raw input snapshot for management telemetry. Unlike the
|
||||
// report-path snapshot, reading this does not consume motion samples.
|
||||
struct Bluepad32PlaytestSnapshot {
|
||||
bool active = false;
|
||||
uint32_t connection_generation = 0;
|
||||
uint32_t state_generation = 0;
|
||||
ControllerIdentity identity{};
|
||||
uint16_t physical_button_mask = 0;
|
||||
uint8_t battery = 0;
|
||||
uint8_t capabilities = 0;
|
||||
ControllerState state{};
|
||||
};
|
||||
|
||||
struct Bluepad32BackendDiagnostics {
|
||||
uint32_t initialization_stage;
|
||||
uint32_t rumble_timer_ticks;
|
||||
|
|
@ -86,19 +100,49 @@ void bluepad32_input_backend_open_pairing_window();
|
|||
uint32_t bluepad32_input_backend_clear_pairings();
|
||||
void bluepad32_input_backend_snapshot(uint8_t slot,
|
||||
Bluepad32SlotSnapshot* out);
|
||||
void bluepad32_input_backend_playtest_snapshot(
|
||||
uint8_t slot, Bluepad32PlaytestSnapshot* out);
|
||||
void bluepad32_input_backend_request_pairing_snapshot();
|
||||
void bluepad32_input_backend_pairing_snapshot(
|
||||
Bluepad32PairingSnapshot* out);
|
||||
void bluepad32_input_backend_diagnostics(
|
||||
Bluepad32BackendDiagnostics* out);
|
||||
void bluepad32_input_backend_report_sent(uint8_t slot);
|
||||
// Toggle motion and queue state feedback only for the matching live
|
||||
// connection generation.
|
||||
bool bluepad32_input_backend_toggle_motion(
|
||||
uint8_t slot, uint32_t connection_generation);
|
||||
void bluepad32_input_backend_queue_rumble(
|
||||
uint8_t slot, const ControllerRumbleOutput& rumble);
|
||||
bool bluepad32_input_backend_identify(
|
||||
const ControllerIdentity& identity);
|
||||
// Enqueue bounded local profile confirmation for the matching live connection
|
||||
// generation. The two-entry per-slot FIFO preserves initial-then-switch
|
||||
// ordering. Profile lighting is transient and restored to the steady slot
|
||||
// indication after the final gap. Profile numbers are one-based (1..4).
|
||||
// indication after the final gap. Profile numbers are one-based (1..8).
|
||||
void bluepad32_input_backend_queue_profile_feedback(
|
||||
uint8_t slot, uint32_t connection_generation,
|
||||
uint8_t active_profile_number,
|
||||
ControllerProfileConfirmationPolicy policy);
|
||||
|
||||
constexpr uint8_t BLUEPAD32_CAPTURE_PAGE_EVENTS = 32;
|
||||
struct Bluepad32CaptureSnapshot {
|
||||
uint32_t run_id = 0;
|
||||
uint32_t connection_generation = 0;
|
||||
uint32_t elapsed_us = 0;
|
||||
uint8_t slot = 0xff;
|
||||
CaptureState state = CaptureState::kIdle;
|
||||
CaptureOptions options{};
|
||||
uint16_t total_events = 0;
|
||||
uint16_t first_index = 0;
|
||||
uint8_t event_count = 0;
|
||||
CaptureEvent events[BLUEPAD32_CAPTURE_PAGE_EVENTS]{};
|
||||
};
|
||||
|
||||
// Core 0 management operations; recording itself observes Core 1 input before
|
||||
// profile transforms. All recorder access uses the existing slot-state lock.
|
||||
bool bluepad32_input_backend_capture_start(
|
||||
uint8_t slot, uint32_t connection_generation, const CaptureOptions& options);
|
||||
bool bluepad32_input_backend_capture_stop(uint32_t run_id);
|
||||
bool bluepad32_input_backend_capture_page(
|
||||
uint32_t run_id, uint16_t first_index, Bluepad32CaptureSnapshot* output);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
// Compile-time AIO controller hotkey configuration.
|
||||
// Compile-time AIO controller action feedback configuration.
|
||||
|
||||
#pragma once
|
||||
|
||||
// Motion toggle chord: D-pad Up + R + START / Options.
|
||||
#define SWITCH_MOTION_HOTKEY_DPAD_MASK DPAD_UP
|
||||
#define SWITCH_MOTION_HOTKEY_BUTTON_MASK BUTTON_SHOULDER_R
|
||||
#define SWITCH_MOTION_HOTKEY_MISC_MASK MISC_BUTTON_START
|
||||
#define SWITCH_MOTION_DEFAULT_ENABLED 1
|
||||
|
||||
// A longer pulse confirms disabled; a shorter pulse confirms enabled.
|
||||
|
|
|
|||
232
src/firmware/input/controller_macro_capture.cpp
Normal file
232
src/firmware/input/controller_macro_capture.cpp
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
#include "input/controller_macro_capture.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint8_t kButtons = 1u << 0;
|
||||
constexpr uint8_t kLeftStick = 1u << 1;
|
||||
constexpr uint8_t kRightStick = 1u << 2;
|
||||
constexpr uint8_t kLeftTrigger = 1u << 3;
|
||||
constexpr uint8_t kRightTrigger = 1u << 4;
|
||||
constexpr uint32_t kMaxHoldUs = 10000000;
|
||||
constexpr uint32_t kMaxDurationMs = 80000;
|
||||
|
||||
uint16_t button_mask(const ControllerState& state) {
|
||||
return static_cast<uint16_t>(
|
||||
(state.button_south ? 1u << 0 : 0) |
|
||||
(state.button_east ? 1u << 1 : 0) |
|
||||
(state.button_west ? 1u << 2 : 0) |
|
||||
(state.button_north ? 1u << 3 : 0) |
|
||||
(state.button_left_shoulder ? 1u << 4 : 0) |
|
||||
(state.button_right_shoulder ? 1u << 5 : 0) |
|
||||
(state.button_select ? 1u << 6 : 0) |
|
||||
(state.button_start ? 1u << 7 : 0) |
|
||||
(state.button_system ? 1u << 8 : 0) |
|
||||
(state.button_capture ? 1u << 9 : 0) |
|
||||
(state.button_left_stick ? 1u << 10 : 0) |
|
||||
(state.button_right_stick ? 1u << 11 : 0) |
|
||||
(state.dpad_up ? 1u << 12 : 0) |
|
||||
(state.dpad_down ? 1u << 13 : 0) |
|
||||
(state.dpad_left ? 1u << 14 : 0) |
|
||||
(state.dpad_right ? 1u << 15 : 0));
|
||||
}
|
||||
|
||||
// Round magnitudes to nearest, with ties away from zero. Keep full scale
|
||||
// exact and use the same magnitude on both sides of zero where representable.
|
||||
int16_t quantize_axis(int16_t value, uint16_t quantum) {
|
||||
if (value == INT16_MIN) {
|
||||
return value;
|
||||
}
|
||||
const int32_t magnitude = value < 0 ? -static_cast<int32_t>(value) : value;
|
||||
if (magnitude == INT16_MAX) {
|
||||
return value;
|
||||
}
|
||||
int32_t rounded = ((magnitude + quantum / 2) / quantum) * quantum;
|
||||
if (rounded > INT16_MAX) {
|
||||
rounded = INT16_MAX;
|
||||
}
|
||||
return static_cast<int16_t>(value < 0 ? -rounded : rounded);
|
||||
}
|
||||
|
||||
uint16_t quantize_trigger(uint16_t value, uint16_t quantum) {
|
||||
if (value == UINT16_MAX) {
|
||||
return value;
|
||||
}
|
||||
const uint32_t rounded =
|
||||
((static_cast<uint32_t>(value) + quantum / 2u) / quantum) * quantum;
|
||||
return static_cast<uint16_t>(rounded > UINT16_MAX ? UINT16_MAX : rounded);
|
||||
}
|
||||
|
||||
bool same_channels(const CaptureEvent& left, const CaptureEvent& right) {
|
||||
return left.buttons == right.buttons && left.left_x == right.left_x &&
|
||||
left.left_y == right.left_y && left.right_x == right.right_x &&
|
||||
left.right_y == right.right_y &&
|
||||
left.left_trigger == right.left_trigger &&
|
||||
left.right_trigger == right.right_trigger;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ControllerMacroCapture::start(uint8_t slot, uint32_t generation,
|
||||
const CaptureOptions& options,
|
||||
uint32_t now_us,
|
||||
const ControllerState& initial) {
|
||||
if (state_ == CaptureState::kRecording || slot >= 4 ||
|
||||
options.channels == 0 || options.channels > 31 ||
|
||||
options.axis_quantum == 0 || options.axis_quantum > INT16_MAX ||
|
||||
options.trigger_quantum == 0 || options.max_events == 0 ||
|
||||
options.max_events > kCapacity || options.max_duration_ms == 0 ||
|
||||
options.max_duration_ms > kMaxDurationMs) {
|
||||
return false;
|
||||
}
|
||||
options_ = options;
|
||||
slot_ = slot;
|
||||
generation_ = generation;
|
||||
started_us_ = now_us;
|
||||
ended_us_ = 0;
|
||||
++run_id_;
|
||||
if (run_id_ == 0) {
|
||||
++run_id_;
|
||||
}
|
||||
events_[0] = snapshot(initial, 0);
|
||||
event_count_ = 1;
|
||||
state_ = CaptureState::kRecording;
|
||||
return true;
|
||||
}
|
||||
|
||||
CaptureEvent ControllerMacroCapture::snapshot(const ControllerState& state,
|
||||
uint32_t at_us) const {
|
||||
CaptureEvent captured{};
|
||||
captured.at_us = at_us;
|
||||
if ((options_.channels & kButtons) != 0) {
|
||||
captured.buttons = button_mask(state);
|
||||
}
|
||||
if ((options_.channels & kLeftStick) != 0) {
|
||||
captured.left_x = quantize_axis(state.left_stick_x, options_.axis_quantum);
|
||||
captured.left_y = quantize_axis(state.left_stick_y, options_.axis_quantum);
|
||||
}
|
||||
if ((options_.channels & kRightStick) != 0) {
|
||||
captured.right_x = quantize_axis(state.right_stick_x, options_.axis_quantum);
|
||||
captured.right_y = quantize_axis(state.right_stick_y, options_.axis_quantum);
|
||||
}
|
||||
if ((options_.channels & kLeftTrigger) != 0) {
|
||||
captured.left_trigger =
|
||||
quantize_trigger(state.left_trigger, options_.trigger_quantum);
|
||||
}
|
||||
if ((options_.channels & kRightTrigger) != 0) {
|
||||
captured.right_trigger =
|
||||
quantize_trigger(state.right_trigger, options_.trigger_quantum);
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
void ControllerMacroCapture::finish(CaptureState state, uint32_t at_us) {
|
||||
state_ = state;
|
||||
ended_us_ = at_us;
|
||||
}
|
||||
|
||||
bool ControllerMacroCapture::append(const CaptureEvent& captured) {
|
||||
if (captured.at_us == events_[event_count_ - 1].at_us) {
|
||||
events_[event_count_ - 1] = captured;
|
||||
return true;
|
||||
}
|
||||
if (event_count_ >= options_.max_events) {
|
||||
finish(CaptureState::kFull, captured.at_us);
|
||||
return false;
|
||||
}
|
||||
events_[event_count_++] = captured;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ControllerMacroCapture::advance(uint32_t now_us, bool ending) {
|
||||
if (state_ != CaptureState::kRecording) {
|
||||
return;
|
||||
}
|
||||
const uint32_t elapsed = now_us - started_us_;
|
||||
const uint32_t duration = options_.max_duration_ms * 1000u;
|
||||
const bool timed_out = elapsed >= duration;
|
||||
const uint32_t target = timed_out ? duration : elapsed;
|
||||
// An ending interval may be exactly 10 seconds; it needs no zero-length
|
||||
// trailing event. A live boundary can be replaced by an edge at that time.
|
||||
for (uint16_t step = 0; step < kCapacity; ++step) {
|
||||
const CaptureEvent& previous = events_[event_count_ - 1];
|
||||
const uint32_t boundary = previous.at_us + kMaxHoldUs;
|
||||
if (boundary > target ||
|
||||
(boundary == target && (ending || timed_out))) {
|
||||
break;
|
||||
}
|
||||
CaptureEvent held = previous;
|
||||
held.at_us = boundary;
|
||||
if (!append(held)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (timed_out) {
|
||||
finish(CaptureState::kTimedOut, duration);
|
||||
}
|
||||
}
|
||||
|
||||
void ControllerMacroCapture::observe(uint8_t slot, uint32_t generation,
|
||||
uint32_t now_us,
|
||||
const ControllerState& state) {
|
||||
if (state_ != CaptureState::kRecording || slot != slot_) {
|
||||
return;
|
||||
}
|
||||
if (generation != generation_) {
|
||||
advance(now_us, true);
|
||||
if (state_ == CaptureState::kRecording) {
|
||||
finish(CaptureState::kDisconnected, now_us - started_us_);
|
||||
}
|
||||
return;
|
||||
}
|
||||
advance(now_us, false);
|
||||
if (state_ != CaptureState::kRecording) {
|
||||
return;
|
||||
}
|
||||
const CaptureEvent captured = snapshot(state, now_us - started_us_);
|
||||
if (!same_channels(captured, events_[event_count_ - 1])) {
|
||||
append(captured);
|
||||
}
|
||||
}
|
||||
|
||||
void ControllerMacroCapture::stop(uint32_t now_us) {
|
||||
advance(now_us, true);
|
||||
if (state_ == CaptureState::kRecording) {
|
||||
finish(CaptureState::kStopped, now_us - started_us_);
|
||||
}
|
||||
}
|
||||
|
||||
void ControllerMacroCapture::disconnect(uint8_t slot, uint32_t generation,
|
||||
uint32_t now_us) {
|
||||
if (state_ != CaptureState::kRecording || slot != slot_ ||
|
||||
generation != generation_) {
|
||||
return;
|
||||
}
|
||||
advance(now_us, true);
|
||||
if (state_ == CaptureState::kRecording) {
|
||||
finish(CaptureState::kDisconnected, now_us - started_us_);
|
||||
}
|
||||
}
|
||||
|
||||
void ControllerMacroCapture::tick(uint32_t now_us) {
|
||||
advance(now_us, false);
|
||||
}
|
||||
|
||||
uint32_t ControllerMacroCapture::elapsed_us(uint32_t now_us) const {
|
||||
if (state_ == CaptureState::kIdle) {
|
||||
return 0;
|
||||
}
|
||||
if (state_ != CaptureState::kRecording) {
|
||||
return ended_us_;
|
||||
}
|
||||
const uint32_t elapsed = now_us - started_us_;
|
||||
const uint32_t duration = options_.max_duration_ms * 1000u;
|
||||
return elapsed < duration ? elapsed : duration;
|
||||
}
|
||||
|
||||
bool ControllerMacroCapture::event(uint16_t index, CaptureEvent* output) const {
|
||||
if (output == nullptr || index >= event_count_) {
|
||||
return false;
|
||||
}
|
||||
*output = events_[index];
|
||||
return true;
|
||||
}
|
||||
73
src/firmware/input/controller_macro_capture.h
Normal file
73
src/firmware/input/controller_macro_capture.h
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include "core/controller_state.h"
|
||||
|
||||
enum class CaptureState : uint8_t {
|
||||
kIdle = 0,
|
||||
kRecording = 1,
|
||||
kStopped = 2,
|
||||
kFull = 3,
|
||||
kTimedOut = 4,
|
||||
kDisconnected = 5,
|
||||
};
|
||||
|
||||
struct CaptureOptions {
|
||||
uint8_t channels = 1;
|
||||
uint16_t axis_quantum = 512;
|
||||
uint16_t trigger_quantum = 1024;
|
||||
uint8_t max_events = 8;
|
||||
uint32_t max_duration_ms = 10000;
|
||||
};
|
||||
|
||||
struct CaptureEvent {
|
||||
uint32_t at_us;
|
||||
uint16_t buttons;
|
||||
int16_t left_x;
|
||||
int16_t left_y;
|
||||
int16_t right_x;
|
||||
int16_t right_y;
|
||||
uint16_t left_trigger;
|
||||
uint16_t right_trigger;
|
||||
};
|
||||
|
||||
// Caller serializes access. Timestamps are a monotonically advancing uint32
|
||||
// microsecond clock, including wrap; unselected event channels are zero.
|
||||
class ControllerMacroCapture {
|
||||
public:
|
||||
static constexpr uint16_t kCapacity = 128;
|
||||
|
||||
bool start(uint8_t slot, uint32_t generation,
|
||||
const CaptureOptions& options, uint32_t now_us,
|
||||
const ControllerState& initial);
|
||||
void observe(uint8_t slot, uint32_t generation, uint32_t now_us,
|
||||
const ControllerState& state);
|
||||
void stop(uint32_t now_us);
|
||||
void disconnect(uint8_t slot, uint32_t generation, uint32_t now_us);
|
||||
void tick(uint32_t now_us);
|
||||
|
||||
CaptureState state() const { return state_; }
|
||||
const CaptureOptions& options() const { return options_; }
|
||||
uint8_t slot() const { return slot_; }
|
||||
uint32_t generation() const { return generation_; }
|
||||
uint32_t run_id() const { return run_id_; }
|
||||
uint16_t event_count() const { return event_count_; }
|
||||
uint32_t elapsed_us(uint32_t now_us) const;
|
||||
bool event(uint16_t index, CaptureEvent* output) const;
|
||||
|
||||
private:
|
||||
CaptureEvent snapshot(const ControllerState& state, uint32_t at_us) const;
|
||||
bool append(const CaptureEvent& event);
|
||||
void advance(uint32_t now_us, bool ending);
|
||||
void finish(CaptureState state, uint32_t at_us);
|
||||
|
||||
CaptureOptions options_{};
|
||||
CaptureState state_ = CaptureState::kIdle;
|
||||
uint8_t slot_ = 0;
|
||||
uint32_t generation_ = 0;
|
||||
uint32_t run_id_ = 0;
|
||||
uint32_t started_us_ = 0;
|
||||
uint32_t ended_us_ = 0;
|
||||
uint16_t event_count_ = 0;
|
||||
CaptureEvent events_[kCapacity]{};
|
||||
};
|
||||
965
src/firmware/input/haptics_experiment.cpp
Normal file
965
src/firmware/input/haptics_experiment.cpp
Normal file
|
|
@ -0,0 +1,965 @@
|
|||
#include "input/haptics_experiment.h"
|
||||
#include "input/haptics_transport_probe.h"
|
||||
#include "input/switch_hd_rumble_synth.h"
|
||||
|
||||
#include <btstack.h>
|
||||
#include <pico/critical_section.h>
|
||||
#include <pico/stdlib.h>
|
||||
#include <uni.h>
|
||||
|
||||
#if SWITCH_PICO_HAPTICS_EXPERIMENT_RAM
|
||||
#define HAPTICS_HOT(name) __time_critical_func(name)
|
||||
#define HAPTICS_DATA __not_in_flash("haptics_experiment_waveform")
|
||||
#else
|
||||
#define HAPTICS_HOT(name) name
|
||||
#define HAPTICS_DATA
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
#ifndef SWITCH_PICO_HD_PACKET_FRAMES
|
||||
#define SWITCH_PICO_HD_PACKET_FRAMES 64
|
||||
#endif
|
||||
constexpr uint32_t kGameplayFrames = SWITCH_PICO_HD_PACKET_FRAMES;
|
||||
static_assert(kGameplayFrames == 32 || kGameplayFrames == 64);
|
||||
constexpr uint32_t kPacketDenominator = 3;
|
||||
constexpr uint32_t kPackets = 288;
|
||||
constexpr uint32_t kPrimingPackets = 48;
|
||||
constexpr uint32_t kToneEndPacket = 240;
|
||||
constexpr uint32_t kPhasePackets = 12;
|
||||
constexpr uint32_t kDrainTimeoutUs = 100000;
|
||||
// Briefly retain ownership while compatibility output drains. Its parser timer
|
||||
// is canceled synchronously; it must not survive a device disconnect/reuse.
|
||||
constexpr uint32_t kRestoreSettleUs = 3000;
|
||||
constexpr uint16_t kReportBytes = 143; // A2 + 142-byte report 0x32.
|
||||
constexpr uint16_t kCrcOffset = kReportBytes - 4;
|
||||
constexpr uint8_t kNoSlot = 0xff;
|
||||
|
||||
enum Error : uint8_t {
|
||||
kNoError = 0,
|
||||
kUnsupported = 1,
|
||||
kMtu = 2,
|
||||
kConnection = 3,
|
||||
kTimeout = 4,
|
||||
kTransport = 5,
|
||||
kQueuedOutput = 6,
|
||||
};
|
||||
|
||||
enum class Phase { kIdle, kPrepare, kPattern, kDrain, kRestore };
|
||||
|
||||
struct Attachment {
|
||||
uni_hid_device_t* device = nullptr;
|
||||
uint32_t generation = 0;
|
||||
uint16_t cid = 0;
|
||||
};
|
||||
|
||||
struct Command {
|
||||
bool pending = false;
|
||||
uint8_t action = 0;
|
||||
uint8_t slot = kNoSlot;
|
||||
uint32_t run_id = 0;
|
||||
Attachment connection{};
|
||||
uint8_t mode = 0;
|
||||
};
|
||||
|
||||
struct HostUpdate {
|
||||
uint64_t received_us = 0;
|
||||
SwitchHapticsFrame frame{};
|
||||
bool rumble = false;
|
||||
uint8_t low = 0;
|
||||
uint8_t high = 0;
|
||||
};
|
||||
constexpr uint8_t kHostQueueCapacity = 16;
|
||||
|
||||
// Only the attachment identities, mailbox and published snapshot cross cores.
|
||||
// No BTstack call (including synchronous reentry) holds this lock.
|
||||
critical_section_t g_lock;
|
||||
HapticsExperimentDiagnostics g_snapshot;
|
||||
bool g_snapshot_waiting = false;
|
||||
uint32_t g_snapshot_request_us = 0;
|
||||
Command g_command;
|
||||
bool g_busy = false;
|
||||
bool g_prepared = false;
|
||||
bool g_accept_host = false;
|
||||
HostUpdate g_host_queue[kHostQueueCapacity];
|
||||
uint8_t g_host_head = 0;
|
||||
uint8_t g_host_count = 0;
|
||||
uint32_t g_host_updates = 0;
|
||||
uint32_t g_host_drops = 0;
|
||||
|
||||
// Written on core 1 under the lock; core 0 only reads identities for requests.
|
||||
Attachment g_attachments[4];
|
||||
|
||||
// All remaining state belongs exclusively to the BTstack core.
|
||||
HapticsExperimentDiagnostics g_diagnostics;
|
||||
Attachment g_connection;
|
||||
Phase g_phase = Phase::kIdle;
|
||||
HapticsExperimentState g_finish_state = HapticsExperimentState::kCompleted;
|
||||
btstack_timer_source_t g_cadence_timer{};
|
||||
btstack_timer_source_t g_lifecycle_timer{};
|
||||
bool g_cadence_armed = false;
|
||||
bool g_lifecycle_armed = false;
|
||||
bool g_send_requested = false;
|
||||
bool g_request_in_progress = false;
|
||||
bool g_in_callback = false;
|
||||
bool g_last_was_silence = true;
|
||||
bool g_first_tone_sent = false;
|
||||
uint32_t g_next_packet = 0;
|
||||
uint64_t g_start_us = 0;
|
||||
uint64_t g_end_us = 0;
|
||||
uint64_t g_lifecycle_due_us = 0;
|
||||
uint64_t g_request_us = 0;
|
||||
uint64_t g_restore_deadline_us = 0;
|
||||
SwitchHdRumbleSynth g_synth;
|
||||
bool g_synth_started = false;
|
||||
|
||||
// round(32 * sin(2*pi*n/30)). A stride of one is 100 Hz at 3 kHz;
|
||||
// a stride of two is 200 Hz. Preserve phase across all 12 packets of a tone.
|
||||
static const int8_t HAPTICS_DATA kSine[30] = {
|
||||
0, 7, 13, 19, 24, 28, 30, 32, 32, 30, 28, 24, 19, 13, 7,
|
||||
0, -7, -13, -19, -24, -28, -30, -32, -32, -30, -28, -24, -19, -13, -7,
|
||||
};
|
||||
|
||||
void cadence_timer(btstack_timer_source_t*);
|
||||
void lifecycle_timer(btstack_timer_source_t*);
|
||||
void request_send();
|
||||
void restore_compatibility(HapticsExperimentState state);
|
||||
void start_stream();
|
||||
|
||||
bool gameplay() {
|
||||
return g_diagnostics.mode == 1;
|
||||
}
|
||||
|
||||
uint32_t packet_numerator_us() {
|
||||
return (gameplay() ? kGameplayFrames : 64u) * 1000u;
|
||||
}
|
||||
|
||||
void drain_host_updates() {
|
||||
// Bound work even if USB keeps publishing while the BT core drains.
|
||||
for (uint8_t index = 0; index < kHostQueueCapacity; ++index) {
|
||||
HostUpdate update;
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
if (g_host_count == 0) {
|
||||
critical_section_exit(&g_lock);
|
||||
break;
|
||||
}
|
||||
update = g_host_queue[g_host_head];
|
||||
g_host_head = (g_host_head + 1) % kHostQueueCapacity;
|
||||
--g_host_count;
|
||||
critical_section_exit(&g_lock);
|
||||
if (update.rumble) {
|
||||
g_synth.push_rumble(update.low, update.high, update.received_us);
|
||||
} else {
|
||||
g_synth.push(update.frame, update.received_us);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool submit_host_update(uint8_t slot, uint32_t generation,
|
||||
const HostUpdate& update) {
|
||||
if (!g_prepared || slot >= 4) return false;
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
const bool accepted = g_accept_host && g_busy && g_snapshot.mode == 1 &&
|
||||
g_snapshot.slot == slot &&
|
||||
g_snapshot.connection_generation == generation &&
|
||||
g_attachments[slot].device != nullptr &&
|
||||
g_attachments[slot].generation == generation;
|
||||
if (accepted) {
|
||||
if (g_host_count == kHostQueueCapacity) {
|
||||
g_host_head = (g_host_head + 1) % kHostQueueCapacity;
|
||||
--g_host_count;
|
||||
if (g_host_drops != UINT32_MAX) ++g_host_drops;
|
||||
}
|
||||
const uint8_t index = (g_host_head + g_host_count) % kHostQueueCapacity;
|
||||
g_host_queue[index] = update;
|
||||
++g_host_count;
|
||||
if (g_host_updates != UINT32_MAX) ++g_host_updates;
|
||||
}
|
||||
critical_section_exit(&g_lock);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
void update_max(uint32_t* value, uint32_t candidate) {
|
||||
if (candidate > *value) {
|
||||
*value = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
uint64_t packet_due(uint32_t packet) {
|
||||
// Round each absolute rational deadline up, never its relative interval.
|
||||
return g_start_us +
|
||||
(static_cast<uint64_t>(packet) * packet_numerator_us() +
|
||||
kPacketDenominator - 1) / kPacketDenominator;
|
||||
}
|
||||
|
||||
bool connection_current() {
|
||||
if (g_phase == Phase::kIdle || g_diagnostics.slot >= 4) {
|
||||
return false;
|
||||
}
|
||||
const Attachment& attached = g_attachments[g_diagnostics.slot];
|
||||
return attached.device == g_connection.device &&
|
||||
attached.generation == g_connection.generation &&
|
||||
attached.cid == g_connection.cid;
|
||||
}
|
||||
|
||||
void publish(bool finished = false) {
|
||||
if (g_phase != Phase::kIdle) {
|
||||
g_diagnostics.elapsed_us =
|
||||
static_cast<uint32_t>(time_us_64() - g_start_us);
|
||||
}
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
// A newly accepted start must not be overwritten by the preceding run.
|
||||
if (g_snapshot.run_id == g_diagnostics.run_id) {
|
||||
g_diagnostics.host_updates = g_host_updates;
|
||||
const uint64_t dropped = uint64_t{g_host_drops} +
|
||||
(gameplay() && g_synth_started ? g_synth.dropped_updates() : 0);
|
||||
g_diagnostics.dropped_updates =
|
||||
dropped > UINT32_MAX ? UINT32_MAX : static_cast<uint32_t>(dropped);
|
||||
g_snapshot = g_diagnostics;
|
||||
g_snapshot_waiting = g_send_requested;
|
||||
g_snapshot_request_us = static_cast<uint32_t>(g_request_us);
|
||||
if (finished) {
|
||||
g_busy = false;
|
||||
g_accept_host = false;
|
||||
}
|
||||
}
|
||||
critical_section_exit(&g_lock);
|
||||
}
|
||||
|
||||
void cancel_timer(btstack_timer_source_t* timer, bool* armed) {
|
||||
if (*armed) {
|
||||
btstack_run_loop_remove_timer(timer);
|
||||
*armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
void schedule_timer(btstack_timer_source_t* timer, bool* armed,
|
||||
uint64_t deadline_us) {
|
||||
cancel_timer(timer, armed);
|
||||
const uint64_t now_us = time_us_64();
|
||||
// Pico's relative timer is floor(now_us/1000) + timeout_ms + 1.
|
||||
// Aim for the deadline's millisecond (possibly early); the handlers check
|
||||
// microseconds again. Only that final fractional tick needs a zero-delay
|
||||
// rearm. There is no permanent millisecond polling timer.
|
||||
const uint64_t now_ms = now_us / 1000;
|
||||
const uint64_t due_ms = deadline_us / 1000;
|
||||
const uint32_t delay_ms = due_ms > now_ms + 1
|
||||
? static_cast<uint32_t>(due_ms - now_ms - 1)
|
||||
: 0;
|
||||
btstack_run_loop_set_timer(timer, delay_ms);
|
||||
*armed = true;
|
||||
btstack_run_loop_add_timer(timer);
|
||||
}
|
||||
|
||||
void schedule_lifecycle(uint64_t deadline_us) {
|
||||
g_lifecycle_due_us = deadline_us;
|
||||
schedule_timer(&g_lifecycle_timer, &g_lifecycle_armed, deadline_us);
|
||||
}
|
||||
|
||||
void finish(HapticsExperimentState state, uint8_t error) {
|
||||
haptics_transport_probe_end();
|
||||
cancel_timer(&g_cadence_timer, &g_cadence_armed);
|
||||
cancel_timer(&g_lifecycle_timer, &g_lifecycle_armed);
|
||||
g_send_requested = false;
|
||||
g_diagnostics.elapsed_us =
|
||||
static_cast<uint32_t>(time_us_64() - g_start_us);
|
||||
g_diagnostics.state = state;
|
||||
if (error != kNoError) {
|
||||
g_diagnostics.last_error = error;
|
||||
}
|
||||
g_phase = Phase::kIdle;
|
||||
g_connection = {};
|
||||
publish(true);
|
||||
}
|
||||
|
||||
void account_wait(uint64_t now_us) {
|
||||
update_max(&g_diagnostics.max_request_wait_us,
|
||||
static_cast<uint32_t>(now_us - g_request_us));
|
||||
}
|
||||
|
||||
void timeout_drain() {
|
||||
if (g_send_requested) {
|
||||
account_wait(time_us_64());
|
||||
}
|
||||
++g_diagnostics.send_failures;
|
||||
g_diagnostics.last_error = kTimeout;
|
||||
// The outstanding BTstack notification cannot be canceled. Stop accepting
|
||||
// it as PCM permission; the normal FIFO may use it for compatibility output.
|
||||
g_send_requested = false;
|
||||
restore_compatibility(HapticsExperimentState::kError);
|
||||
}
|
||||
|
||||
void begin_drain(HapticsExperimentState state, uint64_t deadline_us) {
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
g_accept_host = false;
|
||||
critical_section_exit(&g_lock);
|
||||
cancel_timer(&g_cadence_timer, &g_cadence_armed);
|
||||
g_phase = Phase::kDrain;
|
||||
g_finish_state = state;
|
||||
schedule_lifecycle(deadline_us);
|
||||
if (time_us_64() >= deadline_us) {
|
||||
timeout_drain();
|
||||
} else if (!g_send_requested) {
|
||||
request_send();
|
||||
}
|
||||
}
|
||||
|
||||
void end_pattern() {
|
||||
g_diagnostics.skipped_packets += kPackets - g_next_packet;
|
||||
g_next_packet = kPackets;
|
||||
if (!g_send_requested && g_diagnostics.sent_packets != 0 &&
|
||||
g_last_was_silence) {
|
||||
restore_compatibility(HapticsExperimentState::kCompleted);
|
||||
} else {
|
||||
// Do not let a never-delivered CAN_SEND_NOW strand ownership forever.
|
||||
begin_drain(HapticsExperimentState::kCompleted,
|
||||
g_end_us + kDrainTimeoutUs);
|
||||
}
|
||||
}
|
||||
|
||||
void restore_compatibility(HapticsExperimentState state) {
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
g_accept_host = false;
|
||||
critical_section_exit(&g_lock);
|
||||
cancel_timer(&g_cadence_timer, &g_cadence_armed);
|
||||
g_send_requested = false;
|
||||
g_phase = Phase::kRestore;
|
||||
g_finish_state = g_diagnostics.send_failures != 0
|
||||
? HapticsExperimentState::kError
|
||||
: state;
|
||||
// duration=0 is a no-op when the parser already believes rumble is off.
|
||||
// Force the HAPTICS_SELECT / compatible-vibration report with zero motors.
|
||||
// Set the phase first: synchronous notifications here belong to that FIFO.
|
||||
g_connection.device->report_parser.play_dual_rumble(
|
||||
g_connection.device, 0, 1, 0, 0);
|
||||
// Immediately cancel the newly installed parser timer and emit its zero
|
||||
// stop. Upstream device deletion does not remove private parser timers.
|
||||
// No host effect can interleave between these two calls on the BT core.
|
||||
g_connection.device->report_parser.play_dual_rumble(
|
||||
g_connection.device, 0, 0, 0, 0);
|
||||
const uint64_t now_us = time_us_64();
|
||||
g_restore_deadline_us = now_us + kDrainTimeoutUs;
|
||||
schedule_lifecycle(now_us + kRestoreSettleUs);
|
||||
publish();
|
||||
}
|
||||
|
||||
void request_send() {
|
||||
if (g_send_requested || g_request_in_progress ||
|
||||
(g_phase != Phase::kPattern && g_phase != Phase::kDrain)) {
|
||||
return;
|
||||
}
|
||||
g_request_us = time_us_64();
|
||||
g_send_requested = true;
|
||||
g_request_in_progress = true;
|
||||
++g_diagnostics.can_send_requests;
|
||||
if (gameplay() && g_phase == Phase::kPattern) {
|
||||
schedule_lifecycle(g_request_us + kDrainTimeoutUs);
|
||||
}
|
||||
const uint8_t status =
|
||||
l2cap_request_can_send_now_event(g_connection.cid);
|
||||
g_request_in_progress = false;
|
||||
if (status == ERROR_CODE_SUCCESS && !g_send_requested) {
|
||||
// The synchronous callback already published its result and armed the
|
||||
// next deadline. Do not touch that timer or copy its snapshot again.
|
||||
return;
|
||||
}
|
||||
if (status != ERROR_CODE_SUCCESS && g_send_requested) {
|
||||
g_send_requested = false;
|
||||
++g_diagnostics.send_failures;
|
||||
g_diagnostics.last_error = kTransport;
|
||||
// A failed request is not permission. Retry on a future deadline, not
|
||||
// through a recursive callback or a tight loop.
|
||||
if (g_phase == Phase::kPattern) {
|
||||
const uint64_t now_us = time_us_64();
|
||||
if (now_us >= g_end_us) {
|
||||
end_pattern();
|
||||
} else {
|
||||
const uint32_t current = static_cast<uint32_t>(
|
||||
((now_us - g_start_us) * kPacketDenominator) /
|
||||
packet_numerator_us());
|
||||
g_diagnostics.skipped_packets += current + 1 - g_next_packet;
|
||||
g_next_packet = current + 1;
|
||||
if (gameplay() || g_next_packet < kPackets) {
|
||||
schedule_timer(&g_cadence_timer, &g_cadence_armed,
|
||||
packet_due(g_next_packet));
|
||||
}
|
||||
}
|
||||
} else if (g_phase == Phase::kDrain) {
|
||||
schedule_timer(&g_cadence_timer, &g_cadence_armed,
|
||||
time_us_64() + (packet_numerator_us() + 2) / 3);
|
||||
}
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
// Returns whether the block contains a scheduled tone (not actuator evidence).
|
||||
bool HAPTICS_HOT(generate_packet)(uint8_t* report, uint32_t packet,
|
||||
bool silence) {
|
||||
for (uint16_t i = 0; i < kReportBytes; ++i) {
|
||||
report[i] = 0;
|
||||
}
|
||||
report[0] = 0xa2;
|
||||
report[1] = 0x32;
|
||||
uint16_t sample_offset;
|
||||
uint32_t frames;
|
||||
if (g_diagnostics.sent_packets == 0) {
|
||||
// Explicitly leave compatibility mode with a sized 0x10 state block.
|
||||
// All other state-write flags remain clear, preserving other outputs.
|
||||
report[3] = 0x90;
|
||||
report[4] = 63;
|
||||
report[68] = 0x92;
|
||||
report[69] = 64;
|
||||
sample_offset = 70;
|
||||
frames = 32; // Initial mode handoff occupies the first silent interval.
|
||||
} else {
|
||||
// Compact controls leave room for either one or two 64-byte blocks.
|
||||
frames = gameplay() ? kGameplayFrames : 64u;
|
||||
report[3] = 0x91;
|
||||
report[4] = 3;
|
||||
report[5] = 0x62;
|
||||
report[6] = 16;
|
||||
report[7] = static_cast<uint8_t>(g_diagnostics.sent_packets * (frames / 32));
|
||||
report[8] = frames == 64 ? 0xd2 : 0x92;
|
||||
report[9] = 64;
|
||||
sample_offset = 10;
|
||||
}
|
||||
bool tone = false;
|
||||
if (gameplay() && !silence) {
|
||||
drain_host_updates();
|
||||
// One report of causal lookback preserves every 8 ms USB command,
|
||||
// including substeps arriving between Bluetooth sends.
|
||||
if (packet != 0 && g_diagnostics.sent_packets != 0) {
|
||||
const uint64_t first_sample = uint64_t{packet - 1} * frames;
|
||||
g_synth.render(first_sample, frames, report + sample_offset);
|
||||
for (uint32_t frame = 0; frame < frames; ++frame) {
|
||||
if (report[sample_offset + frame * 2] != 0 ||
|
||||
report[sample_offset + frame * 2 + 1] != 0) {
|
||||
tone = true;
|
||||
if (!g_first_tone_sent) {
|
||||
g_diagnostics.first_tone_due_us = static_cast<uint32_t>(
|
||||
g_start_us + ((first_sample + frame) * 1000 + 2) / 3);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!gameplay() && !silence && packet >= kPrimingPackets &&
|
||||
packet < kToneEndPacket) {
|
||||
const uint32_t relative = packet - kPrimingPackets;
|
||||
const uint32_t phase = (relative / kPhasePackets) % 4;
|
||||
if (phase == 0 || phase == 2) {
|
||||
tone = true;
|
||||
const uint32_t stride = phase == 0 ? 1 : 2;
|
||||
uint32_t wave = ((relative % kPhasePackets) * 64 * stride) % 30;
|
||||
const uint32_t channel = phase == 0 ? 0 : 1;
|
||||
for (uint32_t frame = 0; frame < frames; ++frame) {
|
||||
report[sample_offset + frame * 2 + channel] =
|
||||
static_cast<uint8_t>(kSine[wave]);
|
||||
wave += stride;
|
||||
if (wave >= 30) {
|
||||
wave -= 30;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bluetooth CRC includes the A2 transaction byte and excludes only CRC.
|
||||
uint32_t crc = 0xffffffffu;
|
||||
for (uint16_t i = 0; i < kCrcOffset; ++i) {
|
||||
crc ^= report[i];
|
||||
for (uint8_t bit = 0; bit < 8; ++bit) {
|
||||
crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1u)));
|
||||
}
|
||||
}
|
||||
crc = ~crc;
|
||||
for (uint8_t byte = 0; byte < 4; ++byte) {
|
||||
report[kCrcOffset + byte] = static_cast<uint8_t>(crc >> (byte * 8));
|
||||
}
|
||||
return tone;
|
||||
}
|
||||
|
||||
void cadence_timer(btstack_timer_source_t*) {
|
||||
g_cadence_armed = false;
|
||||
if (!connection_current()) {
|
||||
if (g_phase != Phase::kIdle) {
|
||||
finish(HapticsExperimentState::kDisconnected, kConnection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const uint64_t now_us = time_us_64();
|
||||
if (g_phase == Phase::kPrepare) {
|
||||
if (uni_circular_buffer_is_empty(&g_connection.device->outgoing_buffer)) {
|
||||
start_stream();
|
||||
} else if (now_us >= g_lifecycle_due_us) {
|
||||
finish(HapticsExperimentState::kError, kQueuedOutput);
|
||||
} else {
|
||||
schedule_timer(&g_cadence_timer, &g_cadence_armed, now_us + 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (g_phase == Phase::kPattern) {
|
||||
const uint64_t due_us = packet_due(g_next_packet);
|
||||
haptics_transport_probe_timer(
|
||||
now_us > due_us ? static_cast<uint32_t>(now_us - due_us) : 0);
|
||||
}
|
||||
if (g_phase == Phase::kPattern) {
|
||||
if (now_us >= g_end_us) {
|
||||
end_pattern();
|
||||
} else if (now_us < packet_due(g_next_packet)) {
|
||||
schedule_timer(&g_cadence_timer, &g_cadence_armed,
|
||||
packet_due(g_next_packet));
|
||||
} else {
|
||||
request_send();
|
||||
}
|
||||
} else if (g_phase == Phase::kDrain) {
|
||||
if (now_us >= g_lifecycle_due_us) {
|
||||
timeout_drain();
|
||||
} else {
|
||||
request_send();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void lifecycle_timer(btstack_timer_source_t*) {
|
||||
g_lifecycle_armed = false;
|
||||
if (!connection_current()) {
|
||||
if (g_phase != Phase::kIdle) {
|
||||
finish(HapticsExperimentState::kDisconnected, kConnection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (time_us_64() < g_lifecycle_due_us) {
|
||||
schedule_lifecycle(g_lifecycle_due_us);
|
||||
return;
|
||||
}
|
||||
if (g_phase == Phase::kPrepare) {
|
||||
finish(HapticsExperimentState::kError, kQueuedOutput);
|
||||
} else if (g_phase == Phase::kPattern) {
|
||||
if (gameplay()) {
|
||||
timeout_drain();
|
||||
} else {
|
||||
end_pattern();
|
||||
}
|
||||
} else if (g_phase == Phase::kDrain) {
|
||||
timeout_drain();
|
||||
} else if (g_phase == Phase::kRestore) {
|
||||
if (uni_circular_buffer_is_empty(&g_connection.device->outgoing_buffer)) {
|
||||
finish(g_finish_state, kNoError);
|
||||
} else if (time_us_64() < g_restore_deadline_us) {
|
||||
schedule_lifecycle(g_restore_deadline_us);
|
||||
} else {
|
||||
++g_diagnostics.send_failures;
|
||||
finish(HapticsExperimentState::kError, kTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void start_stream() {
|
||||
cancel_timer(&g_cadence_timer, &g_cadence_armed);
|
||||
g_start_us = time_us_64();
|
||||
g_end_us = gameplay() ? UINT64_MAX : g_start_us + 6144000;
|
||||
g_diagnostics.start_us = static_cast<uint32_t>(g_start_us);
|
||||
g_diagnostics.first_tone_due_us =
|
||||
gameplay() ? 0 : static_cast<uint32_t>(packet_due(kPrimingPackets));
|
||||
g_diagnostics.state = HapticsExperimentState::kRunning;
|
||||
g_phase = Phase::kPattern;
|
||||
g_next_packet = 0;
|
||||
g_send_requested = false;
|
||||
g_last_was_silence = true;
|
||||
g_first_tone_sent = false;
|
||||
if (gameplay()) {
|
||||
g_synth.reset(g_start_us);
|
||||
g_synth_started = true;
|
||||
}
|
||||
schedule_lifecycle(gameplay() ? g_start_us + kDrainTimeoutUs : g_end_us);
|
||||
request_send();
|
||||
}
|
||||
|
||||
void start(const Command& command) {
|
||||
g_diagnostics = {};
|
||||
g_diagnostics.run_id = command.run_id;
|
||||
g_diagnostics.slot = command.slot;
|
||||
g_diagnostics.mode = command.action == 2 ? 1 : 0;
|
||||
g_synth_started = false;
|
||||
g_connection = command.connection;
|
||||
g_diagnostics.connection_generation = g_connection.generation;
|
||||
g_start_us = time_us_64();
|
||||
g_diagnostics.start_us = static_cast<uint32_t>(g_start_us);
|
||||
uni_hid_device_t* device = g_connection.device;
|
||||
const Attachment& attached = g_attachments[command.slot];
|
||||
const bool current = device != nullptr && attached.device == device &&
|
||||
attached.generation == g_connection.generation &&
|
||||
attached.cid == g_connection.cid;
|
||||
haptics_transport_probe_begin(
|
||||
command.run_id, g_connection.generation,
|
||||
current ? device->conn.handle : 0xffff);
|
||||
if (!current) {
|
||||
finish(HapticsExperimentState::kDisconnected, kConnection);
|
||||
return;
|
||||
}
|
||||
if (device->vendor_id != 0x054c ||
|
||||
(device->product_id != 0x0ce6 && device->product_id != 0x0df2) ||
|
||||
gap_get_connection_type(device->conn.handle) != GAP_CONNECTION_ACL ||
|
||||
device->report_parser.play_dual_rumble == nullptr) {
|
||||
finish(HapticsExperimentState::kUnsupported, kUnsupported);
|
||||
return;
|
||||
}
|
||||
if (g_connection.cid == 0 || !device->conn.connected ||
|
||||
device->conn.interrupt_cid != g_connection.cid) {
|
||||
finish(HapticsExperimentState::kDisconnected, kConnection);
|
||||
return;
|
||||
}
|
||||
if (l2cap_get_remote_mtu_for_local_cid(g_connection.cid) < kReportBytes) {
|
||||
finish(HapticsExperimentState::kUnsupported, kMtu);
|
||||
return;
|
||||
}
|
||||
// Never discard unrelated LED/control reports or allow them to switch the
|
||||
// controller back to compatibility midstream. A queued start is retryable
|
||||
// once the ordinary sender has drained it.
|
||||
if (!gameplay() && !uni_circular_buffer_is_empty(&device->outgoing_buffer)) {
|
||||
finish(HapticsExperimentState::kError, kQueuedOutput);
|
||||
return;
|
||||
}
|
||||
// Cancel any existing parser duration/delayed-start timer before taking
|
||||
// over. In the already-disabled case this deliberately emits no report.
|
||||
device->report_parser.play_dual_rumble(device, 0, 0, 0, 0);
|
||||
btstack_run_loop_set_timer_handler(&g_cadence_timer, cadence_timer);
|
||||
btstack_run_loop_set_timer_handler(&g_lifecycle_timer, lifecycle_timer);
|
||||
if (!uni_circular_buffer_is_empty(&device->outgoing_buffer)) {
|
||||
if (gameplay()) {
|
||||
// Let connection setup/LED reports drain before taking over.
|
||||
g_phase = Phase::kPrepare;
|
||||
g_diagnostics.state = HapticsExperimentState::kPending;
|
||||
schedule_lifecycle(time_us_64() + kDrainTimeoutUs);
|
||||
schedule_timer(&g_cadence_timer, &g_cadence_armed, time_us_64() + 2000);
|
||||
publish();
|
||||
} else {
|
||||
finish(HapticsExperimentState::kError, kQueuedOutput);
|
||||
}
|
||||
return;
|
||||
}
|
||||
start_stream();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void haptics_experiment_prepare() {
|
||||
if (!g_prepared) {
|
||||
critical_section_init(&g_lock);
|
||||
haptics_transport_probe_prepare();
|
||||
g_prepared = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool haptics_experiment_request(uint8_t action, uint8_t slot) {
|
||||
if (action > 2 || slot >= 4) {
|
||||
return false;
|
||||
}
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
bool accepted = true;
|
||||
if (action != 0) {
|
||||
if (g_busy) {
|
||||
accepted = false;
|
||||
} else {
|
||||
const uint32_t run_id = g_snapshot.run_id + 1;
|
||||
g_snapshot = {};
|
||||
g_snapshot.run_id = run_id;
|
||||
g_snapshot.slot = slot;
|
||||
g_snapshot.connection_generation = g_attachments[slot].generation;
|
||||
g_snapshot.state = HapticsExperimentState::kPending;
|
||||
g_snapshot.mode = action == 2 ? 1 : 0;
|
||||
g_accept_host = action == 2;
|
||||
g_host_head = 0;
|
||||
g_host_count = 0;
|
||||
g_host_updates = 0;
|
||||
g_host_drops = 0;
|
||||
g_snapshot_waiting = false;
|
||||
g_busy = true;
|
||||
g_command = {true, action, slot, run_id, g_attachments[slot], g_snapshot.mode};
|
||||
}
|
||||
} else if (g_busy) {
|
||||
if (slot != g_snapshot.slot) {
|
||||
accepted = false;
|
||||
} else {
|
||||
g_accept_host = false;
|
||||
// Replaces even an unconsumed start, without a FIFO of commands.
|
||||
g_command = {
|
||||
true, action, slot, g_snapshot.run_id,
|
||||
{nullptr, g_snapshot.connection_generation, 0}, g_snapshot.mode};
|
||||
}
|
||||
}
|
||||
critical_section_exit(&g_lock);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
bool haptics_experiment_submit(uint8_t slot, uint32_t generation,
|
||||
uint64_t received_us,
|
||||
const SwitchHapticsFrame& frame) {
|
||||
if (frame.actuators[0].sample_count > 3 || frame.actuators[1].sample_count > 3 ||
|
||||
(frame.actuators[0].sample_count == 0 && frame.actuators[1].sample_count == 0)) {
|
||||
return false;
|
||||
}
|
||||
return submit_host_update(slot, generation, {received_us, frame});
|
||||
}
|
||||
|
||||
bool haptics_experiment_submit_rumble(uint8_t slot, uint32_t generation,
|
||||
uint64_t received_us,
|
||||
uint8_t low, uint8_t high) {
|
||||
return submit_host_update(slot, generation, {received_us, {}, true, low, high});
|
||||
}
|
||||
|
||||
void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output) {
|
||||
if (output == nullptr) {
|
||||
return;
|
||||
}
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
*output = g_snapshot;
|
||||
const bool waiting = g_snapshot_waiting;
|
||||
const uint32_t requested_us = g_snapshot_request_us;
|
||||
critical_section_exit(&g_lock);
|
||||
output->packet_frames = output->mode == 1 ? kGameplayFrames : 64;
|
||||
if (output->state == HapticsExperimentState::kRunning) {
|
||||
const uint32_t now_us = static_cast<uint32_t>(time_us_64());
|
||||
output->elapsed_us = now_us - output->start_us;
|
||||
if (waiting) {
|
||||
update_max(&output->max_request_wait_us, now_us - requested_us);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void haptics_experiment_attach(uint8_t slot, uint32_t generation,
|
||||
uni_hid_device_t* device) {
|
||||
if (slot >= 4 || device == nullptr) {
|
||||
return;
|
||||
}
|
||||
const uint16_t cid = device->conn.interrupt_cid;
|
||||
if (g_phase != Phase::kIdle &&
|
||||
(g_diagnostics.slot == slot || g_connection.device == device) &&
|
||||
(g_diagnostics.slot != slot || g_connection.device != device ||
|
||||
g_connection.generation != generation || g_connection.cid != cid)) {
|
||||
finish(HapticsExperimentState::kDisconnected, kConnection);
|
||||
}
|
||||
// A reused instance must not remain selectable through an old slot.
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
for (Attachment& attached : g_attachments) {
|
||||
if (attached.device == device) {
|
||||
attached = {};
|
||||
}
|
||||
}
|
||||
g_attachments[slot] = {device, generation, cid};
|
||||
critical_section_exit(&g_lock);
|
||||
}
|
||||
|
||||
void haptics_experiment_detach(uni_hid_device_t* device) {
|
||||
if (device == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (g_phase != Phase::kIdle && g_connection.device == device) {
|
||||
// Do not dereference the device or send restoration on a dead link.
|
||||
finish(HapticsExperimentState::kDisconnected, kConnection);
|
||||
}
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
for (Attachment& attached : g_attachments) {
|
||||
if (attached.device == device) {
|
||||
attached = {};
|
||||
}
|
||||
}
|
||||
critical_section_exit(&g_lock);
|
||||
}
|
||||
|
||||
void haptics_experiment_poll() {
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
const Command command = g_command;
|
||||
g_command.pending = false;
|
||||
critical_section_exit(&g_lock);
|
||||
if (!command.pending) {
|
||||
return;
|
||||
}
|
||||
if (command.action != 0) {
|
||||
start(command);
|
||||
} else if (g_phase != Phase::kIdle &&
|
||||
g_diagnostics.run_id == command.run_id) {
|
||||
if (g_phase == Phase::kPrepare) {
|
||||
finish(HapticsExperimentState::kStopped, kNoError);
|
||||
} else if (g_phase == Phase::kRestore) {
|
||||
if (g_finish_state != HapticsExperimentState::kError) {
|
||||
g_finish_state = HapticsExperimentState::kStopped;
|
||||
}
|
||||
} else if (g_phase != Phase::kDrain ||
|
||||
g_finish_state != HapticsExperimentState::kStopped) {
|
||||
begin_drain(HapticsExperimentState::kStopped,
|
||||
time_us_64() + kDrainTimeoutUs);
|
||||
}
|
||||
} else {
|
||||
// Stop preempted a start still in the mailbox, or raced completion.
|
||||
if (g_diagnostics.run_id != command.run_id) {
|
||||
g_diagnostics = {};
|
||||
g_diagnostics.run_id = command.run_id;
|
||||
g_diagnostics.slot = command.slot;
|
||||
g_diagnostics.connection_generation = command.connection.generation;
|
||||
g_diagnostics.state = HapticsExperimentState::kStopped;
|
||||
g_diagnostics.mode = command.mode;
|
||||
haptics_transport_probe_begin(
|
||||
command.run_id, command.connection.generation, 0xffff);
|
||||
haptics_transport_probe_end();
|
||||
}
|
||||
publish(true);
|
||||
}
|
||||
}
|
||||
|
||||
bool haptics_experiment_owns(const uni_hid_device_t* device) {
|
||||
return device != nullptr && device == g_connection.device &&
|
||||
connection_current();
|
||||
}
|
||||
|
||||
bool haptics_experiment_gameplay_owns(const uni_hid_device_t* device) {
|
||||
return gameplay() && g_phase == Phase::kPattern &&
|
||||
haptics_experiment_owns(device);
|
||||
}
|
||||
|
||||
bool haptics_experiment_feedback(uni_hid_device_t* device,
|
||||
uint8_t low, uint8_t high, uint16_t duration_ms) {
|
||||
if (!haptics_experiment_gameplay_owns(device)) return false;
|
||||
drain_host_updates();
|
||||
g_synth.feedback(time_us_64(), uint32_t{duration_ms} * 1000, low, high);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device,
|
||||
uint16_t cid) {
|
||||
if (device != g_connection.device || !connection_current() ||
|
||||
(g_phase != Phase::kPattern && g_phase != Phase::kDrain)) {
|
||||
return false;
|
||||
}
|
||||
if (gameplay() && g_phase == Phase::kPattern && !g_in_callback &&
|
||||
!uni_circular_buffer_is_empty(&device->outgoing_buffer)) {
|
||||
// Gameplay permits ordinary LED reports, never compatibility rumble.
|
||||
// Yield this credit to the generic FIFO, then request fresh permission.
|
||||
if (cid == g_connection.cid && g_send_requested) {
|
||||
account_wait(time_us_64());
|
||||
g_send_requested = false;
|
||||
schedule_timer(&g_cadence_timer, &g_cadence_armed, time_us_64() + 1000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// The generic FIFO is device-wide, not CID-specific. Consume control-CID
|
||||
// and unsolicited events too, without treating them as PCM permission.
|
||||
if (cid != g_connection.cid || !g_send_requested || g_in_callback) {
|
||||
return true;
|
||||
}
|
||||
g_send_requested = false;
|
||||
g_in_callback = true;
|
||||
if (g_request_in_progress) {
|
||||
++g_diagnostics.synchronous_callbacks;
|
||||
}
|
||||
const uint64_t now_us = time_us_64();
|
||||
account_wait(now_us);
|
||||
haptics_transport_probe_permission(
|
||||
static_cast<uint32_t>(now_us - g_request_us));
|
||||
if (g_phase == Phase::kPattern && now_us >= g_end_us) {
|
||||
g_diagnostics.skipped_packets += kPackets - g_next_packet;
|
||||
g_next_packet = kPackets;
|
||||
g_phase = Phase::kDrain;
|
||||
g_finish_state = HapticsExperimentState::kCompleted;
|
||||
schedule_lifecycle(g_end_us + kDrainTimeoutUs);
|
||||
}
|
||||
if (g_phase == Phase::kDrain && now_us >= g_lifecycle_due_us) {
|
||||
timeout_drain();
|
||||
g_in_callback = false;
|
||||
return true;
|
||||
}
|
||||
if (g_phase == Phase::kPattern && now_us < packet_due(g_next_packet)) {
|
||||
// Notifications are not reservations of credit for a future deadline.
|
||||
schedule_timer(&g_cadence_timer, &g_cadence_armed,
|
||||
packet_due(g_next_packet));
|
||||
g_in_callback = false;
|
||||
publish();
|
||||
return true;
|
||||
}
|
||||
const bool stopping = g_phase == Phase::kDrain;
|
||||
uint64_t due_us = now_us;
|
||||
if (!stopping) {
|
||||
const uint32_t current = static_cast<uint32_t>(
|
||||
((now_us - g_start_us) * kPacketDenominator) / packet_numerator_us());
|
||||
g_diagnostics.skipped_packets += current - g_next_packet;
|
||||
g_next_packet = current;
|
||||
due_us = packet_due(current);
|
||||
}
|
||||
uint8_t report[kReportBytes];
|
||||
const uint64_t generate_start_us = time_us_64();
|
||||
const bool tone = generate_packet(report, g_next_packet, stopping);
|
||||
const uint64_t submit_us = time_us_64();
|
||||
update_max(&g_diagnostics.max_generate_us,
|
||||
static_cast<uint32_t>(submit_us - generate_start_us));
|
||||
++g_diagnostics.generated_packets;
|
||||
update_max(&g_diagnostics.max_lateness_us,
|
||||
static_cast<uint32_t>(submit_us - due_us));
|
||||
if (!stopping && submit_us >= packet_due(g_next_packet + 1)) {
|
||||
// A flash/interrupt stall can occur during synthesis as well as before
|
||||
// CAN_SEND_NOW. Never submit a now-obsolete tone after its phase ended.
|
||||
++g_diagnostics.skipped_packets;
|
||||
++g_next_packet;
|
||||
if (gameplay() || g_next_packet < kPackets) {
|
||||
schedule_timer(&g_cadence_timer, &g_cadence_armed,
|
||||
packet_due(g_next_packet));
|
||||
}
|
||||
g_in_callback = false;
|
||||
publish();
|
||||
return true;
|
||||
}
|
||||
if (stopping && submit_us >= g_lifecycle_due_us) {
|
||||
timeout_drain();
|
||||
g_in_callback = false;
|
||||
return true;
|
||||
}
|
||||
const uint64_t send_started_us = time_us_64();
|
||||
const uint8_t status = l2cap_send(cid, report, sizeof(report));
|
||||
const uint64_t send_returned_us = time_us_64();
|
||||
haptics_transport_probe_send(
|
||||
static_cast<uint32_t>(send_returned_us - send_started_us),
|
||||
static_cast<uint32_t>(send_returned_us),
|
||||
tone && !g_first_tone_sent && status == ERROR_CODE_SUCCESS);
|
||||
if (!connection_current()) {
|
||||
// A transport may synchronously report teardown. Detach already
|
||||
// published the terminal state; do not rearm a timer on its old CID.
|
||||
g_in_callback = false;
|
||||
return true;
|
||||
}
|
||||
if (status == ERROR_CODE_SUCCESS) {
|
||||
if (g_diagnostics.sent_packets != 0) {
|
||||
update_max(&g_diagnostics.max_send_gap_us,
|
||||
static_cast<uint32_t>(submit_us) - g_diagnostics.last_sent_us);
|
||||
}
|
||||
++g_diagnostics.sent_packets;
|
||||
g_diagnostics.last_sent_us = static_cast<uint32_t>(submit_us);
|
||||
g_last_was_silence = !tone;
|
||||
g_diagnostics.last_packet_nonzero = tone;
|
||||
if (tone && !g_first_tone_sent) {
|
||||
g_first_tone_sent = true;
|
||||
g_diagnostics.first_tone_sent_us = static_cast<uint32_t>(submit_us);
|
||||
}
|
||||
} else {
|
||||
++g_diagnostics.send_failures;
|
||||
g_diagnostics.last_error = kTransport;
|
||||
}
|
||||
if (stopping) {
|
||||
if (status == ERROR_CODE_SUCCESS) {
|
||||
restore_compatibility(g_finish_state);
|
||||
} else {
|
||||
schedule_timer(&g_cadence_timer, &g_cadence_armed,
|
||||
time_us_64() + (packet_numerator_us() + 2) / 3);
|
||||
}
|
||||
} else {
|
||||
++g_next_packet;
|
||||
if (gameplay() || g_next_packet < kPackets) {
|
||||
schedule_timer(&g_cadence_timer, &g_cadence_armed,
|
||||
packet_due(g_next_packet));
|
||||
}
|
||||
if (gameplay()) {
|
||||
schedule_lifecycle(packet_due(g_next_packet) + kDrainTimeoutUs);
|
||||
}
|
||||
}
|
||||
g_in_callback = false;
|
||||
publish();
|
||||
return true;
|
||||
}
|
||||
71
src/firmware/input/haptics_experiment.h
Normal file
71
src/firmware/input/haptics_experiment.h
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
struct uni_hid_device_s;
|
||||
typedef struct uni_hid_device_s uni_hid_device_t;
|
||||
struct SwitchHapticsFrame;
|
||||
|
||||
enum class HapticsExperimentState : uint8_t {
|
||||
kIdle = 0,
|
||||
kPending = 1,
|
||||
kRunning = 2,
|
||||
kCompleted = 3,
|
||||
kStopped = 4,
|
||||
kDisconnected = 5,
|
||||
kUnsupported = 6,
|
||||
kError = 7,
|
||||
};
|
||||
|
||||
struct HapticsExperimentDiagnostics {
|
||||
uint32_t run_id = 0;
|
||||
uint32_t connection_generation = 0;
|
||||
uint32_t start_us = 0;
|
||||
uint32_t generated_packets = 0;
|
||||
uint32_t sent_packets = 0;
|
||||
uint32_t skipped_packets = 0;
|
||||
uint32_t send_failures = 0;
|
||||
uint32_t can_send_requests = 0;
|
||||
uint32_t synchronous_callbacks = 0;
|
||||
uint32_t max_generate_us = 0;
|
||||
uint32_t max_send_gap_us = 0;
|
||||
uint32_t max_lateness_us = 0;
|
||||
uint32_t max_request_wait_us = 0;
|
||||
uint32_t first_tone_due_us = 0;
|
||||
uint32_t first_tone_sent_us = 0;
|
||||
uint32_t last_sent_us = 0;
|
||||
uint32_t elapsed_us = 0;
|
||||
HapticsExperimentState state = HapticsExperimentState::kIdle;
|
||||
uint8_t slot = 0xff;
|
||||
uint8_t last_error = 0;
|
||||
uint8_t mode = 0;
|
||||
uint32_t host_updates = 0;
|
||||
uint32_t dropped_updates = 0;
|
||||
uint8_t packet_frames = 64;
|
||||
bool last_packet_nonzero = false;
|
||||
};
|
||||
|
||||
// Core 0 before launching BTstack; request/snapshot are cross-core safe.
|
||||
void haptics_experiment_prepare();
|
||||
bool haptics_experiment_request(uint8_t action, uint8_t slot);
|
||||
void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output);
|
||||
// Core 0 USB delivery. Rejects non-selected/stale connections; never buffers PCM.
|
||||
bool haptics_experiment_submit(uint8_t slot, uint32_t generation,
|
||||
uint64_t received_us,
|
||||
const SwitchHapticsFrame& frame);
|
||||
// Stateful, already profile-scaled XInput strengths, including explicit zero.
|
||||
bool haptics_experiment_submit_rumble(uint8_t slot, uint32_t generation,
|
||||
uint64_t received_us,
|
||||
uint8_t low, uint8_t high);
|
||||
|
||||
// Core 1 / BTstack only. Poll consumes management requests, not PCM cadence.
|
||||
void haptics_experiment_attach(uint8_t slot, uint32_t generation,
|
||||
uni_hid_device_t* device);
|
||||
void haptics_experiment_detach(uni_hid_device_t* device);
|
||||
void haptics_experiment_poll();
|
||||
bool haptics_experiment_owns(const uni_hid_device_t* device);
|
||||
bool haptics_experiment_gameplay_owns(const uni_hid_device_t* device);
|
||||
bool haptics_experiment_feedback(uni_hid_device_t* device,
|
||||
uint8_t low, uint8_t high, uint16_t duration_ms);
|
||||
bool haptics_experiment_on_can_send_now(uni_hid_device_t* device,
|
||||
uint16_t cid);
|
||||
348
src/firmware/input/haptics_transport_probe.cpp
Normal file
348
src/firmware/input/haptics_transport_probe.cpp
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
#include "input/haptics_transport_probe.h"
|
||||
|
||||
#include <btstack.h>
|
||||
#include <pico/critical_section.h>
|
||||
#include <pico/stdlib.h>
|
||||
#if PICO_ON_DEVICE
|
||||
#include "platform/pico/system_clock.h"
|
||||
#endif
|
||||
|
||||
extern "C" {
|
||||
#include <cyw43.h>
|
||||
|
||||
// Derive the declarations from the SDK: in particular write uses size_t,
|
||||
// whereas read's capacity and output length are uint32_t even on a 64-bit host.
|
||||
decltype(cyw43_bluetooth_hci_write) __real_cyw43_bluetooth_hci_write;
|
||||
decltype(cyw43_bluetooth_hci_write) __wrap_cyw43_bluetooth_hci_write;
|
||||
decltype(cyw43_bluetooth_hci_read) __real_cyw43_bluetooth_hci_read;
|
||||
decltype(cyw43_bluetooth_hci_read) __wrap_cyw43_bluetooth_hci_read;
|
||||
decltype(btstack_run_loop_base_poll_data_sources)
|
||||
__real_btstack_run_loop_base_poll_data_sources;
|
||||
decltype(btstack_run_loop_base_poll_data_sources)
|
||||
__wrap_btstack_run_loop_base_poll_data_sources;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
critical_section_t g_lock;
|
||||
HapticsTransportProbe g_probe;
|
||||
bool g_prepared = false;
|
||||
|
||||
// Only the snapshot crosses cores. All other state, including wrapper entry,
|
||||
// belongs to core 1; the lock never spans SDK calls or event delivery.
|
||||
uint64_t g_epoch = 0;
|
||||
bool g_registered = false;
|
||||
bool g_credit_sampled = false;
|
||||
bool g_tone_recorded = false;
|
||||
bool g_have_completion = false;
|
||||
bool g_have_poll = false;
|
||||
uint32_t g_last_completion_us = 0;
|
||||
uint32_t g_last_poll_us = 0;
|
||||
bool g_in_write = false;
|
||||
bool g_in_read = false;
|
||||
bool g_in_poll = false;
|
||||
bool g_receive_work = false;
|
||||
uint16_t g_controller_acl_bytes = 0;
|
||||
uint16_t g_controller_acl_count = 0;
|
||||
btstack_packet_callback_registration_t g_registration{};
|
||||
|
||||
uint32_t now_us() {
|
||||
return static_cast<uint32_t>(time_us_64());
|
||||
}
|
||||
|
||||
void add(uint32_t& total, uint32_t value) {
|
||||
total = value > UINT32_MAX - total ? UINT32_MAX : total + value;
|
||||
}
|
||||
|
||||
void maximum(uint32_t& peak, uint32_t value) {
|
||||
if (value > peak) peak = value;
|
||||
}
|
||||
|
||||
void duration(uint32_t& calls, uint32_t& peak, uint32_t& total,
|
||||
uint32_t elapsed) {
|
||||
add(calls, 1);
|
||||
maximum(peak, elapsed);
|
||||
add(total, elapsed);
|
||||
}
|
||||
|
||||
bool current(uint64_t epoch) {
|
||||
return g_probe.active != 0 && g_epoch == epoch;
|
||||
}
|
||||
|
||||
void sample_credits(uint64_t epoch) {
|
||||
if (!current(epoch) || g_probe.connection_handle > 0x0fff) return;
|
||||
const auto handle = static_cast<hci_con_handle_t>(g_probe.connection_handle);
|
||||
// SDK lookup is fresh each time: never retain a connection across I/O or
|
||||
// callbacks, which can synchronously disconnect and free it.
|
||||
const hci_connection_t* connection = hci_connection_for_handle(handle);
|
||||
if (connection == nullptr) return;
|
||||
const uint32_t outstanding = connection->num_packets_sent;
|
||||
const int free_slots = hci_number_free_acl_slots_for_handle(handle);
|
||||
if (!current(epoch) || free_slots < 0) return;
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
maximum(g_probe.max_outstanding_acl, outstanding);
|
||||
const auto available = static_cast<uint32_t>(free_slots);
|
||||
if (!g_credit_sampled || available < g_probe.min_free_acl) {
|
||||
g_probe.min_free_acl = available;
|
||||
}
|
||||
g_credit_sampled = true;
|
||||
critical_section_exit(&g_lock);
|
||||
}
|
||||
|
||||
// Suppress only same-boundary recursive measurement, not its real call. Poll,
|
||||
// read, write and sender totals are inclusive and deliberately overlap. Epoch
|
||||
// checks discard a call that spans finish or a new begin, even with reused IDs.
|
||||
struct Call {
|
||||
bool& entered;
|
||||
const bool outer;
|
||||
const uint64_t epoch;
|
||||
const bool measured;
|
||||
uint32_t start_us = 0;
|
||||
|
||||
explicit Call(bool& guard)
|
||||
: entered(guard), outer(!guard), epoch(g_epoch),
|
||||
measured(outer && g_probe.active != 0) {
|
||||
entered = true;
|
||||
if (measured) {
|
||||
sample_credits(epoch);
|
||||
start_us = now_us();
|
||||
}
|
||||
}
|
||||
|
||||
~Call() {
|
||||
if (outer) entered = false;
|
||||
}
|
||||
|
||||
bool live() const {
|
||||
return measured && current(epoch);
|
||||
}
|
||||
};
|
||||
|
||||
uint16_t read_le16(const uint8_t* bytes) {
|
||||
return static_cast<uint16_t>(bytes[0] | (uint16_t{bytes[1]} << 8));
|
||||
}
|
||||
void capture_controller_capacity(const uint8_t* buffer, uint32_t length) {
|
||||
// CYW43's four-byte header precedes HCI Command Complete (Read Buffer Size).
|
||||
// Capture the raw response before BTstack applies its software buffer cap.
|
||||
if (length < 17 || buffer[3] != HCI_EVENT_PACKET || buffer[4] != 0x0e ||
|
||||
buffer[5] != 11 || buffer[7] != 0x05 || buffer[8] != 0x10 ||
|
||||
buffer[9] != 0) {
|
||||
return;
|
||||
}
|
||||
g_controller_acl_bytes = read_le16(buffer + 10);
|
||||
g_controller_acl_count = read_le16(buffer + 13);
|
||||
if (g_prepared) {
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
g_probe.controller_acl_packet_bytes = g_controller_acl_bytes;
|
||||
g_probe.controller_acl_packet_count = g_controller_acl_count;
|
||||
critical_section_exit(&g_lock);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void handle_event(uint8_t packet_type, uint16_t, uint8_t* packet,
|
||||
uint16_t size) {
|
||||
if (!g_probe.active || packet_type != HCI_EVENT_PACKET || packet == nullptr ||
|
||||
size < 3 || packet[0] != HCI_EVENT_NUMBER_OF_COMPLETED_PACKETS ||
|
||||
size != uint16_t{packet[1]} + 2u ||
|
||||
size != 3u + uint16_t{packet[2]} * 4u ||
|
||||
g_probe.connection_handle > 0x0fff) {
|
||||
return;
|
||||
}
|
||||
uint32_t completed = 0;
|
||||
bool selected = false;
|
||||
for (uint16_t offset = 3; offset < size; offset += 4) {
|
||||
if ((read_le16(packet + offset) & 0x0fff) == g_probe.connection_handle) {
|
||||
selected = true;
|
||||
completed += read_le16(packet + offset + 2);
|
||||
}
|
||||
}
|
||||
if (!selected) return;
|
||||
const uint64_t epoch = g_epoch;
|
||||
const uint32_t timestamp = now_us();
|
||||
sample_credits(epoch);
|
||||
if (!current(epoch)) return;
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
add(g_probe.completion_events, 1);
|
||||
add(g_probe.completed_packets, completed);
|
||||
// Gap between selected-handle events, not time from run start to first.
|
||||
if (g_have_completion) {
|
||||
maximum(g_probe.max_completion_gap_us, timestamp - g_last_completion_us);
|
||||
}
|
||||
g_have_completion = true;
|
||||
g_last_completion_us = timestamp;
|
||||
critical_section_exit(&g_lock);
|
||||
// BTstack updates credit accounting before delivering this event. These
|
||||
// samples and the surrounding I/O samples are observed extrema only.
|
||||
sample_credits(epoch);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void haptics_transport_probe_prepare() {
|
||||
if (!g_prepared) {
|
||||
critical_section_init(&g_lock);
|
||||
g_prepared = true;
|
||||
}
|
||||
}
|
||||
|
||||
void haptics_transport_probe_snapshot(HapticsTransportProbe* output) {
|
||||
if (output == nullptr) return;
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
*output = g_probe;
|
||||
critical_section_exit(&g_lock);
|
||||
#if PICO_ON_DEVICE
|
||||
const SystemClockStatus clock = system_clock_status();
|
||||
output->requested_sys_khz = clock.requested_sys_khz;
|
||||
output->measured_sys_khz = clock.measured_sys_khz;
|
||||
output->measured_usb_khz = clock.measured_usb_khz;
|
||||
output->core_voltage_mv = clock.core_voltage_mv;
|
||||
output->flash_clock_divider = clock.flash_clock_divider;
|
||||
output->cyw43_pio_divider256 = clock.cyw43_pio_divider256;
|
||||
output->temperature_millicelsius = clock.temperature_millicelsius;
|
||||
#endif
|
||||
#ifdef SWITCH_PICO_CYW43_PACKET_READ
|
||||
output->packet_read_optimized = 1;
|
||||
#endif
|
||||
}
|
||||
|
||||
void haptics_transport_probe_begin(uint32_t run_id, uint32_t generation,
|
||||
uint16_t handle) {
|
||||
++g_epoch;
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
g_probe = {};
|
||||
g_probe.run_id = run_id;
|
||||
g_probe.connection_generation = generation;
|
||||
g_probe.connection_handle = handle;
|
||||
g_probe.controller_acl_packet_bytes = g_controller_acl_bytes;
|
||||
g_probe.controller_acl_packet_count = g_controller_acl_count;
|
||||
g_probe.active = 1;
|
||||
g_credit_sampled = false;
|
||||
g_tone_recorded = false;
|
||||
g_have_completion = false;
|
||||
g_have_poll = false;
|
||||
critical_section_exit(&g_lock);
|
||||
if (!g_registered) {
|
||||
g_registered = true;
|
||||
g_registration.callback = handle_event;
|
||||
hci_add_event_handler(&g_registration);
|
||||
}
|
||||
sample_credits(g_epoch);
|
||||
}
|
||||
|
||||
void haptics_transport_probe_end() {
|
||||
sample_credits(g_epoch);
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
g_probe.active = 0;
|
||||
critical_section_exit(&g_lock);
|
||||
}
|
||||
|
||||
void haptics_transport_probe_timer(uint32_t lateness_us) {
|
||||
if (!g_probe.active) return;
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
duration(g_probe.timer_wakes, g_probe.max_timer_lateness_us,
|
||||
g_probe.total_timer_lateness_us, lateness_us);
|
||||
critical_section_exit(&g_lock);
|
||||
}
|
||||
|
||||
void haptics_transport_probe_permission(uint32_t wait_us) {
|
||||
if (!g_probe.active) return;
|
||||
sample_credits(g_epoch);
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
duration(g_probe.permission_callbacks, g_probe.max_permission_wait_us,
|
||||
g_probe.total_permission_wait_us, wait_us);
|
||||
critical_section_exit(&g_lock);
|
||||
}
|
||||
|
||||
void haptics_transport_probe_send(uint32_t duration_us, uint32_t return_us,
|
||||
bool first_tone_success) {
|
||||
if (!g_probe.active) return;
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
duration(g_probe.send_calls, g_probe.max_send_us, g_probe.total_send_us,
|
||||
duration_us);
|
||||
if (first_tone_success && !g_tone_recorded) {
|
||||
g_probe.first_tone_send_return_us = return_us;
|
||||
g_tone_recorded = true;
|
||||
}
|
||||
critical_section_exit(&g_lock);
|
||||
sample_credits(g_epoch);
|
||||
}
|
||||
|
||||
extern "C" int __wrap_cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length) {
|
||||
Call call(g_in_write);
|
||||
const bool acl = buffer != nullptr && length >= 4 && buffer[3] == 2;
|
||||
const bool completed = buffer != nullptr && length >= 6 &&
|
||||
buffer[3] == 1 && buffer[4] == 0x35 && buffer[5] == 0x0c;
|
||||
const int result = __real_cyw43_bluetooth_hci_write(buffer, length);
|
||||
if (call.live()) {
|
||||
const uint32_t elapsed = now_us() - call.start_us;
|
||||
sample_credits(call.epoch);
|
||||
if (call.live()) {
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
duration(g_probe.write_calls, g_probe.max_write_us,
|
||||
g_probe.total_write_us, elapsed);
|
||||
add(g_probe.host_completed_writes, completed ? 1 : 0);
|
||||
add(g_probe.acl_writes, acl ? 1 : 0);
|
||||
add(g_probe.other_writes, !acl && !completed ? 1 : 0);
|
||||
add(g_probe.write_failures, result != 0 ? 1 : 0);
|
||||
critical_section_exit(&g_lock);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
extern "C" int __wrap_cyw43_bluetooth_hci_read(uint8_t* buffer, uint32_t capacity,
|
||||
uint32_t* length) {
|
||||
Call call(g_in_read);
|
||||
const int result = __real_cyw43_bluetooth_hci_read(buffer, capacity, length);
|
||||
if (result == 0 && length != nullptr && *length <= capacity && buffer != nullptr) {
|
||||
capture_controller_capacity(buffer, *length);
|
||||
}
|
||||
// A nonempty read may have left more input queued. The experiment limits
|
||||
// each transport poll to one packet so timers can run between packets.
|
||||
g_receive_work = result == 0 && length != nullptr && *length > 0;
|
||||
if (call.live()) {
|
||||
const uint32_t elapsed = now_us() - call.start_us;
|
||||
// Match the transport's successful/nonempty read criterion. An error
|
||||
// may leave length untouched, so never inspect it on an error return.
|
||||
const bool packet = result == 0 && length != nullptr && *length > 0;
|
||||
sample_credits(call.epoch);
|
||||
if (call.live()) {
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
duration(g_probe.read_calls, g_probe.max_read_us,
|
||||
g_probe.total_read_us, elapsed);
|
||||
if (packet) add(g_probe.read_packets, 1);
|
||||
critical_section_exit(&g_lock);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
extern "C" void __wrap_btstack_run_loop_base_poll_data_sources() {
|
||||
Call call(g_in_poll);
|
||||
const bool have_previous = g_have_poll;
|
||||
const uint32_t gap = call.start_us - g_last_poll_us;
|
||||
if (call.measured) {
|
||||
g_have_poll = true;
|
||||
g_last_poll_us = call.start_us;
|
||||
}
|
||||
if (call.outer) g_receive_work = false;
|
||||
__real_btstack_run_loop_base_poll_data_sources();
|
||||
if (call.live()) {
|
||||
const uint32_t elapsed = now_us() - call.start_us;
|
||||
sample_credits(call.epoch);
|
||||
if (call.live()) {
|
||||
critical_section_enter_blocking(&g_lock);
|
||||
duration(g_probe.poll_calls, g_probe.max_poll_us,
|
||||
g_probe.total_poll_us, elapsed);
|
||||
if (have_previous) maximum(g_probe.max_poll_gap_us, gap);
|
||||
critical_section_exit(&g_lock);
|
||||
}
|
||||
}
|
||||
if (call.outer && g_receive_work) {
|
||||
// Mark future work; never recursively drain from the current callback.
|
||||
// This also runs while measurements are inactive so input cannot be
|
||||
// stranded waiting for a new hardware IRQ edge after a bounded poll.
|
||||
btstack_run_loop_poll_data_sources_from_irq();
|
||||
}
|
||||
}
|
||||
64
src/firmware/input/haptics_transport_probe.h
Normal file
64
src/firmware/input/haptics_transport_probe.h
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// Operation 0x41, schema 3: 44 little-endian 32-bit fields.
|
||||
// All durations are host-side microseconds, not radio/actuator latency.
|
||||
struct HapticsTransportProbe {
|
||||
uint32_t run_id = 0;
|
||||
uint32_t connection_generation = 0;
|
||||
uint32_t connection_handle = 0xffff;
|
||||
uint32_t timer_wakes = 0;
|
||||
uint32_t max_timer_lateness_us = 0;
|
||||
uint32_t total_timer_lateness_us = 0;
|
||||
uint32_t send_calls = 0;
|
||||
uint32_t max_send_us = 0;
|
||||
uint32_t total_send_us = 0;
|
||||
uint32_t write_calls = 0;
|
||||
uint32_t max_write_us = 0;
|
||||
uint32_t total_write_us = 0;
|
||||
uint32_t read_calls = 0;
|
||||
uint32_t read_packets = 0;
|
||||
uint32_t max_read_us = 0;
|
||||
uint32_t total_read_us = 0;
|
||||
uint32_t poll_calls = 0;
|
||||
uint32_t max_poll_us = 0;
|
||||
uint32_t total_poll_us = 0;
|
||||
uint32_t completion_events = 0;
|
||||
uint32_t completed_packets = 0;
|
||||
uint32_t max_completion_gap_us = 0;
|
||||
uint32_t max_outstanding_acl = 0;
|
||||
uint32_t min_free_acl = 0;
|
||||
uint32_t first_tone_send_return_us = 0;
|
||||
uint32_t active = 0;
|
||||
uint32_t max_permission_wait_us = 0;
|
||||
uint32_t total_permission_wait_us = 0;
|
||||
uint32_t permission_callbacks = 0;
|
||||
uint32_t max_poll_gap_us = 0;
|
||||
uint32_t controller_acl_packet_bytes = 0;
|
||||
uint32_t controller_acl_packet_count = 0;
|
||||
uint32_t requested_sys_khz = 0;
|
||||
uint32_t measured_sys_khz = 0;
|
||||
uint32_t measured_usb_khz = 0;
|
||||
uint32_t core_voltage_mv = 0;
|
||||
uint32_t flash_clock_divider = 0;
|
||||
uint32_t cyw43_pio_divider256 = 0;
|
||||
int32_t temperature_millicelsius = 0;
|
||||
uint32_t host_completed_writes = 0;
|
||||
uint32_t acl_writes = 0;
|
||||
uint32_t other_writes = 0;
|
||||
uint32_t write_failures = 0;
|
||||
uint32_t packet_read_optimized = 0;
|
||||
};
|
||||
|
||||
// Prepare before core 1 starts. Snapshot alone is called from USB/core 0.
|
||||
void haptics_transport_probe_prepare();
|
||||
void haptics_transport_probe_snapshot(HapticsTransportProbe* output);
|
||||
// Remaining API belongs to core 1. No probe lock may span a stack call.
|
||||
void haptics_transport_probe_begin(uint32_t run_id, uint32_t generation,
|
||||
uint16_t handle);
|
||||
void haptics_transport_probe_end();
|
||||
void haptics_transport_probe_timer(uint32_t lateness_us);
|
||||
void haptics_transport_probe_permission(uint32_t wait_us);
|
||||
void haptics_transport_probe_send(uint32_t duration_us,
|
||||
uint32_t return_us, bool first_tone_success);
|
||||
338
src/firmware/input/switch2_wake.cpp
Normal file
338
src/firmware/input/switch2_wake.cpp
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
#include "input/switch2_wake.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include <btstack.h>
|
||||
|
||||
#ifndef SWITCH2_WAKE_CONFIGURED
|
||||
#define SWITCH2_WAKE_CONFIGURED 0
|
||||
#endif
|
||||
#if SWITCH2_WAKE_CONFIGURED && \
|
||||
!defined(SWITCH2_WAKE_SOURCE_ADDRESS_BYTES)
|
||||
#include "platform/pico/switch2_wake_config.h"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t kRetryIntervalMs = 5;
|
||||
constexpr uint32_t kCommandTimeoutMs = 1000;
|
||||
constexpr uint32_t kBurstDurationMs = 2000;
|
||||
constexpr uint16_t kAdvertisingIntervalUnits = 0x0020; // 20 ms + BLE delay.
|
||||
constexpr uint8_t kAdvertisingTypeNonConnectable = 3;
|
||||
constexpr uint16_t kWritePublicAddressOpcode = 0xfc01;
|
||||
|
||||
#if SWITCH2_WAKE_CONFIGURED
|
||||
const uint8_t kWakeAddress[6] = SWITCH2_WAKE_SOURCE_ADDRESS_BYTES;
|
||||
uint8_t kWakeAdvertisement[31] = SWITCH2_WAKE_ADVERTISEMENT_DATA_BYTES;
|
||||
#endif
|
||||
|
||||
const hci_cmd_t kWritePublicAddress = {kWritePublicAddressOpcode, "B"};
|
||||
const bd_addr_t kUnusedPeerAddress{};
|
||||
|
||||
enum class Phase : uint8_t {
|
||||
kDisabled,
|
||||
kSetStableAddress,
|
||||
kVerifyStableAddress,
|
||||
kIdle,
|
||||
kSetParameters,
|
||||
kSetData,
|
||||
kEnableAdvertising,
|
||||
kAdvertising,
|
||||
kDisableAdvertising,
|
||||
kFailed,
|
||||
};
|
||||
|
||||
Phase g_phase = Phase::kDisabled;
|
||||
btstack_packet_callback_registration_t g_event_registration{};
|
||||
btstack_timer_source_t g_timer{};
|
||||
uint16_t g_pending_opcode = 0;
|
||||
uint32_t g_command_deadline_ms = 0;
|
||||
uint32_t g_burst_deadline_ms = 0;
|
||||
uint32_t g_accepted_requests = 0;
|
||||
uint32_t g_completed_bursts = 0;
|
||||
uint32_t g_failures = 0;
|
||||
bool g_initialized = false;
|
||||
bool g_configured = false;
|
||||
bool g_identity_ready = false;
|
||||
bool g_timer_armed = false;
|
||||
bool g_advertising = false;
|
||||
|
||||
bool deadline_reached(uint32_t now, uint32_t deadline) {
|
||||
return static_cast<int32_t>(now - deadline) >= 0;
|
||||
}
|
||||
|
||||
bool phase_needs_work() {
|
||||
return g_phase != Phase::kDisabled &&
|
||||
g_phase != Phase::kIdle &&
|
||||
g_phase != Phase::kFailed;
|
||||
}
|
||||
|
||||
void cancel_task() {
|
||||
if (g_timer_armed) {
|
||||
btstack_run_loop_remove_timer(&g_timer);
|
||||
g_timer_armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
void schedule_task(uint32_t delay_ms) {
|
||||
cancel_task();
|
||||
btstack_run_loop_set_timer(&g_timer, delay_ms);
|
||||
btstack_run_loop_add_timer(&g_timer);
|
||||
g_timer_armed = true;
|
||||
}
|
||||
|
||||
void schedule_for_phase(uint32_t now_ms) {
|
||||
if (g_pending_opcode != 0) {
|
||||
schedule_task(
|
||||
deadline_reached(now_ms, g_command_deadline_ms)
|
||||
? 0
|
||||
: g_command_deadline_ms - now_ms);
|
||||
} else if (g_phase == Phase::kAdvertising) {
|
||||
schedule_task(
|
||||
deadline_reached(now_ms, g_burst_deadline_ms)
|
||||
? 0
|
||||
: g_burst_deadline_ms - now_ms);
|
||||
} else if (phase_needs_work()) {
|
||||
schedule_task(kRetryIntervalMs);
|
||||
} else {
|
||||
cancel_task();
|
||||
}
|
||||
}
|
||||
|
||||
#if SWITCH2_WAKE_CONFIGURED
|
||||
bool configured_packet_valid() {
|
||||
static const uint8_t prefix[] = {
|
||||
0x02, 0x01, 0x06, 0x1b, 0xff, 0x53, 0x05,
|
||||
0x01, 0x00, 0x03, 0x7e, 0x05,
|
||||
};
|
||||
static const uint8_t suffix[] = {
|
||||
0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
uint8_t address_any = 0;
|
||||
uint8_t target_any = 0;
|
||||
for (uint8_t value : kWakeAddress) {
|
||||
address_any |= value;
|
||||
}
|
||||
for (size_t index = 17; index < 23; ++index) {
|
||||
target_any |= kWakeAdvertisement[index];
|
||||
}
|
||||
return address_any != 0 && target_any != 0 &&
|
||||
memcmp(kWakeAdvertisement, prefix, sizeof(prefix)) == 0 &&
|
||||
kWakeAdvertisement[14] == 0x00 &&
|
||||
kWakeAdvertisement[15] == 0x01 &&
|
||||
kWakeAdvertisement[16] == 0x81 &&
|
||||
memcmp(&kWakeAdvertisement[23], suffix, sizeof(suffix)) == 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
void recover_from_failure() {
|
||||
++g_failures;
|
||||
g_pending_opcode = 0;
|
||||
if (g_phase == Phase::kSetStableAddress ||
|
||||
g_phase == Phase::kVerifyStableAddress) {
|
||||
// Wake stays disabled, but controller input must remain available.
|
||||
g_identity_ready = true;
|
||||
g_phase = Phase::kFailed;
|
||||
} else if (g_advertising) {
|
||||
g_phase = Phase::kDisableAdvertising;
|
||||
} else {
|
||||
g_phase = Phase::kIdle;
|
||||
}
|
||||
}
|
||||
|
||||
void begin_command(uint16_t opcode, uint32_t now_ms) {
|
||||
g_pending_opcode = opcode;
|
||||
g_command_deadline_ms = now_ms + kCommandTimeoutMs;
|
||||
}
|
||||
|
||||
void submit_phase_command(uint32_t now_ms) {
|
||||
if (g_pending_opcode != 0 || !hci_can_send_command_packet_now()) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t result = ERROR_CODE_SUCCESS;
|
||||
switch (g_phase) {
|
||||
case Phase::kSetStableAddress:
|
||||
begin_command(kWritePublicAddress.opcode, now_ms);
|
||||
#if SWITCH2_WAKE_CONFIGURED
|
||||
result = hci_send_cmd(&kWritePublicAddress, kWakeAddress);
|
||||
#endif
|
||||
break;
|
||||
case Phase::kVerifyStableAddress:
|
||||
begin_command(hci_read_bd_addr.opcode, now_ms);
|
||||
result = hci_send_cmd(&hci_read_bd_addr);
|
||||
break;
|
||||
case Phase::kSetParameters:
|
||||
begin_command(hci_le_set_advertising_parameters.opcode, now_ms);
|
||||
result = hci_send_cmd(
|
||||
&hci_le_set_advertising_parameters,
|
||||
kAdvertisingIntervalUnits, kAdvertisingIntervalUnits,
|
||||
kAdvertisingTypeNonConnectable,
|
||||
BD_ADDR_TYPE_LE_PUBLIC, BD_ADDR_TYPE_LE_PUBLIC,
|
||||
kUnusedPeerAddress, 7, 0);
|
||||
break;
|
||||
case Phase::kSetData:
|
||||
begin_command(hci_le_set_advertising_data.opcode, now_ms);
|
||||
#if SWITCH2_WAKE_CONFIGURED
|
||||
result = hci_send_cmd(
|
||||
&hci_le_set_advertising_data,
|
||||
static_cast<uint8_t>(sizeof(kWakeAdvertisement)),
|
||||
kWakeAdvertisement);
|
||||
#endif
|
||||
break;
|
||||
case Phase::kEnableAdvertising:
|
||||
case Phase::kDisableAdvertising:
|
||||
begin_command(hci_le_set_advertise_enable.opcode, now_ms);
|
||||
result = hci_send_cmd(
|
||||
&hci_le_set_advertise_enable,
|
||||
g_phase == Phase::kEnableAdvertising ? 1 : 0);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (result != ERROR_CODE_SUCCESS) {
|
||||
recover_from_failure();
|
||||
}
|
||||
}
|
||||
|
||||
void handle_command_complete(uint8_t* packet, uint16_t size) {
|
||||
if (size < 6 || g_pending_opcode == 0) {
|
||||
return;
|
||||
}
|
||||
const uint16_t opcode =
|
||||
hci_event_command_complete_get_command_opcode(packet);
|
||||
if (opcode != g_pending_opcode) {
|
||||
return;
|
||||
}
|
||||
const uint8_t status =
|
||||
hci_event_command_complete_get_return_parameters(packet)[0];
|
||||
g_pending_opcode = 0;
|
||||
if (status != ERROR_CODE_SUCCESS) {
|
||||
recover_from_failure();
|
||||
schedule_for_phase(btstack_run_loop_get_time_ms());
|
||||
return;
|
||||
}
|
||||
|
||||
switch (g_phase) {
|
||||
case Phase::kSetStableAddress:
|
||||
g_phase = Phase::kVerifyStableAddress;
|
||||
break;
|
||||
case Phase::kVerifyStableAddress:
|
||||
#if SWITCH2_WAKE_CONFIGURED
|
||||
if (size < 12) {
|
||||
recover_from_failure();
|
||||
schedule_for_phase(btstack_run_loop_get_time_ms());
|
||||
return;
|
||||
}
|
||||
for (size_t index = 0; index < sizeof(kWakeAddress); ++index) {
|
||||
if (packet[6 + index] !=
|
||||
kWakeAddress[sizeof(kWakeAddress) - 1 - index]) {
|
||||
recover_from_failure();
|
||||
schedule_for_phase(btstack_run_loop_get_time_ms());
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
g_identity_ready = true;
|
||||
g_phase = Phase::kIdle;
|
||||
break;
|
||||
case Phase::kSetParameters:
|
||||
g_phase = Phase::kSetData;
|
||||
break;
|
||||
case Phase::kSetData:
|
||||
g_phase = Phase::kEnableAdvertising;
|
||||
break;
|
||||
case Phase::kEnableAdvertising:
|
||||
g_advertising = true;
|
||||
g_burst_deadline_ms =
|
||||
btstack_run_loop_get_time_ms() + kBurstDurationMs;
|
||||
g_phase = Phase::kAdvertising;
|
||||
break;
|
||||
case Phase::kDisableAdvertising:
|
||||
g_advertising = false;
|
||||
++g_completed_bursts;
|
||||
g_phase = Phase::kIdle;
|
||||
break;
|
||||
default:
|
||||
recover_from_failure();
|
||||
break;
|
||||
}
|
||||
schedule_for_phase(btstack_run_loop_get_time_ms());
|
||||
}
|
||||
|
||||
void handle_hci_event(uint8_t packet_type, uint16_t,
|
||||
uint8_t* packet, uint16_t size) {
|
||||
if (packet_type == HCI_EVENT_PACKET && size >= 2 &&
|
||||
hci_event_packet_get_type(packet) == HCI_EVENT_COMMAND_COMPLETE) {
|
||||
handle_command_complete(packet, size);
|
||||
}
|
||||
}
|
||||
|
||||
void task(btstack_timer_source_t*) {
|
||||
g_timer_armed = false;
|
||||
const uint32_t now_ms = btstack_run_loop_get_time_ms();
|
||||
if (g_pending_opcode != 0 &&
|
||||
deadline_reached(now_ms, g_command_deadline_ms)) {
|
||||
recover_from_failure();
|
||||
}
|
||||
if (g_phase == Phase::kAdvertising &&
|
||||
deadline_reached(now_ms, g_burst_deadline_ms)) {
|
||||
g_phase = Phase::kDisableAdvertising;
|
||||
}
|
||||
submit_phase_command(now_ms);
|
||||
schedule_for_phase(now_ms);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void switch2_wake_initialize() {
|
||||
if (g_initialized) {
|
||||
return;
|
||||
}
|
||||
g_initialized = true;
|
||||
#if SWITCH2_WAKE_CONFIGURED
|
||||
if (!configured_packet_valid()) {
|
||||
g_identity_ready = true;
|
||||
g_phase = Phase::kFailed;
|
||||
++g_failures;
|
||||
return;
|
||||
}
|
||||
g_configured = true;
|
||||
g_event_registration.callback = handle_hci_event;
|
||||
hci_add_event_handler(&g_event_registration);
|
||||
btstack_run_loop_set_timer_handler(&g_timer, task);
|
||||
g_phase = Phase::kSetStableAddress;
|
||||
schedule_task(0);
|
||||
#else
|
||||
g_identity_ready = true;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool switch2_wake_ready_for_connections() {
|
||||
return g_identity_ready;
|
||||
}
|
||||
|
||||
bool switch2_wake_request() {
|
||||
if (g_phase != Phase::kIdle) {
|
||||
return false;
|
||||
}
|
||||
++g_accepted_requests;
|
||||
g_phase = Phase::kSetParameters;
|
||||
schedule_task(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
void switch2_wake_diagnostics(Switch2WakeDiagnostics* out) {
|
||||
if (out == nullptr) {
|
||||
return;
|
||||
}
|
||||
*out = {
|
||||
g_configured,
|
||||
g_phase != Phase::kDisabled && g_phase != Phase::kIdle &&
|
||||
g_phase != Phase::kFailed,
|
||||
g_accepted_requests,
|
||||
g_completed_bursts,
|
||||
g_failures,
|
||||
};
|
||||
}
|
||||
21
src/firmware/input/switch2_wake.h
Normal file
21
src/firmware/input/switch2_wake.h
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
struct Switch2WakeDiagnostics {
|
||||
bool configured;
|
||||
bool busy;
|
||||
uint32_t accepted_requests;
|
||||
uint32_t completed_bursts;
|
||||
uint32_t failures;
|
||||
};
|
||||
|
||||
// Installs BTstack callbacks and applies the configured stable public identity.
|
||||
// Call once from the BTstack core before admitting controller connections.
|
||||
void switch2_wake_initialize();
|
||||
bool switch2_wake_ready_for_connections();
|
||||
|
||||
// Starts one wake burst when configured and idle. Calls while busy coalesce.
|
||||
bool switch2_wake_request();
|
||||
|
||||
void switch2_wake_diagnostics(Switch2WakeDiagnostics* out);
|
||||
27
src/firmware/input/switch_hd_rumble_envelope.h
Normal file
27
src/firmware/input/switch_hd_rumble_envelope.h
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// round(65535 * (i / 256)^0.8), i=0..256. Interpolate between entries
|
||||
// once per envelope segment, never run pow() in the PCM loop. Zero stays zero.
|
||||
namespace SwitchHdRumbleEnvelope {
|
||||
constexpr uint16_t kLevel[257] = {
|
||||
0, 776, 1351, 1869, 2352, 2812, 3254, 3681, 4096, 4501, 4896, 5284, 5665, 6040, 6409, 6773,
|
||||
7131, 7486, 7836, 8182, 8525, 8865, 9201, 9534, 9864, 10191, 10516, 10839, 11159, 11476, 11792, 12105,
|
||||
12417, 12726, 13034, 13339, 13643, 13946, 14247, 14546, 14843, 15139, 15434, 15727, 16019, 16310, 16599, 16887,
|
||||
17174, 17460, 17744, 18028, 18310, 18591, 18871, 19150, 19428, 19705, 19981, 20256, 20531, 20804, 21076, 21348,
|
||||
21618, 21888, 22157, 22425, 22693, 22959, 23225, 23490, 23755, 24018, 24281, 24543, 24805, 25065, 25325, 25585,
|
||||
25844, 26102, 26359, 26616, 26872, 27128, 27383, 27637, 27891, 28144, 28397, 28649, 28901, 29152, 29402, 29652,
|
||||
29902, 30151, 30399, 30647, 30895, 31141, 31388, 31634, 31879, 32124, 32369, 32613, 32856, 33100, 33342, 33585,
|
||||
33826, 34068, 34309, 34549, 34789, 35029, 35268, 35507, 35746, 35984, 36222, 36459, 36696, 36933, 37169, 37405,
|
||||
37640, 37875, 38110, 38344, 38578, 38812, 39045, 39278, 39510, 39743, 39975, 40206, 40437, 40668, 40899, 41129,
|
||||
41359, 41589, 41818, 42047, 42276, 42504, 42732, 42960, 43187, 43414, 43641, 43868, 44094, 44320, 44546, 44771,
|
||||
44996, 45221, 45446, 45670, 45894, 46118, 46341, 46564, 46787, 47010, 47232, 47455, 47676, 47898, 48119, 48341,
|
||||
48561, 48782, 49002, 49223, 49442, 49662, 49881, 50101, 50319, 50538, 50757, 50975, 51193, 51410, 51628, 51845,
|
||||
52062, 52279, 52496, 52712, 52928, 53144, 53360, 53575, 53790, 54006, 54220, 54435, 54649, 54864, 55078, 55291,
|
||||
55505, 55718, 55932, 56145, 56357, 56570, 56782, 56994, 57206, 57418, 57630, 57841, 58052, 58263, 58474, 58685,
|
||||
58895, 59105, 59315, 59525, 59735, 59945, 60154, 60363, 60572, 60781, 60989, 61198, 61406, 61614, 61822, 62030,
|
||||
62237, 62445, 62652, 62859, 63066, 63272, 63479, 63685, 63891, 64097, 64303, 64509, 64715, 64920, 65125, 65330,
|
||||
65535,
|
||||
};
|
||||
} // namespace SwitchHdRumbleEnvelope
|
||||
398
src/firmware/input/switch_hd_rumble_synth.cpp
Normal file
398
src/firmware/input/switch_hd_rumble_synth.cpp
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
#include "input/switch_hd_rumble_synth.h"
|
||||
#include "input/switch_hd_rumble_envelope.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int64_t kWatchdogSamples = 150; // 50 ms at 3 kHz.
|
||||
constexpr unsigned kWindowSamples = 24; // 8 ms, independently split per side.
|
||||
|
||||
// round(40 * 2^(index/32) * 2^32 / 3000), index 0..159. High-band
|
||||
// indices address the same logarithmic table with an offset of 32 (80 Hz base).
|
||||
constexpr uint32_t kPhaseIncrement[160] = {
|
||||
57266231u, 58520198u, 59801623u, 61111108u, 62449267u, 63816728u, 65214133u, 66642136u,
|
||||
68101409u, 69592636u, 71116516u, 72673765u, 74265113u, 75891307u, 77553110u, 79251302u,
|
||||
80986680u, 82760057u, 84572267u, 86424158u, 88316601u, 90250483u, 92226711u, 94246213u,
|
||||
96309936u, 98418849u, 100573941u, 102776224u, 105026730u, 107326516u, 109676661u, 112078267u,
|
||||
114532461u, 117040396u, 119603246u, 122222217u, 124898535u, 127633456u, 130428265u, 133284272u,
|
||||
136202818u, 139185271u, 142233032u, 145347530u, 148530226u, 151782614u, 155106221u, 158502605u,
|
||||
161973360u, 165520115u, 169144533u, 172848316u, 176633202u, 180500965u, 184453421u, 188492425u,
|
||||
192619872u, 196837698u, 201147882u, 205552448u, 210053460u, 214653032u, 219353321u, 224156534u,
|
||||
229064922u, 234080791u, 239206493u, 244444433u, 249797069u, 255266913u, 260856530u, 266568545u,
|
||||
272405636u, 278370542u, 284466063u, 290695059u, 297060452u, 303565229u, 310212442u, 317005210u,
|
||||
323946720u, 331040229u, 338289067u, 345696633u, 353266403u, 361001930u, 368906843u, 376984851u,
|
||||
385239744u, 393675396u, 402295765u, 411104895u, 420106920u, 429306064u, 438706642u, 448313067u,
|
||||
458129845u, 468161582u, 478412986u, 488888866u, 499594138u, 510533826u, 521713061u, 533137089u,
|
||||
544811271u, 556741085u, 568932127u, 581390118u, 594120904u, 607130458u, 620424884u, 634010420u,
|
||||
647893440u, 662080459u, 676578133u, 691393265u, 706532806u, 722003860u, 737813686u, 753969702u,
|
||||
770479489u, 787350793u, 804591530u, 822209790u, 840213840u, 858612128u, 877413285u, 896626134u,
|
||||
916259690u, 936323164u, 956825972u, 977777733u, 999188277u, 1021067651u, 1043426121u, 1066274178u,
|
||||
1089622542u, 1113482169u, 1137864254u, 1162780236u, 1188241808u, 1214260916u, 1240849767u, 1268020839u,
|
||||
1295786880u, 1324160918u, 1353156266u, 1382786530u, 1413065613u, 1444007720u, 1475627372u, 1507939404u,
|
||||
1540958977u, 1574701585u, 1609183060u, 1644419580u, 1680427680u, 1717224255u, 1754826570u, 1793252268u,
|
||||
};
|
||||
|
||||
// round(127 * 256 * sin(2*pi*index/256)). Linear interpolation retains
|
||||
// sub-byte precision until the final two-band mix; opposite phases are exact
|
||||
// negatives, including rounding, so quantization cannot introduce a DC bias.
|
||||
constexpr int16_t kSine[256] = {
|
||||
0, 798, 1595, 2392, 3187, 3980, 4771, 5558, 6343, 7123, 7900, 8671, 9438, 10198, 10953, 11701,
|
||||
12442, 13175, 13901, 14618, 15326, 16025, 16715, 17394, 18063, 18721, 19367, 20002, 20625, 21236, 21834, 22418,
|
||||
22989, 23547, 24090, 24618, 25132, 25631, 26114, 26581, 27033, 27468, 27886, 28288, 28673, 29041, 29390, 29723,
|
||||
30037, 30333, 30611, 30871, 31112, 31334, 31538, 31722, 31887, 32033, 32160, 32267, 32355, 32424, 32473, 32502,
|
||||
32512, 32502, 32473, 32424, 32355, 32267, 32160, 32033, 31887, 31722, 31538, 31334, 31112, 30871, 30611, 30333,
|
||||
30037, 29723, 29390, 29041, 28673, 28288, 27886, 27468, 27033, 26581, 26114, 25631, 25132, 24618, 24090, 23547,
|
||||
22989, 22418, 21834, 21236, 20625, 20002, 19367, 18721, 18063, 17394, 16715, 16025, 15326, 14618, 13901, 13175,
|
||||
12442, 11701, 10953, 10198, 9438, 8671, 7900, 7123, 6343, 5558, 4771, 3980, 3187, 2392, 1595, 798,
|
||||
0, -798, -1595, -2392, -3187, -3980, -4771, -5558, -6343, -7123, -7900, -8671, -9438, -10198, -10953, -11701,
|
||||
-12442, -13175, -13901, -14618, -15326, -16025, -16715, -17394, -18063, -18721, -19367, -20002, -20625, -21236, -21834, -22418,
|
||||
-22989, -23547, -24090, -24618, -25132, -25631, -26114, -26581, -27033, -27468, -27886, -28288, -28673, -29041, -29390, -29723,
|
||||
-30037, -30333, -30611, -30871, -31112, -31334, -31538, -31722, -31887, -32033, -32160, -32267, -32355, -32424, -32473, -32502,
|
||||
-32512, -32502, -32473, -32424, -32355, -32267, -32160, -32033, -31887, -31722, -31538, -31334, -31112, -30871, -30611, -30333,
|
||||
-30037, -29723, -29390, -29041, -28673, -28288, -27886, -27468, -27033, -26581, -26114, -25631, -25132, -24618, -24090, -23547,
|
||||
-22989, -22418, -21834, -21236, -20625, -20002, -19367, -18721, -18063, -17394, -16715, -16025, -15326, -14618, -13901, -13175,
|
||||
-12442, -11701, -10953, -10198, -9438, -8671, -7900, -7123, -6343, -5558, -4771, -3980, -3187, -2392, -1595, -798,
|
||||
};
|
||||
|
||||
bool due(int64_t sample, uint64_t cursor) {
|
||||
return sample <= 0 || static_cast<uint64_t>(sample) <= cursor;
|
||||
}
|
||||
|
||||
bool older(uint64_t timestamp, uint64_t previous) {
|
||||
return timestamp - previous > static_cast<uint64_t>(INT64_MAX);
|
||||
}
|
||||
|
||||
int32_t rounded_shift(int32_t value, unsigned shift) {
|
||||
const int32_t half = int32_t{1} << (shift - 1);
|
||||
return value < 0 ? -((-value + half) >> shift)
|
||||
: (value + half) >> shift;
|
||||
}
|
||||
|
||||
int32_t sine(uint32_t phase) {
|
||||
const unsigned index = phase >> 24;
|
||||
const int32_t first = kSine[index];
|
||||
const int32_t difference = kSine[(index + 1) & 255u] - first;
|
||||
const int32_t fraction = static_cast<int32_t>((phase >> 8) & 65535u);
|
||||
return first + rounded_shift(difference * fraction, 16);
|
||||
}
|
||||
|
||||
void apply_host_gain(uint16_t& low, uint16_t& high) {
|
||||
if ((low | high) == 0) return;
|
||||
const uint32_t weighted_low = uint32_t{low} * 2;
|
||||
const uint32_t weighted_high = uint32_t{high} * 2;
|
||||
const uint32_t total = weighted_low + weighted_high;
|
||||
uint32_t target = 65535u;
|
||||
if (total < 65535u) {
|
||||
// A gentle 0.8-power curve lifts quiet/mid-level effects. Apply it
|
||||
// jointly so band balance is unchanged, not separately to each voice.
|
||||
const uint32_t index = total >> 8;
|
||||
const uint32_t fraction = total & 255u;
|
||||
const uint32_t first = SwitchHdRumbleEnvelope::kLevel[index];
|
||||
const uint32_t difference = SwitchHdRumbleEnvelope::kLevel[index + 1] - first;
|
||||
target = first + ((difference * fraction + 128u) >> 8);
|
||||
}
|
||||
// Product <= 65536*65535 fits uint32; floor rounding keeps the combined
|
||||
// weights <= 65535 and each weight representable in uint16. No clipping.
|
||||
low = static_cast<uint16_t>(weighted_low * target / total);
|
||||
high = static_cast<uint16_t>(weighted_high * target / total);
|
||||
}
|
||||
|
||||
uint8_t mix(uint32_t low_phase, uint32_t high_phase,
|
||||
uint16_t low, uint16_t high) {
|
||||
const int32_t sum = (low ? sine(low_phase) * low : 0) +
|
||||
(high ? sine(high_phase) * high : 0);
|
||||
// Q8 sine times band weights with a combined ceiling of 65536. The
|
||||
// 24-bit shift maps that ceiling to +/-127 without int32 overflow.
|
||||
return static_cast<uint8_t>(rounded_shift(sum, 24));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SwitchHdRumbleSynth::reset(uint64_t epoch_us) {
|
||||
epoch_us_ = epoch_us;
|
||||
cursor_ = 0;
|
||||
last_host_us_ = last_feedback_us_ = 0;
|
||||
have_host_ = have_feedback_ = false;
|
||||
head_ = count_ = 0;
|
||||
dropped_updates_ = 0;
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
sides_[side] = Side{};
|
||||
phase_[side][0] = phase_[side][1] = 0;
|
||||
}
|
||||
feedback_expires_ = 0;
|
||||
feedback_low_ = feedback_high_ = 0;
|
||||
}
|
||||
|
||||
void SwitchHdRumbleSynth::count_drop() {
|
||||
if (dropped_updates_ != UINT32_MAX) {
|
||||
++dropped_updates_;
|
||||
}
|
||||
}
|
||||
|
||||
bool SwitchHdRumbleSynth::timestamp_sample(uint64_t timestamp_us,
|
||||
int64_t& sample) const {
|
||||
const uint64_t elapsed = timestamp_us - epoch_us_;
|
||||
if (elapsed <= static_cast<uint64_t>(INT64_MAX)) {
|
||||
// ceil(elapsed * 3 / 1000), without overflowing an intermediate.
|
||||
sample = static_cast<int64_t>((elapsed / 1000) * 3 +
|
||||
((elapsed % 1000) * 3 + 999) / 1000);
|
||||
return true;
|
||||
}
|
||||
const uint64_t before = epoch_us_ - timestamp_us;
|
||||
if (before > static_cast<uint64_t>(INT64_MAX)) {
|
||||
return false; // Exactly half the clock range has ambiguous ordering.
|
||||
}
|
||||
sample = -static_cast<int64_t>((before / 1000) * 3 +
|
||||
(before % 1000) * 3 / 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SwitchHdRumbleSynth::push(const SwitchHapticsFrame& frame,
|
||||
uint64_t received_us) {
|
||||
Command command;
|
||||
if ((have_host_ && older(received_us, last_host_us_)) ||
|
||||
!timestamp_sample(received_us, command.sample) ||
|
||||
command.sample + kWatchdogSamples <= 0) {
|
||||
count_drop();
|
||||
return false;
|
||||
}
|
||||
bool has_update = false;
|
||||
for (const auto& actuator : frame.actuators) {
|
||||
if (actuator.sample_count > 3) {
|
||||
count_drop();
|
||||
return false;
|
||||
}
|
||||
has_update |= actuator.sample_count != 0;
|
||||
for (unsigned index = 0; index < actuator.sample_count; ++index) {
|
||||
const auto& sample = actuator.samples[index];
|
||||
if (sample.low_frequency_index > 127 ||
|
||||
sample.high_frequency_index > 127 ||
|
||||
sample.low_amplitude_q15 > 32768 ||
|
||||
sample.high_amplitude_q15 > 32768) {
|
||||
count_drop();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
have_host_ = true;
|
||||
last_host_us_ = received_us;
|
||||
if (has_update) {
|
||||
command.frame = frame;
|
||||
enqueue(command);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SwitchHdRumbleSynth::push_rumble(uint8_t low_magnitude,
|
||||
uint8_t high_magnitude,
|
||||
uint64_t received_us) {
|
||||
Command command;
|
||||
if ((have_host_ && older(received_us, last_host_us_)) ||
|
||||
!timestamp_sample(received_us, command.sample)) {
|
||||
count_drop();
|
||||
return false;
|
||||
}
|
||||
have_host_ = true;
|
||||
last_host_us_ = received_us;
|
||||
command.persistent = true;
|
||||
command.frame.actuators[0].sample_count = 1;
|
||||
command.frame.actuators[1].sample_count = 1;
|
||||
command.frame.actuators[0].samples[0].low_amplitude_q15 =
|
||||
(static_cast<uint32_t>(low_magnitude) * 32768u + 127u) / 255u;
|
||||
command.frame.actuators[1].samples[0].high_amplitude_q15 =
|
||||
(static_cast<uint32_t>(high_magnitude) * 32768u + 127u) / 255u;
|
||||
enqueue(command);
|
||||
return true;
|
||||
}
|
||||
|
||||
void SwitchHdRumbleSynth::feedback(uint64_t at_us, uint32_t duration_us,
|
||||
uint8_t low_magnitude,
|
||||
uint8_t high_magnitude) {
|
||||
Command command;
|
||||
if ((have_feedback_ && older(at_us, last_feedback_us_)) ||
|
||||
!timestamp_sample(at_us, command.sample) ||
|
||||
!timestamp_sample(at_us + duration_us, command.expires)) {
|
||||
count_drop();
|
||||
return;
|
||||
}
|
||||
have_feedback_ = true;
|
||||
last_feedback_us_ = at_us;
|
||||
command.is_feedback = true;
|
||||
command.low = (static_cast<uint32_t>(low_magnitude) * 32768u + 127u) / 255u;
|
||||
command.high = (static_cast<uint32_t>(high_magnitude) * 32768u + 127u) / 255u;
|
||||
enqueue(command);
|
||||
}
|
||||
|
||||
void SwitchHdRumbleSynth::enqueue(const Command& command) {
|
||||
if (count_ == kCapacity) {
|
||||
const int64_t oldest_sample = commands_[head_].sample;
|
||||
const int64_t watermark = command.sample < oldest_sample
|
||||
? command.sample : oldest_sample;
|
||||
advance_to(watermark > 0 ? static_cast<uint64_t>(watermark) : 0, true);
|
||||
if (count_ == kCapacity) {
|
||||
// An older command from the other producer is itself the oldest.
|
||||
// Fold it directly into the baseline without losing either side.
|
||||
apply(command);
|
||||
count_drop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
unsigned position = count_;
|
||||
while (position && commands_[(head_ + position - 1) % kCapacity].sample >
|
||||
command.sample) {
|
||||
commands_[(head_ + position) % kCapacity] =
|
||||
commands_[(head_ + position - 1) % kCapacity];
|
||||
--position;
|
||||
}
|
||||
commands_[(head_ + position) % kCapacity] = command;
|
||||
++count_;
|
||||
}
|
||||
|
||||
void SwitchHdRumbleSynth::apply(const Command& command) {
|
||||
if (command.is_feedback) {
|
||||
feedback_expires_ = command.expires;
|
||||
feedback_low_ = command.low;
|
||||
feedback_high_ = command.high;
|
||||
return;
|
||||
}
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
if (command.frame.actuators[side].sample_count) {
|
||||
sides_[side].frame = command.frame.actuators[side];
|
||||
sides_[side].start = command.sample;
|
||||
sides_[side].expires = command.sample + kWatchdogSamples;
|
||||
sides_[side].persistent = command.persistent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SwitchHdRumbleSynth::apply_due(bool discarded) {
|
||||
while (count_ && due(commands_[head_].sample, cursor_)) {
|
||||
apply(commands_[head_]);
|
||||
head_ = (head_ + 1) % kCapacity;
|
||||
--count_;
|
||||
if (discarded) {
|
||||
count_drop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SwitchHapticsSample& SwitchHdRumbleSynth::host_sample(unsigned side) const {
|
||||
const Side& state = sides_[side];
|
||||
const unsigned spacing = kWindowSamples / state.frame.sample_count;
|
||||
unsigned index = 0;
|
||||
while (index + 1 < state.frame.sample_count &&
|
||||
due(state.start + (index + 1) * spacing, cursor_)) {
|
||||
++index;
|
||||
}
|
||||
return state.frame.samples[index];
|
||||
}
|
||||
|
||||
uint64_t SwitchHdRumbleSynth::next_boundary(uint64_t limit) const {
|
||||
const auto consider = [this, &limit](int64_t boundary) {
|
||||
if (!due(boundary, cursor_) && static_cast<uint64_t>(boundary) < limit) {
|
||||
limit = static_cast<uint64_t>(boundary);
|
||||
}
|
||||
};
|
||||
if (count_) {
|
||||
consider(commands_[head_].sample);
|
||||
}
|
||||
for (const Side& side : sides_) {
|
||||
const unsigned spacing = kWindowSamples / side.frame.sample_count;
|
||||
for (unsigned index = 1; index < side.frame.sample_count; ++index) {
|
||||
consider(side.start + index * spacing);
|
||||
}
|
||||
if (!side.persistent) consider(side.expires);
|
||||
}
|
||||
consider(feedback_expires_);
|
||||
return limit;
|
||||
}
|
||||
|
||||
void SwitchHdRumbleSynth::advance_phases(uint64_t samples) {
|
||||
// Multiplication modulo 2^32 skips arbitrarily large intervals in O(1).
|
||||
const uint32_t count = static_cast<uint32_t>(samples);
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
const auto& sample = host_sample(side);
|
||||
phase_[side][0] += kPhaseIncrement[sample.low_frequency_index] * count;
|
||||
phase_[side][1] += kPhaseIncrement[sample.high_frequency_index + 32] * count;
|
||||
}
|
||||
}
|
||||
|
||||
void SwitchHdRumbleSynth::advance_to(uint64_t sample, bool discarded) {
|
||||
apply_due(discarded);
|
||||
while (cursor_ < sample) {
|
||||
const uint64_t boundary = next_boundary(sample);
|
||||
advance_phases(boundary - cursor_);
|
||||
cursor_ = boundary;
|
||||
apply_due(discarded);
|
||||
}
|
||||
}
|
||||
|
||||
void SwitchHdRumbleSynth::render(uint64_t first_sample, uint32_t frames,
|
||||
uint8_t* interleaved_stereo) {
|
||||
if (!frames) {
|
||||
advance_to(first_sample);
|
||||
return;
|
||||
}
|
||||
if (!interleaved_stereo) {
|
||||
return;
|
||||
}
|
||||
if (first_sample > UINT64_MAX - frames) {
|
||||
memset(interleaved_stereo, 0, static_cast<size_t>(frames) * 2);
|
||||
return;
|
||||
}
|
||||
if (first_sample < cursor_) {
|
||||
const uint64_t consumed = cursor_ - first_sample;
|
||||
const uint32_t silence = consumed < frames
|
||||
? static_cast<uint32_t>(consumed) : frames;
|
||||
memset(interleaved_stereo, 0, static_cast<size_t>(silence) * 2);
|
||||
interleaved_stereo += static_cast<size_t>(silence) * 2;
|
||||
first_sample += silence;
|
||||
frames -= silence;
|
||||
}
|
||||
advance_to(first_sample);
|
||||
const uint64_t end = first_sample + frames;
|
||||
while (cursor_ < end) {
|
||||
const uint64_t boundary = next_boundary(end);
|
||||
uint32_t increment[2][2];
|
||||
uint16_t amplitude[2][2];
|
||||
const bool overlay = !due(feedback_expires_, cursor_) &&
|
||||
(feedback_low_ || feedback_high_);
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
const auto& sample = host_sample(side);
|
||||
increment[side][0] = kPhaseIncrement[sample.low_frequency_index];
|
||||
increment[side][1] = kPhaseIncrement[sample.high_frequency_index + 32];
|
||||
const bool expired = !sides_[side].persistent &&
|
||||
due(sides_[side].expires, cursor_);
|
||||
amplitude[side][0] = expired ? 0 : sample.low_amplitude_q15;
|
||||
amplitude[side][1] = expired ? 0 : sample.high_amplitude_q15;
|
||||
if (!overlay) apply_host_gain(amplitude[side][0], amplitude[side][1]);
|
||||
}
|
||||
uint32_t feedback_low_phase = kPhaseIncrement[64] *
|
||||
static_cast<uint32_t>(cursor_);
|
||||
uint32_t feedback_high_phase = kPhaseIncrement[96] *
|
||||
static_cast<uint32_t>(cursor_);
|
||||
for (; cursor_ < boundary; ++cursor_) {
|
||||
if (overlay) {
|
||||
const uint8_t value = mix(feedback_low_phase, feedback_high_phase,
|
||||
feedback_low_, feedback_high_);
|
||||
*interleaved_stereo++ = value;
|
||||
*interleaved_stereo++ = value;
|
||||
feedback_low_phase += kPhaseIncrement[64];
|
||||
feedback_high_phase += kPhaseIncrement[96];
|
||||
} else {
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
*interleaved_stereo++ = mix(phase_[side][0], phase_[side][1],
|
||||
amplitude[side][0], amplitude[side][1]);
|
||||
}
|
||||
}
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
phase_[side][0] += increment[side][0];
|
||||
phase_[side][1] += increment[side][1];
|
||||
}
|
||||
}
|
||||
apply_due();
|
||||
}
|
||||
}
|
||||
100
src/firmware/input/switch_hd_rumble_synth.h
Normal file
100
src/firmware/input/switch_hd_rumble_synth.h
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "usb/switch/switch_haptics.h"
|
||||
|
||||
// Single-core, allocation-free 3 kHz stereo PCM timeline. All times use the same
|
||||
// 64-bit microsecond clock; unsigned clock rollover is supported for intervals
|
||||
// shorter than 2^63 us. reset() establishes sample zero and zero oscillator phase.
|
||||
// Host PCM gets balanced 2x gain and a joint 0.8-power amplitude curve after
|
||||
// profile scaling, bounded to mixer headroom. Feedback gain remains unchanged.
|
||||
class SwitchHdRumbleSynth {
|
||||
public:
|
||||
void reset(uint64_t epoch_us);
|
||||
|
||||
// Zero-count sides are untouched (including their independent watchdog).
|
||||
// Updated sides resume the 50 ms watchdog even after persistent rumble.
|
||||
// Duplicate timestamps are accepted in call order; the last update wins.
|
||||
// Out-of-order or malformed batches are rejected and counted. Pre-epoch
|
||||
// batches retain their original substep position and expiry; already
|
||||
// expired pre-epoch batches are rejected. Late ordered batches take effect
|
||||
// at the render cursor's current substep, never replaying earlier substeps
|
||||
// or extending their original expiry.
|
||||
bool push(const SwitchHapticsFrame& frame, uint64_t received_us);
|
||||
|
||||
// Profile-scaled conventional host rumble: low drives left at 160 Hz and
|
||||
// high drives right at 320 Hz, with the opposite bands silent. Both sides
|
||||
// persist until replaced or reset; zero stops both. Pre-epoch commands do
|
||||
// not expire. Host timestamp ordering is shared with push(), including
|
||||
// duplicate last-wins and late-command phase/history behavior.
|
||||
bool push_rumble(uint8_t low_magnitude, uint8_t high_magnitude,
|
||||
uint64_t received_us);
|
||||
|
||||
// Signed int8 PCM encoded in bytes, left then right. Calls normally advance
|
||||
// monotonically; forward gaps advance phase analytically, not sample by
|
||||
// sample. Already consumed samples are returned as silence, never replayed.
|
||||
void render(uint64_t first_sample, uint32_t frames,
|
||||
uint8_t* interleaved_stereo);
|
||||
|
||||
// Timestamped conventional override on both sides, at 160/320 Hz. Zero
|
||||
// duration or two zero magnitudes cancels only the override at at_us.
|
||||
// Host oscillators and updates continue underneath it; expiry reveals the
|
||||
// current host state. Override phases also free-run from the stream epoch.
|
||||
// Feedback timestamps are chronological independently of both host APIs.
|
||||
void feedback(uint64_t at_us, uint32_t duration_us,
|
||||
uint8_t low_magnitude, uint8_t high_magnitude);
|
||||
|
||||
uint32_t dropped_updates() const { return dropped_updates_; }
|
||||
|
||||
private:
|
||||
static constexpr uint8_t kCapacity = 16;
|
||||
|
||||
struct Command {
|
||||
int64_t sample = 0;
|
||||
int64_t expires = 0;
|
||||
SwitchHapticsFrame frame{};
|
||||
uint16_t low = 0;
|
||||
uint16_t high = 0;
|
||||
bool is_feedback = false;
|
||||
bool persistent = false;
|
||||
};
|
||||
|
||||
struct Side {
|
||||
SwitchHapticsActuatorFrame frame{1, {}};
|
||||
bool persistent = false;
|
||||
int64_t start = 0;
|
||||
int64_t expires = 0;
|
||||
};
|
||||
|
||||
// A fixed ring shared by host and feedback commands, ordered by sample.
|
||||
// Overflow consumes the oldest command analytically into the live baseline,
|
||||
// advancing a discard watermark. Unrendered PCM before that watermark is
|
||||
// silence. Thus missing history cannot replay later, and partial-side state
|
||||
// and oscillator phase survive eviction without an additional history ring.
|
||||
void enqueue(const Command& command);
|
||||
void apply(const Command& command);
|
||||
void apply_due(bool discarded = false);
|
||||
void advance_to(uint64_t sample, bool discarded = false);
|
||||
void advance_phases(uint64_t samples);
|
||||
uint64_t next_boundary(uint64_t limit) const;
|
||||
const SwitchHapticsSample& host_sample(unsigned side) const;
|
||||
bool timestamp_sample(uint64_t timestamp_us, int64_t& sample) const;
|
||||
void count_drop();
|
||||
|
||||
uint64_t epoch_us_ = 0;
|
||||
uint64_t cursor_ = 0;
|
||||
uint64_t last_host_us_ = 0;
|
||||
uint64_t last_feedback_us_ = 0;
|
||||
bool have_host_ = false;
|
||||
bool have_feedback_ = false;
|
||||
uint8_t head_ = 0;
|
||||
uint8_t count_ = 0;
|
||||
uint32_t dropped_updates_ = 0;
|
||||
Command commands_[kCapacity]{};
|
||||
Side sides_[2]{};
|
||||
uint32_t phase_[2][2]{};
|
||||
int64_t feedback_expires_ = 0;
|
||||
uint16_t feedback_low_ = 0;
|
||||
uint16_t feedback_high_ = 0;
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
182
src/firmware/platform/pico/cyw43_packet_transport.c
Normal file
182
src/firmware/platform/pico/cyw43_packet_transport.c
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/*
|
||||
* Opt-in GNU --wrap=cyw43_btbus_read / --wrap=cyw43_btbus_init transport.
|
||||
* Requires the build-local cybt_shared_bus_driver.c status-propagation patch;
|
||||
* the stock driver's memory-read helpers discard SPI errors.
|
||||
*/
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cyw43_btbus.h"
|
||||
#include "cyw43_config.h"
|
||||
#include "cybt_shared_bus_driver.h"
|
||||
|
||||
#define PACKET_HEADER_SIZE 4u
|
||||
#define RING_MASK (BTSDIO_FWBUF_SIZE - 1u)
|
||||
#define RING_CAPACITY (BTSDIO_FWBUF_SIZE - 4u)
|
||||
#define WAKE_RETRIES 300u
|
||||
|
||||
/*
|
||||
* Preserve cybt_hci_read's overflow check, not its cached indices. This lower
|
||||
* bound is reset with every controller initialization, under the driver lock.
|
||||
* All actual indices and packet availability are fetched afresh on each call.
|
||||
*/
|
||||
static uint32_t minimum_available;
|
||||
|
||||
int __real_cyw43_btbus_init(cyw43_ll_t *self);
|
||||
|
||||
int __wrap_cyw43_btbus_init(cyw43_ll_t *self) {
|
||||
CYW43_THREAD_ENTER
|
||||
minimum_available = 0;
|
||||
int result = __real_cyw43_btbus_init(self);
|
||||
CYW43_THREAD_EXIT
|
||||
return result;
|
||||
}
|
||||
|
||||
static cybt_result_t request_bus(void) {
|
||||
cybt_result_t result = cybt_set_bt_awake(true);
|
||||
if (result != CYBT_SUCCESS) {
|
||||
return result;
|
||||
}
|
||||
/* Match the SDK's bounded 301 polls and 1 ms wake-handshake delay. */
|
||||
for (uint32_t remaining = WAKE_RETRIES;; --remaining) {
|
||||
int awake = cybt_awake();
|
||||
if (awake == 1) {
|
||||
return CYBT_SUCCESS;
|
||||
}
|
||||
if (awake != 0) {
|
||||
return CYBT_ERR_HCI_READ_FAILED;
|
||||
}
|
||||
cyw43_delay_ms(1);
|
||||
if (remaining == 0) {
|
||||
return CYBT_ERR_TIMEOUT;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* All offsets, lengths and destinations here are word aligned. */
|
||||
static cybt_result_t read_ring(uint32_t offset, uint8_t *buffer, uint32_t length) {
|
||||
if (length == 0) {
|
||||
return CYBT_SUCCESS;
|
||||
}
|
||||
uint32_t first = BTSDIO_FWBUF_SIZE - offset;
|
||||
if (first > length) {
|
||||
first = length;
|
||||
}
|
||||
cybt_result_t result = cybt_mem_read_idx(B2H_BUF_ADDR_IDX, offset, buffer, first);
|
||||
if (result != CYBT_SUCCESS || first == length) {
|
||||
return result;
|
||||
}
|
||||
return cybt_mem_read_idx(B2H_BUF_ADDR_IDX, 0, buffer + first, length - first);
|
||||
}
|
||||
|
||||
static cybt_result_t read_packet(uint8_t *buffer, uint32_t capacity, uint32_t *size) {
|
||||
cybt_fw_membuf_index_t indices;
|
||||
cybt_result_t result = cybt_get_bt_buf_index(&indices);
|
||||
if (result != CYBT_SUCCESS) {
|
||||
return result;
|
||||
}
|
||||
/* Keep the SDK's four-register corruption checks effective in release. */
|
||||
if (indices.host2bt_in_val >= BTSDIO_FWBUF_SIZE ||
|
||||
indices.host2bt_out_val >= BTSDIO_FWBUF_SIZE ||
|
||||
indices.bt2host_in_val >= BTSDIO_FWBUF_SIZE ||
|
||||
indices.bt2host_out_val >= BTSDIO_FWBUF_SIZE ||
|
||||
(indices.bt2host_out_val & 3u) != 0) {
|
||||
return CYBT_ERR_HCI_READ_FAILED;
|
||||
}
|
||||
uint32_t available = (indices.bt2host_in_val - indices.bt2host_out_val) & RING_MASK;
|
||||
if (available > RING_CAPACITY) {
|
||||
return CYBT_ERR_HCI_READ_FAILED;
|
||||
}
|
||||
if (available < minimum_available) {
|
||||
panic("cyw43 buffer overflow");
|
||||
}
|
||||
minimum_available = available;
|
||||
if (available < PACKET_HEADER_SIZE) {
|
||||
/* No partial-header consumption and no polling for more packet data. */
|
||||
return CYBT_SUCCESS;
|
||||
}
|
||||
|
||||
result = read_ring(indices.bt2host_out_val, buffer, PACKET_HEADER_SIZE);
|
||||
if (result != CYBT_SUCCESS) {
|
||||
return result;
|
||||
}
|
||||
uint32_t payload_length = (uint32_t)buffer[0] |
|
||||
((uint32_t)buffer[1] << 8) |
|
||||
((uint32_t)buffer[2] << 16);
|
||||
if (payload_length > capacity - PACKET_HEADER_SIZE ||
|
||||
payload_length > RING_CAPACITY - PACKET_HEADER_SIZE) {
|
||||
return CYBT_ERR_BADARG;
|
||||
}
|
||||
uint32_t payload_wire_length = (payload_length + 3u) & ~3u;
|
||||
uint32_t wire_length = PACKET_HEADER_SIZE + payload_wire_length;
|
||||
if (wire_length > available) {
|
||||
/* The producer may finish later; retain both header and payload. */
|
||||
return CYBT_SUCCESS;
|
||||
}
|
||||
|
||||
uint32_t payload_offset = (indices.bt2host_out_val + PACKET_HEADER_SIZE) & RING_MASK;
|
||||
if (wire_length <= capacity) {
|
||||
result = read_ring(payload_offset, buffer + PACKET_HEADER_SIZE, payload_wire_length);
|
||||
} else {
|
||||
/* A valid unpadded capacity must not permit a 1-3 byte buffer overrun. */
|
||||
uint32_t whole_length = payload_length & ~3u;
|
||||
result = read_ring(payload_offset, buffer + PACKET_HEADER_SIZE, whole_length);
|
||||
if (result == CYBT_SUCCESS) {
|
||||
uint32_t last_word;
|
||||
result = read_ring((payload_offset + whole_length) & RING_MASK,
|
||||
(uint8_t *)&last_word, sizeof(last_word));
|
||||
if (result == CYBT_SUCCESS) {
|
||||
memcpy(buffer + PACKET_HEADER_SIZE + whole_length, &last_word,
|
||||
payload_length - whole_length);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result != CYBT_SUCCESS) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* The producer publishes only readable bytes and may not overwrite unread
|
||||
* bytes (the same ring ownership rule as the SDK). One availability snapshot
|
||||
* therefore covers header, payload and padding until this commit point.
|
||||
*/
|
||||
uint32_t next = (indices.bt2host_out_val + wire_length) & RING_MASK;
|
||||
result = cybt_reg_write_idx(B2H_BUF_OUT_ADDR_IDX, next);
|
||||
if (result != CYBT_SUCCESS) {
|
||||
return result;
|
||||
}
|
||||
minimum_available = available - wire_length;
|
||||
/*
|
||||
* DATA_VALID signals changed host ring state, not an RX IRQ acknowledgement:
|
||||
* cyw43_ll_bt_has_work separately clears SDIO_INT_STATUS/I_HMB_FC_CHANGE.
|
||||
* Empty/partial reads change no ring state and need no DATA_VALID toggle.
|
||||
* The SDK's notification helper always succeeds (its register write is void).
|
||||
* If that contract changes, a failure here cannot safely roll back the
|
||||
* consumer: the controller already owns the reclaimed bytes.
|
||||
*/
|
||||
result = cybt_toggle_bt_intr();
|
||||
if (result != CYBT_SUCCESS) {
|
||||
return result;
|
||||
}
|
||||
*size = PACKET_HEADER_SIZE + payload_length;
|
||||
return CYBT_SUCCESS;
|
||||
}
|
||||
|
||||
int __wrap_cyw43_btbus_read(uint8_t *buffer, uint32_t capacity, uint32_t *size) {
|
||||
if (size == NULL) {
|
||||
return -1;
|
||||
}
|
||||
*size = 0;
|
||||
if (buffer == NULL || capacity < PACKET_HEADER_SIZE || ((uintptr_t)buffer & 3u) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
CYW43_THREAD_ENTER
|
||||
cybt_result_t result = request_bus();
|
||||
if (result == CYBT_SUCCESS) {
|
||||
result = read_packet(buffer, capacity, size);
|
||||
}
|
||||
CYW43_THREAD_EXIT
|
||||
return result == CYBT_SUCCESS ? 0 : -1;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
#include "platform/pico/pico_profile_storage.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "configuration/configuration_storage.h"
|
||||
|
|
@ -19,132 +20,105 @@ constexpr uint32_t kProfileStorageOffset =
|
|||
kConfigurationStorageOffset - PROFILE_STORAGE_TOTAL_SIZE;
|
||||
constexpr uint32_t kFlashSafeExecuteTimeoutMs = 5000;
|
||||
|
||||
static_assert(FLASH_SECTOR_SIZE == PROFILE_STORAGE_SECTOR_SIZE,
|
||||
"profile storage sector size does not match Pico flash");
|
||||
static_assert(FLASH_PAGE_SIZE == PROFILE_STORAGE_PAGE_SIZE,
|
||||
"profile storage page size does not match Pico flash");
|
||||
static_assert(FLASH_SECTOR_SIZE == PROFILE_STORAGE_SECTOR_SIZE);
|
||||
static_assert(FLASH_PAGE_SIZE == PROFILE_STORAGE_PAGE_SIZE);
|
||||
static_assert(PICO_FLASH_BANK_STORAGE_OFFSET >=
|
||||
kConfigurationStorageSize + PROFILE_STORAGE_TOTAL_SIZE,
|
||||
"profile storage offset underflows flash");
|
||||
kConfigurationStorageSize + PROFILE_STORAGE_TOTAL_SIZE);
|
||||
static_assert(kProfileStorageOffset + PROFILE_STORAGE_TOTAL_SIZE <=
|
||||
kConfigurationStorageOffset,
|
||||
"profile storage overlaps adapter configuration storage");
|
||||
kConfigurationStorageOffset);
|
||||
static_assert(kConfigurationStorageOffset + kConfigurationStorageSize <=
|
||||
PICO_FLASH_BANK_STORAGE_OFFSET,
|
||||
"adapter configuration storage overlaps BTstack bonds");
|
||||
static_assert(PICO_FLASH_BANK_STORAGE_OFFSET +
|
||||
PICO_FLASH_BANK_TOTAL_SIZE <=
|
||||
PICO_FLASH_SIZE_BYTES,
|
||||
"BTstack storage exceeds flash");
|
||||
PICO_FLASH_BANK_STORAGE_OFFSET);
|
||||
static_assert(PICO_FLASH_BANK_STORAGE_OFFSET + PICO_FLASH_BANK_TOTAL_SIZE <=
|
||||
PICO_FLASH_SIZE_BYTES);
|
||||
|
||||
struct FlashBankReplacement {
|
||||
uint8_t bank;
|
||||
const uint8_t* payload;
|
||||
size_t payload_size;
|
||||
const uint8_t* header;
|
||||
bool replaced;
|
||||
struct EraseOperation {
|
||||
uint32_t offset;
|
||||
};
|
||||
|
||||
void perform_flash_bank_replacement(void* context) {
|
||||
auto* replacement =
|
||||
static_cast<FlashBankReplacement*>(context);
|
||||
replacement->replaced = false;
|
||||
const uint32_t bank_offset =
|
||||
kProfileStorageOffset +
|
||||
replacement->bank * PROFILE_STORAGE_BANK_SIZE;
|
||||
struct ProgramOperation {
|
||||
uint32_t offset;
|
||||
const uint8_t *page;
|
||||
};
|
||||
|
||||
for (size_t offset = 0; offset < PROFILE_STORAGE_BANK_SIZE;
|
||||
offset += FLASH_SECTOR_SIZE) {
|
||||
flash_range_erase(bank_offset + offset, FLASH_SECTOR_SIZE);
|
||||
}
|
||||
void perform_erase(void *context) {
|
||||
const auto *operation = static_cast<const EraseOperation *>(context);
|
||||
flash_range_erase(operation->offset, PROFILE_STORAGE_ARENA_SIZE);
|
||||
}
|
||||
|
||||
uint8_t final_page[FLASH_PAGE_SIZE]{};
|
||||
for (size_t offset = 0; offset < replacement->payload_size;
|
||||
offset += FLASH_PAGE_SIZE) {
|
||||
const size_t remaining =
|
||||
replacement->payload_size - offset;
|
||||
const uint8_t* page = &replacement->payload[offset];
|
||||
if (remaining < FLASH_PAGE_SIZE) {
|
||||
memcpy(final_page, page, remaining);
|
||||
page = final_page;
|
||||
}
|
||||
flash_range_program(
|
||||
bank_offset + PROFILE_STORAGE_RECORD_HEADER_SIZE + offset,
|
||||
page, FLASH_PAGE_SIZE);
|
||||
}
|
||||
|
||||
const auto* stored_payload = reinterpret_cast<const uint8_t*>(
|
||||
XIP_BASE + bank_offset +
|
||||
PROFILE_STORAGE_RECORD_HEADER_SIZE);
|
||||
if (memcmp(stored_payload, replacement->payload,
|
||||
replacement->payload_size) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
flash_range_program(bank_offset, replacement->header,
|
||||
PROFILE_STORAGE_RECORD_HEADER_SIZE);
|
||||
const auto* stored_header = reinterpret_cast<const uint8_t*>(
|
||||
XIP_BASE + bank_offset);
|
||||
replacement->replaced =
|
||||
memcmp(stored_header, replacement->header,
|
||||
PROFILE_STORAGE_RECORD_HEADER_SIZE) == 0;
|
||||
void perform_program(void *context) {
|
||||
const auto *operation = static_cast<const ProgramOperation *>(context);
|
||||
flash_range_program(operation->offset, operation->page,
|
||||
PROFILE_STORAGE_PAGE_SIZE);
|
||||
}
|
||||
|
||||
bool storage_region_available() {
|
||||
const uintptr_t binary_end =
|
||||
reinterpret_cast<uintptr_t>(&__flash_binary_end) - XIP_BASE;
|
||||
return binary_end <= kProfileStorageOffset;
|
||||
const uintptr_t binary_end =
|
||||
reinterpret_cast<uintptr_t>(&__flash_binary_end) - XIP_BASE;
|
||||
return binary_end <= kProfileStorageOffset;
|
||||
}
|
||||
|
||||
bool read_storage(void*, uint8_t bank, size_t offset, uint8_t* output,
|
||||
bool read_storage(void *, uint8_t arena, size_t offset, uint8_t *output,
|
||||
size_t size) {
|
||||
if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr ||
|
||||
offset > PROFILE_STORAGE_BANK_SIZE ||
|
||||
size > PROFILE_STORAGE_BANK_SIZE - offset ||
|
||||
!storage_region_available()) {
|
||||
return false;
|
||||
}
|
||||
const uintptr_t address =
|
||||
XIP_BASE + kProfileStorageOffset +
|
||||
bank * PROFILE_STORAGE_BANK_SIZE + offset;
|
||||
memcpy(output, reinterpret_cast<const void*>(address), size);
|
||||
return true;
|
||||
if (arena >= PROFILE_STORAGE_ARENA_COUNT || output == nullptr ||
|
||||
offset > PROFILE_STORAGE_ARENA_SIZE ||
|
||||
size > PROFILE_STORAGE_ARENA_SIZE - offset ||
|
||||
!storage_region_available()) {
|
||||
return false;
|
||||
}
|
||||
const uintptr_t address = XIP_BASE + kProfileStorageOffset +
|
||||
arena * PROFILE_STORAGE_ARENA_SIZE + offset;
|
||||
memcpy(output, reinterpret_cast<const void *>(address), size);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool replace_storage_bank(void*, uint8_t bank,
|
||||
const uint8_t* payload,
|
||||
size_t payload_size,
|
||||
const uint8_t* header,
|
||||
size_t header_size) {
|
||||
if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr ||
|
||||
header == nullptr ||
|
||||
payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
|
||||
header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE ||
|
||||
!storage_region_available()) {
|
||||
return false;
|
||||
bool erase_arena(void *, uint8_t arena) {
|
||||
if (arena >= PROFILE_STORAGE_ARENA_COUNT || !storage_region_available()) {
|
||||
return false;
|
||||
}
|
||||
EraseOperation operation{
|
||||
kProfileStorageOffset + arena * PROFILE_STORAGE_ARENA_SIZE,
|
||||
};
|
||||
if (flash_safe_execute(perform_erase, &operation,
|
||||
kFlashSafeExecuteTimeoutMs) != PICO_OK) {
|
||||
return false;
|
||||
}
|
||||
const auto *stored =
|
||||
reinterpret_cast<const uint8_t *>(XIP_BASE + operation.offset);
|
||||
for (size_t offset = 0; offset < PROFILE_STORAGE_ARENA_SIZE; ++offset) {
|
||||
if (stored[offset] != 0xff) {
|
||||
return false;
|
||||
}
|
||||
FlashBankReplacement replacement{
|
||||
bank,
|
||||
payload,
|
||||
payload_size,
|
||||
header,
|
||||
false,
|
||||
};
|
||||
return flash_safe_execute(
|
||||
perform_flash_bank_replacement, &replacement,
|
||||
kFlashSafeExecuteTimeoutMs) == PICO_OK &&
|
||||
replacement.replaced;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
bool program_page(void *, uint8_t arena, size_t offset, const uint8_t *page,
|
||||
size_t size) {
|
||||
if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr ||
|
||||
size != PROFILE_STORAGE_PAGE_SIZE ||
|
||||
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
|
||||
offset > PROFILE_STORAGE_ARENA_SIZE ||
|
||||
size > PROFILE_STORAGE_ARENA_SIZE - offset ||
|
||||
!storage_region_available()) {
|
||||
return false;
|
||||
}
|
||||
ProgramOperation operation{
|
||||
kProfileStorageOffset + arena * PROFILE_STORAGE_ARENA_SIZE + offset,
|
||||
page,
|
||||
};
|
||||
return flash_safe_execute(perform_program, &operation,
|
||||
kFlashSafeExecuteTimeoutMs) == PICO_OK &&
|
||||
memcmp(reinterpret_cast<const void *>(XIP_BASE + operation.offset),
|
||||
page, size) == 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ProfileStorageIo pico_profile_storage_io() {
|
||||
return {
|
||||
nullptr,
|
||||
PROFILE_STORAGE_BANK_SIZE,
|
||||
FLASH_SECTOR_SIZE,
|
||||
FLASH_PAGE_SIZE,
|
||||
read_storage,
|
||||
replace_storage_bank,
|
||||
};
|
||||
return {
|
||||
nullptr, PROFILE_STORAGE_ARENA_SIZE,
|
||||
FLASH_SECTOR_SIZE, FLASH_PAGE_SIZE,
|
||||
read_storage, erase_arena,
|
||||
program_page,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
73
src/firmware/platform/pico/system_clock.cpp
Normal file
73
src/firmware/platform/pico/system_clock.cpp
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#include "platform/pico/system_clock.h"
|
||||
|
||||
#include "hardware/adc.h"
|
||||
#include "hardware/clocks.h"
|
||||
#include "hardware/powman.h"
|
||||
#include "hardware/regs/clocks.h"
|
||||
#include "hardware/regs/qmi.h"
|
||||
#include "hardware/structs/qmi.h"
|
||||
#include "hardware/vreg.h"
|
||||
#include "pico/stdlib.h"
|
||||
|
||||
namespace {
|
||||
SystemClockStatus g_status{};
|
||||
}
|
||||
|
||||
void system_clock_initialize() {
|
||||
static_assert(SWITCH_PICO_SYS_CLOCK_MHZ == 150 ||
|
||||
SWITCH_PICO_SYS_CLOCK_MHZ == 300 ||
|
||||
SWITCH_PICO_SYS_CLOCK_MHZ == 400);
|
||||
// Flash timing was established by boot stage 2. Do not raise clk_sys if
|
||||
// another boot configuration failed to provide the required divider.
|
||||
const uint32_t flash_divider =
|
||||
(qmi_hw->m[0].timing & QMI_M0_TIMING_CLKDIV_BITS) >>
|
||||
QMI_M0_TIMING_CLKDIV_LSB;
|
||||
if (flash_divider < (SWITCH_PICO_SYS_CLOCK_MHZ + 74u) / 75u) {
|
||||
panic("unsafe overclock flash divider");
|
||||
}
|
||||
#if SWITCH_PICO_SYS_CLOCK_MHZ == 150
|
||||
constexpr auto voltage = VREG_VOLTAGE_1_10;
|
||||
#elif SWITCH_PICO_OVERCLOCK_MV == 1400
|
||||
constexpr auto voltage = VREG_VOLTAGE_1_40;
|
||||
vreg_disable_voltage_limit();
|
||||
#else
|
||||
constexpr auto voltage = VREG_VOLTAGE_1_30;
|
||||
#endif
|
||||
if (vreg_get_voltage() != voltage) {
|
||||
vreg_set_voltage(voltage);
|
||||
sleep_us(1000);
|
||||
}
|
||||
#if SWITCH_PICO_SYS_CLOCK_MHZ != 400 || SWITCH_PICO_OVERCLOCK_MV != 1400
|
||||
// A warm reboot from the explicit 1.4 V image must not leave its lifted
|
||||
// voltage limit behind. Lower voltage before reinstating the limit.
|
||||
powman_clear_bits(&powman_hw->vreg_ctrl,
|
||||
POWMAN_VREG_CTRL_DISABLE_VOLTAGE_LIMIT_BITS);
|
||||
#endif
|
||||
#if SWITCH_PICO_SYS_CLOCK_MHZ != 150
|
||||
set_sys_clock_khz(SWITCH_PICO_SYS_CLOCK_MHZ * 1000u, true);
|
||||
#endif
|
||||
g_status.requested_sys_khz = SWITCH_PICO_SYS_CLOCK_MHZ * 1000u;
|
||||
g_status.measured_sys_khz = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_CLK_SYS);
|
||||
g_status.measured_usb_khz = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_CLK_USB);
|
||||
// Regulator setting, not an externally calibrated voltage measurement.
|
||||
g_status.core_voltage_mv = 550u + 50u * vreg_get_voltage();
|
||||
g_status.flash_clock_divider = flash_divider;
|
||||
g_status.cyw43_pio_divider256 =
|
||||
CYW43_PIO_CLOCK_DIV_INT * 256u + CYW43_PIO_CLOCK_DIV_FRAC8;
|
||||
adc_init();
|
||||
adc_set_temp_sensor_enabled(true);
|
||||
adc_select_input(4);
|
||||
}
|
||||
|
||||
SystemClockStatus system_clock_status() {
|
||||
uint32_t sum = 0;
|
||||
for (unsigned sample = 0; sample < 8; ++sample) sum += adc_read();
|
||||
// Datasheet nominal transfer: 27 C - (Vadc - 0.706 V) / 0.001721.
|
||||
// The 3.3 V reference and sensor offset make this an estimate, not a
|
||||
// calibrated thermal qualification. Use signed integer microvolts.
|
||||
const int32_t microvolts = static_cast<int32_t>(
|
||||
(uint64_t{sum} * 3300000u + 16384u) / 32768u);
|
||||
g_status.temperature_millicelsius =
|
||||
27000 - static_cast<int32_t>((int64_t{microvolts - 706000} * 1000) / 1721);
|
||||
return g_status;
|
||||
}
|
||||
18
src/firmware/platform/pico/system_clock.h
Normal file
18
src/firmware/platform/pico/system_clock.h
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
struct SystemClockStatus {
|
||||
uint32_t requested_sys_khz;
|
||||
uint32_t measured_sys_khz;
|
||||
uint32_t measured_usb_khz;
|
||||
uint32_t core_voltage_mv;
|
||||
uint32_t flash_clock_divider;
|
||||
uint32_t cyw43_pio_divider256;
|
||||
int32_t temperature_millicelsius;
|
||||
};
|
||||
|
||||
// Core 0, before board/peripheral initialization and before launching core 1.
|
||||
void system_clock_initialize();
|
||||
// Core 0 only; reads the dedicated on-chip temperature ADC channel.
|
||||
SystemClockStatus system_clock_status();
|
||||
|
|
@ -17,6 +17,8 @@ constexpr uint8_t kMacroOverrideMask =
|
|||
kControllerProfileOverrideLeftTrigger |
|
||||
kControllerProfileOverrideRightTrigger;
|
||||
constexpr uint16_t kLegacyDefaultDigitalThreshold = 0x8000;
|
||||
constexpr uint32_t kLogicalControlMask =
|
||||
(1u << CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) - 1u;
|
||||
|
||||
uint16_t profile_read_u16(const uint8_t* input) {
|
||||
return static_cast<uint16_t>(input[0]) |
|
||||
|
|
@ -50,18 +52,21 @@ bool valid_button(uint8_t button) {
|
|||
button == CONTROLLER_PROFILE_NO_BUTTON;
|
||||
}
|
||||
|
||||
bool valid_macro_step(const ControllerProfileMacroStep& step,
|
||||
bool must_end) {
|
||||
if (must_end) {
|
||||
return step.type == ControllerProfileMacroStepType::kEnd &&
|
||||
step.override_flags == 0 && step.duration_ms == 0 &&
|
||||
step.output_button_mask == 0 && step.left_stick_x == 0 &&
|
||||
step.left_stick_y == 0 && step.right_stick_x == 0 &&
|
||||
step.right_stick_y == 0 && step.left_trigger == 0 &&
|
||||
step.right_trigger == 0;
|
||||
}
|
||||
if (step.type != ControllerProfileMacroStepType::kState ||
|
||||
(step.override_flags & ~kMacroOverrideMask) != 0 ||
|
||||
bool valid_control_output(uint8_t output) {
|
||||
return output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT ||
|
||||
output == CONTROLLER_PROFILE_NO_BUTTON;
|
||||
}
|
||||
|
||||
bool valid_turbo_settings(const ControllerProfileTurboSettings& settings) {
|
||||
return settings.rate_hz >= CONTROLLER_PROFILE_TURBO_RATE_MIN &&
|
||||
settings.rate_hz <= CONTROLLER_PROFILE_TURBO_RATE_MAX &&
|
||||
settings.duty_percent >= CONTROLLER_PROFILE_TURBO_DUTY_MIN &&
|
||||
settings.duty_percent <= CONTROLLER_PROFILE_TURBO_DUTY_MAX &&
|
||||
settings.burst_count >= CONTROLLER_PROFILE_TURBO_BURST_MIN;
|
||||
}
|
||||
|
||||
bool valid_macro_step(const ControllerProfileMacroStep& step) {
|
||||
if ((step.override_flags & ~kMacroOverrideMask) != 0 ||
|
||||
step.duration_ms > CONTROLLER_PROFILE_MAX_WAIT_MS) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -88,6 +93,112 @@ bool valid_macro_step(const ControllerProfileMacroStep& step,
|
|||
return true;
|
||||
}
|
||||
|
||||
size_t sparse_macro_step_size(const ControllerProfileMacroStep& step) {
|
||||
size_t size = 3;
|
||||
size += (step.override_flags & kControllerProfileOverrideButtons) != 0
|
||||
? 2
|
||||
: 0;
|
||||
size += (step.override_flags & kControllerProfileOverrideLeftStick) != 0
|
||||
? 4
|
||||
: 0;
|
||||
size += (step.override_flags & kControllerProfileOverrideRightStick) != 0
|
||||
? 4
|
||||
: 0;
|
||||
size += (step.override_flags & kControllerProfileOverrideLeftTrigger) != 0
|
||||
? 2
|
||||
: 0;
|
||||
size += (step.override_flags & kControllerProfileOverrideRightTrigger) != 0
|
||||
? 2
|
||||
: 0;
|
||||
return size;
|
||||
}
|
||||
|
||||
bool encode_sparse_macro_step(
|
||||
const ControllerProfileMacroStep& step, uint8_t* output,
|
||||
size_t output_size, size_t* encoded_size) {
|
||||
const size_t required = sparse_macro_step_size(step);
|
||||
if (!valid_macro_step(step) || output == nullptr ||
|
||||
encoded_size == nullptr || required > output_size) {
|
||||
return false;
|
||||
}
|
||||
size_t offset = 0;
|
||||
output[offset++] = step.override_flags;
|
||||
profile_write_u16(&output[offset], step.duration_ms);
|
||||
offset += 2;
|
||||
if ((step.override_flags & kControllerProfileOverrideButtons) != 0) {
|
||||
profile_write_u16(&output[offset], step.output_button_mask);
|
||||
offset += 2;
|
||||
}
|
||||
if ((step.override_flags & kControllerProfileOverrideLeftStick) != 0) {
|
||||
profile_write_i16(&output[offset], step.left_stick_x);
|
||||
profile_write_i16(&output[offset + 2], step.left_stick_y);
|
||||
offset += 4;
|
||||
}
|
||||
if ((step.override_flags & kControllerProfileOverrideRightStick) != 0) {
|
||||
profile_write_i16(&output[offset], step.right_stick_x);
|
||||
profile_write_i16(&output[offset + 2], step.right_stick_y);
|
||||
offset += 4;
|
||||
}
|
||||
if ((step.override_flags & kControllerProfileOverrideLeftTrigger) != 0) {
|
||||
profile_write_u16(&output[offset], step.left_trigger);
|
||||
offset += 2;
|
||||
}
|
||||
if ((step.override_flags & kControllerProfileOverrideRightTrigger) != 0) {
|
||||
profile_write_u16(&output[offset], step.right_trigger);
|
||||
offset += 2;
|
||||
}
|
||||
*encoded_size = offset;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool decode_sparse_macro_step(
|
||||
const uint8_t* input, size_t input_size,
|
||||
ControllerProfileMacroStep* output, size_t* decoded_size) {
|
||||
if (input == nullptr || output == nullptr || decoded_size == nullptr ||
|
||||
input_size < 3) {
|
||||
return false;
|
||||
}
|
||||
ControllerProfileMacroStep step{};
|
||||
step.override_flags = input[0];
|
||||
step.duration_ms = profile_read_u16(&input[1]);
|
||||
if (!valid_macro_step(step)) {
|
||||
return false;
|
||||
}
|
||||
const size_t required = sparse_macro_step_size(step);
|
||||
if (required > input_size) {
|
||||
return false;
|
||||
}
|
||||
size_t offset = 3;
|
||||
if ((step.override_flags & kControllerProfileOverrideButtons) != 0) {
|
||||
step.output_button_mask = profile_read_u16(&input[offset]);
|
||||
offset += 2;
|
||||
}
|
||||
if ((step.override_flags & kControllerProfileOverrideLeftStick) != 0) {
|
||||
step.left_stick_x = profile_read_i16(&input[offset]);
|
||||
step.left_stick_y = profile_read_i16(&input[offset + 2]);
|
||||
offset += 4;
|
||||
}
|
||||
if ((step.override_flags & kControllerProfileOverrideRightStick) != 0) {
|
||||
step.right_stick_x = profile_read_i16(&input[offset]);
|
||||
step.right_stick_y = profile_read_i16(&input[offset + 2]);
|
||||
offset += 4;
|
||||
}
|
||||
if ((step.override_flags & kControllerProfileOverrideLeftTrigger) != 0) {
|
||||
step.left_trigger = profile_read_u16(&input[offset]);
|
||||
offset += 2;
|
||||
}
|
||||
if ((step.override_flags & kControllerProfileOverrideRightTrigger) != 0) {
|
||||
step.right_trigger = profile_read_u16(&input[offset]);
|
||||
offset += 2;
|
||||
}
|
||||
if (!valid_macro_step(step)) {
|
||||
return false;
|
||||
}
|
||||
*output = step;
|
||||
*decoded_size = offset;
|
||||
return true;
|
||||
}
|
||||
|
||||
void copy_overlap(size_t range_offset, uint8_t* output,
|
||||
size_t output_size, size_t field_offset,
|
||||
const uint8_t* field, size_t field_size) {
|
||||
|
|
@ -140,66 +251,167 @@ ControllerProfile controller_profile_default(const ControllerIdentity& identity,
|
|||
stick.invert_x = false;
|
||||
stick.invert_y = false;
|
||||
}
|
||||
for (ControllerProfileTriggerConfiguration& trigger : profile.triggers) {
|
||||
for (uint8_t index = 0; index < 2; ++index) {
|
||||
ControllerProfileTriggerConfiguration& trigger =
|
||||
profile.triggers[index];
|
||||
trigger.lower_deadzone = 0;
|
||||
trigger.upper_saturation = UINT16_MAX;
|
||||
trigger.curve_q8_8 = 256;
|
||||
trigger.digital_threshold =
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
|
||||
trigger.output = static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL + index);
|
||||
}
|
||||
profile.weak_rumble_scale = UINT8_MAX;
|
||||
profile.strong_rumble_scale = UINT8_MAX;
|
||||
profile.confirmation_policy =
|
||||
ControllerProfileConfirmationPolicy::kRumbleAndLed;
|
||||
profile.switching_chord = 0;
|
||||
profile.macro_trigger = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
profile.macro_cancel = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
profile.macro_step_count = 1;
|
||||
profile.motion_toggle_chord = 0;
|
||||
for (ControllerProfileMacro& macro : profile.macros) {
|
||||
macro = {};
|
||||
macro.cancel_control = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
}
|
||||
profile.macro_step_count = 0;
|
||||
for (ControllerProfileMacroStep& step : profile.macro_steps) {
|
||||
step = {};
|
||||
step.type = ControllerProfileMacroStepType::kEnd;
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
bool controller_profile_validate(const ControllerProfile& profile) {
|
||||
for (uint8_t output : profile.button_map) {
|
||||
if (!valid_control_output(output)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!valid_control_output(profile.shortcuts.modifier) ||
|
||||
!valid_control_output(profile.shift.modifier) ||
|
||||
static_cast<uint8_t>(profile.shift.mode) >
|
||||
static_cast<uint8_t>(ControllerProfileShiftMode::kToggle) ||
|
||||
(profile.shift.mode != ControllerProfileShiftMode::kOff &&
|
||||
profile.shift.modifier == CONTROLLER_PROFILE_NO_BUTTON) ||
|
||||
!valid_turbo_settings(profile.turbo_defaults)) {
|
||||
return false;
|
||||
}
|
||||
uint16_t selectors = 0;
|
||||
for (uint8_t selector : profile.shortcuts.selectors) {
|
||||
if (selector == CONTROLLER_PROFILE_NO_BUTTON) {
|
||||
continue;
|
||||
}
|
||||
if (!(selector < 4 || (selector >= 12 && selector < 16)) ||
|
||||
selector == profile.shortcuts.modifier ||
|
||||
profile.shortcuts.modifier == CONTROLLER_PROFILE_NO_BUTTON ||
|
||||
(selectors & (1u << selector)) != 0) {
|
||||
return false;
|
||||
}
|
||||
selectors |= static_cast<uint16_t>(1u << selector);
|
||||
}
|
||||
for (uint8_t output : profile.shift.button_map) {
|
||||
if (!valid_button(output)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (uint8_t button = 0;
|
||||
button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++button) {
|
||||
if ((profile.turbo_override_mask & (1u << button)) != 0 &&
|
||||
!valid_turbo_settings(profile.turbo_overrides[button])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const ControllerProfileStickConfiguration& stick : profile.sticks) {
|
||||
if (stick.inner_deadzone >= stick.outer_saturation ||
|
||||
stick.outer_saturation > 32767 || stick.curve_q8_8 == 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
bool routed_triggers[2]{};
|
||||
for (const ControllerProfileTriggerConfiguration& trigger :
|
||||
profile.triggers) {
|
||||
if (trigger.lower_deadzone >= trigger.upper_saturation ||
|
||||
trigger.curve_q8_8 == 0) {
|
||||
trigger.curve_q8_8 == 0 ||
|
||||
!valid_control_output(trigger.output)) {
|
||||
return false;
|
||||
}
|
||||
if (trigger.output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
trigger.output <= CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL) {
|
||||
const uint8_t target = static_cast<uint8_t>(
|
||||
trigger.output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
|
||||
if (routed_triggers[target]) {
|
||||
return false;
|
||||
}
|
||||
routed_triggers[target] = true;
|
||||
}
|
||||
}
|
||||
if (static_cast<uint8_t>(profile.confirmation_policy) >
|
||||
static_cast<uint8_t>(
|
||||
ControllerProfileConfirmationPolicy::kRumbleAndLed) ||
|
||||
!valid_button(profile.macro_trigger) ||
|
||||
!valid_button(profile.macro_cancel) ||
|
||||
profile.macro_step_count == 0 ||
|
||||
(profile.switching_chord & ~kLogicalControlMask) != 0 ||
|
||||
(profile.motion_toggle_chord & ~kLogicalControlMask) != 0 ||
|
||||
profile.macro_step_count > CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) {
|
||||
return false;
|
||||
}
|
||||
for (ControllerProfileTurboMode mode : profile.turbo_modes) {
|
||||
if (static_cast<uint8_t>(mode) >
|
||||
static_cast<uint8_t>(ControllerProfileTurboMode::kAutoBurst)) {
|
||||
uint8_t expected_first_step = 0;
|
||||
size_t encoded_macro_size = 0;
|
||||
uint32_t trigger_masks[CONTROLLER_PROFILE_MACRO_COUNT]{};
|
||||
for (uint8_t macro_index = 0;
|
||||
macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) {
|
||||
const ControllerProfileMacro& macro =
|
||||
profile.macros[macro_index];
|
||||
if ((macro.trigger_mask & ~kLogicalControlMask) != 0 ||
|
||||
!valid_control_output(macro.cancel_control) ||
|
||||
static_cast<uint8_t>(macro.mode) >
|
||||
static_cast<uint8_t>(ControllerProfileMacroMode::kRepeat) ||
|
||||
macro.repeat_count == 0 ||
|
||||
macro.first_step != expected_first_step ||
|
||||
macro.step_count > CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO ||
|
||||
macro.step_count >
|
||||
profile.macro_step_count - expected_first_step) {
|
||||
return false;
|
||||
}
|
||||
for (uint8_t previous = 0; previous < macro_index; ++previous) {
|
||||
if (macro.trigger_mask != 0 &&
|
||||
trigger_masks[previous] == macro.trigger_mask) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
trigger_masks[macro_index] = macro.trigger_mask;
|
||||
uint32_t duration_ms = 0;
|
||||
for (uint8_t step = 0; step < macro.step_count; ++step) {
|
||||
const ControllerProfileMacroStep& value =
|
||||
profile.macro_steps[expected_first_step + step];
|
||||
if (!valid_macro_step(value)) {
|
||||
return false;
|
||||
}
|
||||
encoded_macro_size += sparse_macro_step_size(value);
|
||||
duration_ms += value.duration_ms;
|
||||
}
|
||||
if (macro.trigger_mask != 0 && macro.step_count != 0 &&
|
||||
macro.mode != ControllerProfileMacroMode::kOnce &&
|
||||
duration_ms == 0) {
|
||||
return false;
|
||||
}
|
||||
expected_first_step =
|
||||
static_cast<uint8_t>(expected_first_step + macro.step_count);
|
||||
}
|
||||
if (expected_first_step != profile.macro_step_count ||
|
||||
encoded_macro_size > CONTROLLER_PROFILE_MACRO_STREAM_SIZE) {
|
||||
return false;
|
||||
}
|
||||
for (uint8_t index = profile.macro_step_count;
|
||||
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
|
||||
const ControllerProfileMacroStep& step = profile.macro_steps[index];
|
||||
if (step.override_flags != 0 || step.duration_ms != 0 ||
|
||||
step.output_button_mask != 0 || step.left_stick_x != 0 ||
|
||||
step.left_stick_y != 0 || step.right_stick_x != 0 ||
|
||||
step.right_stick_y != 0 || step.left_trigger != 0 ||
|
||||
step.right_trigger != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
|
||||
const bool must_end = index >= profile.macro_step_count - 1;
|
||||
if (!valid_macro_step(profile.macro_steps[index], must_end)) {
|
||||
for (ControllerProfileTurboMode mode : profile.turbo_modes) {
|
||||
if (static_cast<uint8_t>(mode) >
|
||||
static_cast<uint8_t>(ControllerProfileTurboMode::kBurst)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -237,53 +449,125 @@ bool controller_profile_encode(const ControllerProfile& profile,
|
|||
profile_write_u16(&encoded[2], trigger.upper_saturation);
|
||||
profile_write_u16(&encoded[4], trigger.curve_q8_8);
|
||||
profile_write_u16(&encoded[6], trigger.digital_threshold);
|
||||
encoded[8] = trigger.output;
|
||||
}
|
||||
output[72] = profile.weak_rumble_scale;
|
||||
output[73] = profile.strong_rumble_scale;
|
||||
output[74] = static_cast<uint8_t>(profile.confirmation_policy);
|
||||
profile_write_u16(&output[76], profile.switching_chord);
|
||||
output[78] = profile.macro_trigger;
|
||||
output[79] = profile.macro_cancel;
|
||||
output[80] = profile.macro_step_count;
|
||||
output[75] = static_cast<uint8_t>(
|
||||
((profile.switching_chord >> 16) & 0x03u) |
|
||||
(((profile.motion_toggle_chord >> 16) & 0x03u) << 4));
|
||||
profile_write_u16(
|
||||
&output[76], static_cast<uint16_t>(profile.switching_chord));
|
||||
profile_write_u16(
|
||||
&output[78], static_cast<uint16_t>(profile.motion_toggle_chord));
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||
output[82 + index] =
|
||||
output[80 + index] =
|
||||
static_cast<uint8_t>(profile.turbo_modes[index]);
|
||||
}
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
|
||||
const ControllerProfileMacroStep& step = profile.macro_steps[index];
|
||||
uint8_t* encoded = &output[100 + index * 19];
|
||||
encoded[0] = static_cast<uint8_t>(step.type);
|
||||
encoded[1] = step.override_flags;
|
||||
profile_write_u16(&encoded[2], step.duration_ms);
|
||||
profile_write_u16(&encoded[4], step.output_button_mask);
|
||||
profile_write_i16(&encoded[6], step.left_stick_x);
|
||||
profile_write_i16(&encoded[8], step.left_stick_y);
|
||||
profile_write_i16(&encoded[10], step.right_stick_x);
|
||||
profile_write_i16(&encoded[12], step.right_stick_y);
|
||||
profile_write_u16(&encoded[14], step.left_trigger);
|
||||
profile_write_u16(&encoded[16], step.right_trigger);
|
||||
|
||||
size_t stream_offset = 0;
|
||||
for (uint8_t macro_index = 0;
|
||||
macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) {
|
||||
const ControllerProfileMacro& macro =
|
||||
profile.macros[macro_index];
|
||||
uint8_t* descriptor =
|
||||
&output[96 + macro_index *
|
||||
CONTROLLER_PROFILE_MACRO_DESCRIPTOR_SIZE];
|
||||
profile_write_u16(
|
||||
descriptor, static_cast<uint16_t>(macro.trigger_mask));
|
||||
const uint8_t cancel =
|
||||
macro.cancel_control == CONTROLLER_PROFILE_NO_BUTTON
|
||||
? 0x1fu
|
||||
: macro.cancel_control;
|
||||
descriptor[2] = static_cast<uint8_t>(
|
||||
((macro.trigger_mask >> 16) & 0x03u) |
|
||||
(cancel << 2));
|
||||
descriptor[3] = static_cast<uint8_t>(stream_offset);
|
||||
descriptor[4] = macro.step_count;
|
||||
const size_t macro_start = stream_offset;
|
||||
for (uint8_t step_index = 0;
|
||||
step_index < macro.step_count; ++step_index) {
|
||||
size_t encoded_size = 0;
|
||||
if (!encode_sparse_macro_step(
|
||||
profile.macro_steps[macro.first_step + step_index],
|
||||
&output[120 + stream_offset],
|
||||
CONTROLLER_PROFILE_MACRO_STREAM_SIZE - stream_offset,
|
||||
&encoded_size)) {
|
||||
return false;
|
||||
}
|
||||
stream_offset += encoded_size;
|
||||
}
|
||||
descriptor[5] =
|
||||
static_cast<uint8_t>(stream_offset - macro_start);
|
||||
output[336 + macro_index * 2] = static_cast<uint8_t>(macro.mode);
|
||||
output[337 + macro_index * 2] = macro.repeat_count;
|
||||
}
|
||||
return true;
|
||||
output[256] = profile.shortcuts.modifier;
|
||||
memcpy(&output[257], profile.shortcuts.selectors,
|
||||
CONTROLLER_PROFILE_COUNT);
|
||||
output[265] = static_cast<uint8_t>(profile.shift.mode);
|
||||
output[266] = profile.shift.modifier;
|
||||
memcpy(&output[267], profile.shift.button_map,
|
||||
CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT);
|
||||
output[283] = profile.turbo_defaults.rate_hz;
|
||||
output[284] = profile.turbo_defaults.duty_percent;
|
||||
output[285] = profile.turbo_defaults.burst_count;
|
||||
profile_write_u16(&output[286], profile.turbo_override_mask);
|
||||
size_t settings_offset = 288;
|
||||
for (uint8_t button = 0;
|
||||
button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++button) {
|
||||
if ((profile.turbo_override_mask & (1u << button)) != 0) {
|
||||
const ControllerProfileTurboSettings& settings =
|
||||
profile.turbo_overrides[button];
|
||||
output[settings_offset++] = settings.rate_hz;
|
||||
output[settings_offset++] = settings.duty_percent;
|
||||
output[settings_offset++] = settings.burst_count;
|
||||
}
|
||||
}
|
||||
return stream_offset <= CONTROLLER_PROFILE_MACRO_STREAM_SIZE;
|
||||
}
|
||||
|
||||
bool controller_profile_decode(const uint8_t* input, size_t input_size,
|
||||
ControllerProfile* output) {
|
||||
if (input == nullptr || output == nullptr ||
|
||||
input_size != CONTROLLER_PROFILE_ENCODED_SIZE) {
|
||||
(input_size != CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE &&
|
||||
input_size != CONTROLLER_PROFILE_ENCODED_SIZE)) {
|
||||
return false;
|
||||
}
|
||||
const uint16_t schema_version = profile_read_u16(&input[0]);
|
||||
if ((schema_version != CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION &&
|
||||
schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) ||
|
||||
profile_read_u16(&input[2]) != CONTROLLER_PROFILE_ENCODED_SIZE ||
|
||||
const size_t expected_size =
|
||||
schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION
|
||||
? CONTROLLER_PROFILE_ENCODED_SIZE
|
||||
: CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE;
|
||||
if (schema_version < CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION ||
|
||||
schema_version > CONTROLLER_PROFILE_SCHEMA_VERSION ||
|
||||
input_size != expected_size ||
|
||||
profile_read_u16(&input[2]) != expected_size ||
|
||||
!profile_bytes_are_zero(&input[31], 5) ||
|
||||
!profile_bytes_are_zero(&input[47], 5) ||
|
||||
!profile_bytes_are_zero(&input[60], 2) ||
|
||||
!profile_bytes_are_zero(&input[70], 2) || input[75] != 0 ||
|
||||
input[81] != 0 || !profile_bytes_are_zero(&input[98], 2) ||
|
||||
!profile_bytes_are_zero(&input[252], 4)) {
|
||||
!profile_bytes_are_zero(&input[47], 5)) {
|
||||
return false;
|
||||
}
|
||||
const bool has_control_mapping =
|
||||
schema_version >= CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION;
|
||||
const bool has_action_controls =
|
||||
schema_version >= CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION;
|
||||
const bool sparse_macros =
|
||||
schema_version >= CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION;
|
||||
if ((has_control_mapping
|
||||
? input[61] != 0 || input[71] != 0
|
||||
: !profile_bytes_are_zero(&input[60], 2) ||
|
||||
!profile_bytes_are_zero(&input[70], 2)) ||
|
||||
(sparse_macros
|
||||
? (input[75] & 0xccu) != 0
|
||||
: (has_action_controls ? (input[75] & 0xc0u) != 0
|
||||
: input[75] != 0)) ||
|
||||
(!has_control_mapping &&
|
||||
(input[81] != 0 ||
|
||||
!profile_bytes_are_zero(&input[98], 2))) ||
|
||||
(!sparse_macros &&
|
||||
!profile_bytes_are_zero(&input[252], 4))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -293,7 +577,8 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
|
|||
for (uint8_t index = 0; index < 2; ++index) {
|
||||
ControllerProfileStickConfiguration& stick = profile.sticks[index];
|
||||
const uint8_t* encoded = &input[20 + index * 16];
|
||||
if ((encoded[10] & ~(kStickInvertX | kStickInvertY)) != 0) {
|
||||
if ((encoded[10] & ~(kStickInvertX | kStickInvertY)) != 0 ||
|
||||
!profile_bytes_are_zero(&encoded[11], 5)) {
|
||||
return false;
|
||||
}
|
||||
stick.center_x = profile_read_i16(&encoded[0]);
|
||||
|
|
@ -312,6 +597,11 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
|
|||
trigger.upper_saturation = profile_read_u16(&encoded[2]);
|
||||
trigger.curve_q8_8 = profile_read_u16(&encoded[4]);
|
||||
trigger.digital_threshold = profile_read_u16(&encoded[6]);
|
||||
trigger.output =
|
||||
has_control_mapping
|
||||
? encoded[8]
|
||||
: static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL + index);
|
||||
if (schema_version == CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION &&
|
||||
trigger.digital_threshold == kLegacyDefaultDigitalThreshold) {
|
||||
trigger.digital_threshold =
|
||||
|
|
@ -322,32 +612,188 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
|
|||
profile.strong_rumble_scale = input[73];
|
||||
profile.confirmation_policy =
|
||||
static_cast<ControllerProfileConfirmationPolicy>(input[74]);
|
||||
profile.switching_chord = profile_read_u16(&input[76]);
|
||||
profile.macro_trigger = input[78];
|
||||
profile.macro_cancel = input[79];
|
||||
profile.macro_step_count = input[80];
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||
profile.turbo_modes[index] =
|
||||
static_cast<ControllerProfileTurboMode>(input[82 + index]);
|
||||
}
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
|
||||
ControllerProfileMacroStep& step = profile.macro_steps[index];
|
||||
const uint8_t* encoded = &input[100 + index * 19];
|
||||
if (encoded[18] != 0) {
|
||||
|
||||
if (sparse_macros) {
|
||||
profile.switching_chord =
|
||||
profile_read_u16(&input[76]) |
|
||||
(static_cast<uint32_t>(input[75] & 0x03u) << 16);
|
||||
profile.motion_toggle_chord =
|
||||
profile_read_u16(&input[78]) |
|
||||
(static_cast<uint32_t>((input[75] >> 4) & 0x03u) << 16);
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||
profile.turbo_modes[index] =
|
||||
static_cast<ControllerProfileTurboMode>(input[80 + index]);
|
||||
}
|
||||
|
||||
size_t expected_stream_offset = 0;
|
||||
uint8_t decoded_step_count = 0;
|
||||
for (uint8_t macro_index = 0;
|
||||
macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) {
|
||||
const uint8_t* descriptor =
|
||||
&input[96 + macro_index *
|
||||
CONTROLLER_PROFILE_MACRO_DESCRIPTOR_SIZE];
|
||||
if ((descriptor[2] & 0x80u) != 0 ||
|
||||
descriptor[3] != expected_stream_offset ||
|
||||
descriptor[4] >
|
||||
CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO ||
|
||||
expected_stream_offset + descriptor[5] >
|
||||
CONTROLLER_PROFILE_MACRO_STREAM_SIZE ||
|
||||
decoded_step_count + descriptor[4] >
|
||||
CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) {
|
||||
return false;
|
||||
}
|
||||
ControllerProfileMacro& macro = profile.macros[macro_index];
|
||||
macro.trigger_mask =
|
||||
profile_read_u16(descriptor) |
|
||||
(static_cast<uint32_t>(descriptor[2] & 0x03u) << 16);
|
||||
const uint8_t cancel =
|
||||
static_cast<uint8_t>((descriptor[2] >> 2) & 0x1fu);
|
||||
if (cancel > CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
|
||||
cancel != 0x1fu) {
|
||||
return false;
|
||||
}
|
||||
macro.cancel_control =
|
||||
cancel == 0x1fu ? CONTROLLER_PROFILE_NO_BUTTON : cancel;
|
||||
macro.first_step = decoded_step_count;
|
||||
macro.step_count = descriptor[4];
|
||||
if (schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION) {
|
||||
macro.mode = static_cast<ControllerProfileMacroMode>(
|
||||
input[336 + macro_index * 2]);
|
||||
macro.repeat_count = input[337 + macro_index * 2];
|
||||
}
|
||||
|
||||
size_t consumed = 0;
|
||||
for (uint8_t step_index = 0;
|
||||
step_index < macro.step_count; ++step_index) {
|
||||
size_t step_size = 0;
|
||||
if (!decode_sparse_macro_step(
|
||||
&input[120 + expected_stream_offset + consumed],
|
||||
descriptor[5] - consumed,
|
||||
&profile.macro_steps[decoded_step_count],
|
||||
&step_size)) {
|
||||
return false;
|
||||
}
|
||||
consumed += step_size;
|
||||
++decoded_step_count;
|
||||
}
|
||||
if (consumed != descriptor[5]) {
|
||||
return false;
|
||||
}
|
||||
expected_stream_offset += consumed;
|
||||
}
|
||||
if (!profile_bytes_are_zero(
|
||||
&input[120 + expected_stream_offset],
|
||||
CONTROLLER_PROFILE_MACRO_STREAM_SIZE -
|
||||
expected_stream_offset)) {
|
||||
return false;
|
||||
}
|
||||
step.type = static_cast<ControllerProfileMacroStepType>(encoded[0]);
|
||||
step.override_flags = encoded[1];
|
||||
step.duration_ms = profile_read_u16(&encoded[2]);
|
||||
step.output_button_mask = profile_read_u16(&encoded[4]);
|
||||
step.left_stick_x = profile_read_i16(&encoded[6]);
|
||||
step.left_stick_y = profile_read_i16(&encoded[8]);
|
||||
step.right_stick_x = profile_read_i16(&encoded[10]);
|
||||
step.right_stick_y = profile_read_i16(&encoded[12]);
|
||||
step.left_trigger = profile_read_u16(&encoded[14]);
|
||||
step.right_trigger = profile_read_u16(&encoded[16]);
|
||||
profile.macro_step_count = decoded_step_count;
|
||||
} else {
|
||||
profile.switching_chord = profile_read_u16(&input[76]);
|
||||
profile.motion_toggle_chord =
|
||||
has_control_mapping ? profile_read_u16(&input[98]) : 0;
|
||||
ControllerProfileMacro& macro = profile.macros[0];
|
||||
if (has_control_mapping) {
|
||||
macro.trigger_mask = profile_read_u16(&input[78]);
|
||||
macro.cancel_control = input[81];
|
||||
} else {
|
||||
if (!valid_button(input[78])) {
|
||||
return false;
|
||||
}
|
||||
macro.trigger_mask =
|
||||
input[78] < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT
|
||||
? static_cast<uint32_t>(1u << input[78])
|
||||
: 0;
|
||||
macro.cancel_control = input[79];
|
||||
}
|
||||
if (has_action_controls) {
|
||||
profile.switching_chord |=
|
||||
static_cast<uint32_t>(input[75] & 0x03u) << 16;
|
||||
macro.trigger_mask |=
|
||||
static_cast<uint32_t>((input[75] >> 2) & 0x03u) << 16;
|
||||
profile.motion_toggle_chord |=
|
||||
static_cast<uint32_t>((input[75] >> 4) & 0x03u) << 16;
|
||||
}
|
||||
const uint8_t legacy_step_count = input[80];
|
||||
if (legacy_step_count == 0 ||
|
||||
legacy_step_count >
|
||||
CONTROLLER_PROFILE_LEGACY_MACRO_STEP_CAPACITY) {
|
||||
return false;
|
||||
}
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_LEGACY_MACRO_STEP_CAPACITY;
|
||||
++index) {
|
||||
const uint8_t* encoded = &input[100 + index * 19];
|
||||
const bool must_end = index >= legacy_step_count - 1;
|
||||
if (must_end) {
|
||||
if (encoded[0] != 1 ||
|
||||
!profile_bytes_are_zero(&encoded[1], 18)) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (encoded[0] != 0 || encoded[18] != 0) {
|
||||
return false;
|
||||
}
|
||||
ControllerProfileMacroStep& step = profile.macro_steps[index];
|
||||
step.override_flags = encoded[1];
|
||||
step.duration_ms = profile_read_u16(&encoded[2]);
|
||||
step.output_button_mask = profile_read_u16(&encoded[4]);
|
||||
step.left_stick_x = profile_read_i16(&encoded[6]);
|
||||
step.left_stick_y = profile_read_i16(&encoded[8]);
|
||||
step.right_stick_x = profile_read_i16(&encoded[10]);
|
||||
step.right_stick_y = profile_read_i16(&encoded[12]);
|
||||
step.left_trigger = profile_read_u16(&encoded[14]);
|
||||
step.right_trigger = profile_read_u16(&encoded[16]);
|
||||
if (!valid_macro_step(step)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
macro.first_step = 0;
|
||||
macro.step_count = static_cast<uint8_t>(legacy_step_count - 1u);
|
||||
profile.macro_step_count = macro.step_count;
|
||||
for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) {
|
||||
profile.macros[index].first_step = profile.macro_step_count;
|
||||
}
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||
profile.turbo_modes[index] =
|
||||
static_cast<ControllerProfileTurboMode>(input[82 + index]);
|
||||
}
|
||||
}
|
||||
if (schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION) {
|
||||
profile.shortcuts.modifier = input[256];
|
||||
memcpy(profile.shortcuts.selectors, &input[257],
|
||||
CONTROLLER_PROFILE_COUNT);
|
||||
profile.shift.mode = static_cast<ControllerProfileShiftMode>(input[265]);
|
||||
profile.shift.modifier = input[266];
|
||||
memcpy(profile.shift.button_map, &input[267],
|
||||
CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT);
|
||||
profile.turbo_defaults = {input[283], input[284], input[285]};
|
||||
profile.turbo_override_mask = profile_read_u16(&input[286]);
|
||||
size_t settings_offset = 288;
|
||||
for (uint8_t button = 0;
|
||||
button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++button) {
|
||||
if ((profile.turbo_override_mask & (1u << button)) != 0) {
|
||||
profile.turbo_overrides[button] = {
|
||||
input[settings_offset], input[settings_offset + 1],
|
||||
input[settings_offset + 2]};
|
||||
settings_offset += 3;
|
||||
}
|
||||
}
|
||||
if (!profile_bytes_are_zero(
|
||||
&input[settings_offset], 336 - settings_offset) ||
|
||||
!profile_bytes_are_zero(&input[344], 40)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
for (ControllerProfileTurboMode mode : profile.turbo_modes) {
|
||||
if (static_cast<uint8_t>(mode) >
|
||||
static_cast<uint8_t>(ControllerProfileTurboMode::kAutoBurst)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!controller_profile_validate(profile)) {
|
||||
return false;
|
||||
|
|
@ -503,15 +949,28 @@ bool controller_profile_database_decode(
|
|||
return false;
|
||||
}
|
||||
const uint16_t schema_version = profile_read_u16(&header[4]);
|
||||
const bool legacy =
|
||||
schema_version < CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION;
|
||||
const uint8_t profile_count = header[9];
|
||||
const size_t profile_size = legacy
|
||||
? CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE
|
||||
: CONTROLLER_PROFILE_ENCODED_SIZE;
|
||||
const size_t entry_size =
|
||||
CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE +
|
||||
profile_count * profile_size;
|
||||
const size_t entries_offset =
|
||||
kFallbackOffset + profile_count * profile_size;
|
||||
const size_t encoded_size =
|
||||
entries_offset + CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY * entry_size;
|
||||
if (memcmp(header, kDatabaseMagic, sizeof(kDatabaseMagic)) != 0 ||
|
||||
(schema_version !=
|
||||
CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION &&
|
||||
schema_version != CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION) ||
|
||||
profile_read_u16(&header[6]) !=
|
||||
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
|
||||
schema_version < CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION ||
|
||||
schema_version > CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION ||
|
||||
profile_read_u16(&header[6]) != encoded_size ||
|
||||
header[8] != CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY ||
|
||||
header[9] != CONTROLLER_PROFILE_COUNT ||
|
||||
header[10] >= CONTROLLER_PROFILE_COUNT ||
|
||||
(profile_count != CONTROLLER_PROFILE_COUNT &&
|
||||
!(schema_version == CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION &&
|
||||
profile_count == 4)) ||
|
||||
header[10] >= profile_count ||
|
||||
header[11] > CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY ||
|
||||
!profile_bytes_are_zero(&header[12], sizeof(header) - 12)) {
|
||||
return false;
|
||||
|
|
@ -521,14 +980,14 @@ bool controller_profile_database_decode(
|
|||
output->fallback_active_profile = header[10];
|
||||
uint8_t encoded_profile[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
for (uint8_t profile_index = 0;
|
||||
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
|
||||
profile_index < profile_count; ++profile_index) {
|
||||
const size_t profile_offset =
|
||||
kFallbackOffset +
|
||||
profile_index * CONTROLLER_PROFILE_ENCODED_SIZE;
|
||||
profile_index * profile_size;
|
||||
if (!read(context, profile_offset, encoded_profile,
|
||||
sizeof(encoded_profile)) ||
|
||||
profile_size) ||
|
||||
!controller_profile_decode(
|
||||
encoded_profile, sizeof(encoded_profile),
|
||||
encoded_profile, profile_size,
|
||||
&output->fallback_profiles[profile_index])) {
|
||||
controller_profile_database_default(output);
|
||||
return false;
|
||||
|
|
@ -540,7 +999,7 @@ bool controller_profile_database_decode(
|
|||
entry_index < CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY;
|
||||
++entry_index) {
|
||||
const size_t entry_offset =
|
||||
kEntriesOffset + entry_index * CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE;
|
||||
entries_offset + entry_index * entry_size;
|
||||
uint8_t entry_header[CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE]{};
|
||||
if (!read(context, entry_offset, entry_header,
|
||||
sizeof(entry_header))) {
|
||||
|
|
@ -552,15 +1011,14 @@ bool controller_profile_database_decode(
|
|||
!read_zero_region(
|
||||
read, context,
|
||||
entry_offset + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE,
|
||||
CONTROLLER_PROFILE_COUNT *
|
||||
CONTROLLER_PROFILE_ENCODED_SIZE)) {
|
||||
profile_count * profile_size)) {
|
||||
controller_profile_database_default(output);
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry_header[15] != 1 ||
|
||||
entry_header[14] >= CONTROLLER_PROFILE_COUNT) {
|
||||
entry_header[14] >= profile_count) {
|
||||
controller_profile_database_default(output);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -577,14 +1035,14 @@ bool controller_profile_database_decode(
|
|||
entry.active_profile = entry_header[14];
|
||||
++decoded_used_count;
|
||||
for (uint8_t profile_index = 0;
|
||||
profile_index < CONTROLLER_PROFILE_COUNT; ++profile_index) {
|
||||
profile_index < profile_count; ++profile_index) {
|
||||
const size_t profile_offset =
|
||||
entry_offset + CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE +
|
||||
profile_index * CONTROLLER_PROFILE_ENCODED_SIZE;
|
||||
profile_index * profile_size;
|
||||
if (!read(context, profile_offset, encoded_profile,
|
||||
sizeof(encoded_profile)) ||
|
||||
profile_size) ||
|
||||
!controller_profile_decode(encoded_profile,
|
||||
sizeof(encoded_profile),
|
||||
profile_size,
|
||||
&entry.profiles[profile_index])) {
|
||||
controller_profile_database_default(output);
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -6,22 +6,49 @@
|
|||
#include "core/controller_identity.h"
|
||||
|
||||
constexpr uint16_t CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION = 1;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 2;
|
||||
constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 256;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_COUNT = 4;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 6;
|
||||
constexpr size_t CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE = 256;
|
||||
constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 384;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_COUNT = 8;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEP_CAPACITY = 8;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 18;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL = 16;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL = 17;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_MACRO_COUNT = 4;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEP_CAPACITY = 16;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO = 8;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_LEGACY_MACRO_STEP_CAPACITY = 8;
|
||||
constexpr size_t CONTROLLER_PROFILE_MACRO_DESCRIPTOR_SIZE = 6;
|
||||
constexpr size_t CONTROLLER_PROFILE_MACRO_STREAM_SIZE = 136;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_MAX_WAIT_MS = 10000;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_NO_BUTTON = 0xff;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_ALL = 0xff;
|
||||
// Exact 16-bit counterpart of the existing 358-of-1023 Switch boundary.
|
||||
constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_TURBO_RATE_MIN = 1;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_TURBO_RATE_MAX = 30;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_TURBO_DUTY_MIN = 1;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_TURBO_DUTY_MAX = 99;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_TURBO_BURST_MIN = 1;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_TURBO_BURST_MAX = 255;
|
||||
|
||||
constexpr uint8_t CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY = 16;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_DATABASE_LEGACY_SCHEMA_VERSION = 1;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION = 2;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION = 3;
|
||||
constexpr size_t CONTROLLER_PROFILE_DATABASE_HEADER_SIZE = 32;
|
||||
constexpr size_t CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE = 16;
|
||||
constexpr size_t CONTROLLER_PROFILE_DATABASE_LEGACY_ENTRY_SIZE =
|
||||
CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE +
|
||||
CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE;
|
||||
constexpr size_t CONTROLLER_PROFILE_DATABASE_LEGACY_ENCODED_SIZE =
|
||||
CONTROLLER_PROFILE_DATABASE_HEADER_SIZE +
|
||||
CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE +
|
||||
CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY *
|
||||
CONTROLLER_PROFILE_DATABASE_LEGACY_ENTRY_SIZE;
|
||||
constexpr size_t CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE =
|
||||
CONTROLLER_PROFILE_DATABASE_ENTRY_HEADER_SIZE +
|
||||
CONTROLLER_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE;
|
||||
|
|
@ -31,7 +58,9 @@ constexpr size_t CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE =
|
|||
CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY *
|
||||
CONTROLLER_PROFILE_DATABASE_ENTRY_SIZE;
|
||||
|
||||
static_assert(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE == 17696,
|
||||
static_assert(CONTROLLER_PROFILE_DATABASE_LEGACY_ENCODED_SIZE == 35104,
|
||||
"legacy profile database wire size changed");
|
||||
static_assert(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE == 52512,
|
||||
"profile database wire size changed");
|
||||
enum class ControllerProfileLogicalButton : uint8_t {
|
||||
kSouth = 0,
|
||||
|
|
@ -67,13 +96,42 @@ enum class ControllerProfileTurboMode : uint8_t {
|
|||
kOff = 0,
|
||||
kTurbo = 1,
|
||||
kAutoBurst = 2,
|
||||
kBurst = 3,
|
||||
};
|
||||
|
||||
enum class ControllerProfileMacroStepType : uint8_t {
|
||||
kState = 0,
|
||||
kEnd = 1,
|
||||
enum class ControllerProfileShiftMode : uint8_t {
|
||||
kOff = 0,
|
||||
kHold = 1,
|
||||
kToggle = 2,
|
||||
};
|
||||
|
||||
enum class ControllerProfileMacroMode : uint8_t {
|
||||
kOnce = 0,
|
||||
kWhileHeld = 1,
|
||||
kToggle = 2,
|
||||
kRepeat = 3,
|
||||
};
|
||||
|
||||
struct ControllerProfileShortcutConfiguration {
|
||||
uint8_t modifier = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
uint8_t selectors[CONTROLLER_PROFILE_COUNT]{
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff};
|
||||
};
|
||||
|
||||
struct ControllerProfileShiftConfiguration {
|
||||
ControllerProfileShiftMode mode = ControllerProfileShiftMode::kOff;
|
||||
uint8_t modifier = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15};
|
||||
};
|
||||
|
||||
struct ControllerProfileTurboSettings {
|
||||
uint8_t rate_hz = 15;
|
||||
uint8_t duty_percent = 50;
|
||||
uint8_t burst_count = 3;
|
||||
};
|
||||
|
||||
|
||||
enum ControllerProfileMacroOverride : uint8_t {
|
||||
kControllerProfileOverrideButtons = 1u << 0,
|
||||
kControllerProfileOverrideLeftStick = 1u << 1,
|
||||
|
|
@ -99,11 +157,11 @@ struct ControllerProfileTriggerConfiguration {
|
|||
// Compared against the transformed uint16 trigger output.
|
||||
uint16_t digital_threshold =
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
|
||||
// Logical control output (button, analog trigger, or NO_BUTTON).
|
||||
uint8_t output = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
};
|
||||
|
||||
struct ControllerProfileMacroStep {
|
||||
ControllerProfileMacroStepType type =
|
||||
ControllerProfileMacroStepType::kEnd;
|
||||
uint8_t override_flags = 0;
|
||||
uint16_t duration_ms = 0;
|
||||
uint16_t output_button_mask = 0;
|
||||
|
|
@ -115,6 +173,15 @@ struct ControllerProfileMacroStep {
|
|||
uint16_t right_trigger = 0;
|
||||
};
|
||||
|
||||
struct ControllerProfileMacro {
|
||||
uint32_t trigger_mask = 0;
|
||||
uint8_t cancel_control = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
uint8_t first_step = 0;
|
||||
uint8_t step_count = 0;
|
||||
ControllerProfileMacroMode mode = ControllerProfileMacroMode::kOnce;
|
||||
uint8_t repeat_count = 1;
|
||||
};
|
||||
|
||||
struct ControllerProfile {
|
||||
uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{};
|
||||
ControllerProfileStickConfiguration sticks[2]{};
|
||||
|
|
@ -123,14 +190,20 @@ struct ControllerProfile {
|
|||
uint8_t strong_rumble_scale = UINT8_MAX;
|
||||
ControllerProfileConfirmationPolicy confirmation_policy =
|
||||
ControllerProfileConfirmationPolicy::kRumbleAndLed;
|
||||
uint16_t switching_chord = 0;
|
||||
uint8_t macro_trigger = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
uint8_t macro_cancel = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
uint8_t macro_step_count = 1;
|
||||
uint32_t switching_chord = 0;
|
||||
uint32_t motion_toggle_chord = 0;
|
||||
ControllerProfileMacro macros[CONTROLLER_PROFILE_MACRO_COUNT]{};
|
||||
uint8_t macro_step_count = 0;
|
||||
ControllerProfileTurboMode
|
||||
turbo_modes[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{};
|
||||
ControllerProfileMacroStep
|
||||
macro_steps[CONTROLLER_PROFILE_MACRO_STEP_CAPACITY]{};
|
||||
ControllerProfileShortcutConfiguration shortcuts{};
|
||||
ControllerProfileShiftConfiguration shift{};
|
||||
ControllerProfileTurboSettings turbo_defaults{};
|
||||
uint16_t turbo_override_mask = 0;
|
||||
ControllerProfileTurboSettings
|
||||
turbo_overrides[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{};
|
||||
};
|
||||
|
||||
struct ControllerProfileDatabaseEntry {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ namespace {
|
|||
|
||||
struct ControllerProfileRuntimeContext {
|
||||
bool active = false;
|
||||
bool connection_seen = false;
|
||||
uint32_t connection_generation = 0;
|
||||
ControllerIdentity identity{};
|
||||
uint32_t database_generation = 0;
|
||||
|
|
@ -18,12 +19,13 @@ struct ControllerProfileRuntimeContext {
|
|||
bool runtime_generations_initialized = false;
|
||||
AdapterUsbMode output_mode = AdapterUsbMode::kSwitchProbe;
|
||||
uint32_t configuration_reset_generation = 0;
|
||||
bool switching_chord_held = false;
|
||||
bool switching_chord_armed = true;
|
||||
bool switching_activation_requested = false;
|
||||
uint16_t held_switching_chord = 0;
|
||||
uint8_t switching_target_profile_index = 0;
|
||||
uint32_t switching_transaction_id = 0;
|
||||
uint32_t previous_hotkey_control_mask = 0;
|
||||
uint32_t held_hotkey_chord = 0;
|
||||
uint32_t held_hotkey_release_mask = 0;
|
||||
uint32_t held_shortcut_selectors = 0;
|
||||
bool activation_requested = true;
|
||||
uint8_t target_profile_index = 0;
|
||||
uint32_t activation_transaction_id = 0;
|
||||
bool profile_change_pending = false;
|
||||
ControllerProfileRuntimeProfileChangeEvent pending_profile_change{};
|
||||
bool initial_profile_indication_resolved = false;
|
||||
|
|
@ -49,12 +51,33 @@ uint32_t next_activation_transaction_id() {
|
|||
return transaction_id;
|
||||
}
|
||||
|
||||
uint16_t effective_switching_chord(const ControllerProfile& profile) {
|
||||
uint32_t effective_switching_chord(const ControllerProfile& profile) {
|
||||
return profile.switching_chord == 0
|
||||
? CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD
|
||||
: profile.switching_chord;
|
||||
}
|
||||
|
||||
uint32_t effective_motion_toggle_chord(
|
||||
const ControllerProfile& profile) {
|
||||
return profile.motion_toggle_chord == 0
|
||||
? CONTROLLER_PROFILE_DEFAULT_MOTION_TOGGLE_CHORD
|
||||
: profile.motion_toggle_chord;
|
||||
}
|
||||
|
||||
bool chord_held(uint32_t controls, uint32_t chord) {
|
||||
return chord != 0 && (controls & chord) == chord;
|
||||
}
|
||||
|
||||
uint32_t shortcut_selector_mask(const ControllerProfile& profile) {
|
||||
uint32_t mask = 0;
|
||||
for (const uint8_t selector : profile.shortcuts.selectors) {
|
||||
if (selector < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) {
|
||||
mask |= 1u << selector;
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
void initialize_defaults() {
|
||||
if (g_initialized) {
|
||||
return;
|
||||
|
|
@ -77,7 +100,7 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
|
|||
const ControllerIdentity& identity,
|
||||
uint32_t connection_generation,
|
||||
uint32_t observed_database_generation,
|
||||
uint16_t current_input_button_mask) {
|
||||
const Bluepad32SlotSnapshot& input_snapshot) {
|
||||
ProfileServiceActiveProfileSnapshot snapshot{};
|
||||
profile_service_active_profile_snapshot(identity, &snapshot);
|
||||
|
||||
|
|
@ -91,14 +114,15 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
|
|||
context->active_profile_index;
|
||||
const bool previous_profile_valid =
|
||||
context->profile_snapshot_valid;
|
||||
const bool had_connection = context->connection_seen;
|
||||
if (!same_connection) {
|
||||
context->runtime_generations_initialized = false;
|
||||
context->switching_chord_held = false;
|
||||
context->switching_chord_armed = true;
|
||||
context->switching_activation_requested = false;
|
||||
context->held_switching_chord = 0;
|
||||
context->switching_target_profile_index = 0;
|
||||
context->switching_transaction_id = 0;
|
||||
context->previous_hotkey_control_mask = 0;
|
||||
context->held_hotkey_chord = 0;
|
||||
context->held_hotkey_release_mask = 0;
|
||||
context->held_shortcut_selectors = 0;
|
||||
context->activation_requested = true;
|
||||
context->activation_transaction_id = 0;
|
||||
context->profile_change_pending = false;
|
||||
context->pending_profile_change = {};
|
||||
context->initial_profile_indication_resolved = false;
|
||||
|
|
@ -106,9 +130,8 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
|
|||
context->pending_initial_profile_indication = {};
|
||||
}
|
||||
|
||||
controller_synthetic_input_cancel(&context->synthetic,
|
||||
current_input_button_mask);
|
||||
context->active = true;
|
||||
context->connection_seen = true;
|
||||
context->connection_generation = connection_generation;
|
||||
context->identity = identity;
|
||||
context->database_generation = snapshot.valid
|
||||
|
|
@ -117,6 +140,19 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
|
|||
context->active_profile_index = snapshot.valid ? snapshot.profile_index : 0;
|
||||
context->profile_snapshot_valid = snapshot.valid;
|
||||
context->profile = snapshot.valid ? snapshot.profile : g_default_profile;
|
||||
const uint32_t current_input_control_mask =
|
||||
controller_profile_extract_control_mask(
|
||||
input_snapshot.state, context->profile);
|
||||
controller_synthetic_input_cancel(
|
||||
&context->synthetic, current_input_control_mask);
|
||||
const uint32_t hotkey_control_mask =
|
||||
(current_input_control_mask & ~0xffffu) |
|
||||
input_snapshot.pre_hotkey_button_mask;
|
||||
// Refreshes must not turn a held input into a fresh command. The first
|
||||
// connection retains the existing ability to start a held cycle chord.
|
||||
context->previous_hotkey_control_mask =
|
||||
had_connection ? hotkey_control_mask :
|
||||
hotkey_control_mask & effective_motion_toggle_chord(context->profile);
|
||||
if (!context->initial_profile_indication_resolved && snapshot.valid) {
|
||||
context->initial_profile_indication_resolved = true;
|
||||
const uint8_t policy = static_cast<uint8_t>(
|
||||
|
|
@ -132,13 +168,8 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
|
|||
context->initial_profile_indication_pending = true;
|
||||
}
|
||||
}
|
||||
if (context->switching_chord_held &&
|
||||
!context->switching_activation_requested) {
|
||||
context->switching_target_profile_index =
|
||||
static_cast<uint8_t>(
|
||||
(context->active_profile_index + 1u) %
|
||||
CONTROLLER_PROFILE_COUNT);
|
||||
}
|
||||
// A pending target and its consuming chord belong to the observation that
|
||||
// started them, not to the newly loaded profile's bindings.
|
||||
if (same_identity && previous_profile_valid && snapshot.valid &&
|
||||
previous_profile_index != context->active_profile_index) {
|
||||
context->pending_profile_change = {
|
||||
|
|
@ -161,6 +192,7 @@ ControllerProfileRuntimeContext* update_context(
|
|||
if (!snapshot.active) {
|
||||
if (context.active) {
|
||||
clear_context(&context);
|
||||
context.connection_seen = true;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
|
@ -173,81 +205,107 @@ ControllerProfileRuntimeContext* update_context(
|
|||
context.database_generation != database_generation) {
|
||||
refresh_profile(
|
||||
&context, snapshot.identity, snapshot.connection_generation,
|
||||
database_generation,
|
||||
controller_profile_extract_button_mask(snapshot.state));
|
||||
database_generation, snapshot);
|
||||
}
|
||||
return &context;
|
||||
}
|
||||
void process_profile_switching(
|
||||
ControllerProfileRuntimeContext* context,
|
||||
const ControllerIdentity& identity,
|
||||
uint16_t switching_input_button_mask,
|
||||
uint16_t current_input_button_mask,
|
||||
ControllerState* consumed_input) {
|
||||
if (context == nullptr || consumed_input == nullptr) {
|
||||
void request_activation(ControllerProfileRuntimeContext* context,
|
||||
const ControllerIdentity& identity) {
|
||||
if (context->activation_requested) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context->switching_chord_held) {
|
||||
if ((switching_input_button_mask &
|
||||
context->held_switching_chord) ==
|
||||
context->held_switching_chord) {
|
||||
controller_profile_apply_button_mask(
|
||||
static_cast<uint16_t>(
|
||||
current_input_button_mask &
|
||||
~context->held_switching_chord),
|
||||
consumed_input);
|
||||
if (!context->switching_activation_requested) {
|
||||
const ConfigurationTransactionStatus status =
|
||||
profile_service_activate_internal(
|
||||
context->switching_transaction_id, identity,
|
||||
context->switching_target_profile_index);
|
||||
if (status != ConfigurationTransactionStatus::kBusy) {
|
||||
context->switching_activation_requested = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
context->switching_chord_held = false;
|
||||
context->switching_activation_requested = false;
|
||||
context->held_switching_chord = 0;
|
||||
context->switching_transaction_id = 0;
|
||||
}
|
||||
|
||||
const uint16_t chord =
|
||||
effective_switching_chord(context->profile);
|
||||
const bool chord_fully_held =
|
||||
(switching_input_button_mask & chord) == chord;
|
||||
if (!chord_fully_held) {
|
||||
context->switching_chord_armed = true;
|
||||
if (context->target_profile_index == context->active_profile_index) {
|
||||
context->activation_requested = true;
|
||||
return;
|
||||
}
|
||||
if (!context->switching_chord_armed) {
|
||||
return;
|
||||
}
|
||||
|
||||
context->switching_chord_armed = false;
|
||||
context->switching_chord_held = true;
|
||||
context->held_switching_chord = chord;
|
||||
context->switching_target_profile_index =
|
||||
static_cast<uint8_t>(
|
||||
(context->active_profile_index + 1u) %
|
||||
CONTROLLER_PROFILE_COUNT);
|
||||
context->switching_transaction_id =
|
||||
next_activation_transaction_id();
|
||||
controller_profile_apply_button_mask(
|
||||
static_cast<uint16_t>(
|
||||
current_input_button_mask & ~context->held_switching_chord),
|
||||
consumed_input);
|
||||
const ConfigurationTransactionStatus status =
|
||||
profile_service_activate_internal(
|
||||
context->switching_transaction_id, identity,
|
||||
context->switching_target_profile_index);
|
||||
context->activation_transaction_id, identity,
|
||||
context->target_profile_index);
|
||||
if (status != ConfigurationTransactionStatus::kBusy) {
|
||||
context->switching_activation_requested = true;
|
||||
context->activation_requested = true;
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t process_hotkeys(ControllerProfileRuntimeContext* context,
|
||||
uint8_t slot, uint32_t controls) {
|
||||
const ControllerProfile& profile = context->profile;
|
||||
const uint32_t selectors = shortcut_selector_mask(profile);
|
||||
const uint32_t modifier =
|
||||
profile.shortcuts.modifier < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT
|
||||
? 1u << profile.shortcuts.modifier : 0;
|
||||
const uint32_t pressed_selectors = controls & selectors;
|
||||
const bool direct = (controls & modifier) != 0 && pressed_selectors != 0;
|
||||
const uint32_t cycle_chord = effective_switching_chord(profile);
|
||||
const uint32_t motion_chord = effective_motion_toggle_chord(profile);
|
||||
const bool cycle = chord_held(controls, cycle_chord);
|
||||
const bool motion = chord_held(controls, motion_chord);
|
||||
uint32_t consumed = (direct ? modifier | pressed_selectors : 0) |
|
||||
(cycle ? cycle_chord : 0) |
|
||||
(motion ? motion_chord : 0);
|
||||
|
||||
if (context->held_hotkey_chord != 0) {
|
||||
consumed |= context->held_hotkey_chord |
|
||||
(controls & context->held_shortcut_selectors);
|
||||
const bool released = context->held_shortcut_selectors != 0
|
||||
? (controls & context->held_hotkey_release_mask) == 0 ||
|
||||
!chord_held(controls, context->held_hotkey_chord &
|
||||
~context->held_shortcut_selectors)
|
||||
: !chord_held(controls, context->held_hotkey_chord);
|
||||
if (released) {
|
||||
context->held_hotkey_chord = 0;
|
||||
context->held_hotkey_release_mask = 0;
|
||||
context->held_shortcut_selectors = 0;
|
||||
context->activation_requested = true;
|
||||
context->activation_transaction_id = 0;
|
||||
} else if (chord_held(controls, context->held_hotkey_chord)) {
|
||||
request_activation(context, context->identity);
|
||||
} else {
|
||||
// An ambiguous direct chord remains rejected until its modifier
|
||||
// or all of its original selectors are released.
|
||||
context->activation_requested = true;
|
||||
}
|
||||
if (!released) return consumed;
|
||||
}
|
||||
if (!direct && !cycle && !motion) {
|
||||
return consumed;
|
||||
}
|
||||
|
||||
const uint32_t chord = direct ? modifier | pressed_selectors :
|
||||
cycle ? cycle_chord : motion_chord;
|
||||
context->held_hotkey_chord = chord;
|
||||
context->held_hotkey_release_mask = direct ? pressed_selectors : chord;
|
||||
context->held_shortcut_selectors = direct ? selectors : 0;
|
||||
context->activation_requested = true;
|
||||
if (chord_held(context->previous_hotkey_control_mask, chord)) {
|
||||
return consumed;
|
||||
}
|
||||
if (direct) {
|
||||
if ((pressed_selectors & (pressed_selectors - 1u)) != 0) {
|
||||
return consumed;
|
||||
}
|
||||
for (uint8_t index = 0; index < CONTROLLER_PROFILE_COUNT; ++index) {
|
||||
const uint8_t selector = profile.shortcuts.selectors[index];
|
||||
if (selector < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT &&
|
||||
(pressed_selectors & (1u << selector)) != 0) {
|
||||
context->target_profile_index = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (cycle) {
|
||||
context->target_profile_index = static_cast<uint8_t>(
|
||||
(context->active_profile_index + 1u) % CONTROLLER_PROFILE_COUNT);
|
||||
} else {
|
||||
bluepad32_input_backend_toggle_motion(
|
||||
slot, context->connection_generation);
|
||||
return consumed;
|
||||
}
|
||||
context->activation_requested = false;
|
||||
context->activation_transaction_id = next_activation_transaction_id();
|
||||
request_activation(context, context->identity);
|
||||
return consumed;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void controller_profile_runtime_reset() {
|
||||
|
|
@ -268,30 +326,32 @@ ControllerProfileTransformResult controller_profile_runtime_transform(
|
|||
if (context == nullptr) {
|
||||
return g_neutral_output;
|
||||
}
|
||||
ControllerState consumed_input = snapshot.state;
|
||||
const uint16_t current_input_button_mask =
|
||||
controller_profile_extract_button_mask(snapshot.state);
|
||||
process_profile_switching(
|
||||
context, snapshot.identity, snapshot.pre_hotkey_button_mask,
|
||||
current_input_button_mask, &consumed_input);
|
||||
|
||||
const uint32_t reset_generation =
|
||||
configuration_service_reset_generation();
|
||||
if (!context->runtime_generations_initialized) {
|
||||
context->runtime_generations_initialized = true;
|
||||
context->output_mode = output_mode;
|
||||
context->configuration_reset_generation = reset_generation;
|
||||
} else if (context->output_mode != output_mode ||
|
||||
context->configuration_reset_generation !=
|
||||
reset_generation) {
|
||||
const uint32_t state_control_mask =
|
||||
controller_profile_extract_control_mask(
|
||||
snapshot.state, context->profile);
|
||||
const uint32_t input_control_mask =
|
||||
(state_control_mask & ~0xffffu) |
|
||||
snapshot.pre_hotkey_button_mask;
|
||||
if (context->runtime_generations_initialized &&
|
||||
(context->output_mode != output_mode ||
|
||||
context->configuration_reset_generation != reset_generation)) {
|
||||
controller_synthetic_input_cancel(
|
||||
&context->synthetic,
|
||||
controller_profile_extract_button_mask(consumed_input));
|
||||
context->output_mode = output_mode;
|
||||
context->configuration_reset_generation = reset_generation;
|
||||
&context->synthetic, state_control_mask);
|
||||
context->previous_hotkey_control_mask = input_control_mask;
|
||||
context->activation_requested = true;
|
||||
context->activation_transaction_id = 0;
|
||||
}
|
||||
context->runtime_generations_initialized = true;
|
||||
context->output_mode = output_mode;
|
||||
context->configuration_reset_generation = reset_generation;
|
||||
const uint32_t consumed_controls =
|
||||
process_hotkeys(context, slot, input_control_mask);
|
||||
context->previous_hotkey_control_mask = input_control_mask;
|
||||
return controller_synthetic_input_apply(
|
||||
&context->synthetic, consumed_input, context->profile, now_ms);
|
||||
&context->synthetic, snapshot.state, context->profile, now_ms,
|
||||
consumed_controls, consumed_controls != 0);
|
||||
}
|
||||
|
||||
bool controller_profile_runtime_take_initial_profile_indication(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD =
|
|||
ControllerProfileLogicalButton::kSelect)) |
|
||||
(1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kStart)));
|
||||
constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_MOTION_TOGGLE_CHORD =
|
||||
static_cast<uint16_t>(
|
||||
(1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kDpadUp)) |
|
||||
(1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kRightShoulder)) |
|
||||
(1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kStart)));
|
||||
|
||||
struct ControllerProfileRuntimeProfileChangeEvent {
|
||||
uint32_t connection_generation = 0;
|
||||
|
|
@ -42,10 +50,10 @@ struct ControllerProfileRuntimeLocalConfirmation {
|
|||
// Reset all four fixed slot caches to the default profile.
|
||||
void controller_profile_runtime_reset();
|
||||
|
||||
// Refresh a slot when its identity, connection generation, or database
|
||||
// generation changes; identity-only promotion preserves a held switching
|
||||
// transaction. Consume pre-hotkey switching chords while transforming the
|
||||
// backend-suppressed state through the shared synthetic pipeline. Inactive
|
||||
// Refresh slot-local profile and synthetic state on identity or generation
|
||||
// changes while preserving a latched hotkey target across profile refreshes.
|
||||
// Arbitrate pre-hotkey shortcuts, cycle and motion before Shift and synthetic
|
||||
// input. The caller removes the reserved output-mode chord first. Inactive
|
||||
// snapshots return neutral output and invalidate the slot.
|
||||
ControllerProfileTransformResult controller_profile_runtime_transform(
|
||||
uint8_t slot, const Bluepad32SlotSnapshot& snapshot, uint32_t now_ms,
|
||||
|
|
|
|||
|
|
@ -177,6 +177,26 @@ uint16_t transform_trigger(
|
|||
kQ16One);
|
||||
}
|
||||
|
||||
void route_trigger_output(
|
||||
uint8_t output, uint16_t value, uint16_t digital_threshold,
|
||||
uint16_t* output_button_mask, uint16_t output_triggers[2],
|
||||
uint16_t output_thresholds[2]) {
|
||||
if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) {
|
||||
if (value >= digital_threshold) {
|
||||
*output_button_mask |= static_cast<uint16_t>(1u << output);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) {
|
||||
const uint8_t trigger_index = static_cast<uint8_t>(
|
||||
output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
|
||||
if (value > output_triggers[trigger_index]) {
|
||||
output_triggers[trigger_index] = value;
|
||||
}
|
||||
output_thresholds[trigger_index] = digital_threshold;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
uint16_t controller_profile_extract_button_mask(const ControllerState& state) {
|
||||
|
|
@ -232,6 +252,44 @@ uint16_t controller_profile_extract_button_mask(const ControllerState& state) {
|
|||
return mask;
|
||||
}
|
||||
|
||||
uint32_t controller_profile_extract_control_mask(
|
||||
const ControllerState& state, const ControllerProfile& profile) {
|
||||
uint32_t mask = controller_profile_extract_button_mask(state);
|
||||
const uint16_t trigger_values[2] = {
|
||||
transform_trigger(state.left_trigger, profile.triggers[0]),
|
||||
transform_trigger(state.right_trigger, profile.triggers[1]),
|
||||
};
|
||||
for (uint8_t index = 0; index < 2; ++index) {
|
||||
if (trigger_values[index] >=
|
||||
profile.triggers[index].digital_threshold) {
|
||||
mask |= static_cast<uint32_t>(
|
||||
1u << (CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL + index));
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
void controller_profile_remove_control_mask(
|
||||
uint32_t control_mask, ControllerState* state) {
|
||||
if (state == nullptr) {
|
||||
return;
|
||||
}
|
||||
const uint16_t buttons =
|
||||
controller_profile_extract_button_mask(*state);
|
||||
controller_profile_apply_button_mask(
|
||||
static_cast<uint16_t>(
|
||||
buttons & ~static_cast<uint16_t>(control_mask)),
|
||||
state);
|
||||
if ((control_mask &
|
||||
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL)) != 0) {
|
||||
state->left_trigger = 0;
|
||||
}
|
||||
if ((control_mask &
|
||||
(1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL)) != 0) {
|
||||
state->right_trigger = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void controller_profile_apply_button_mask(uint16_t button_mask,
|
||||
ControllerState* state) {
|
||||
if (state == nullptr) {
|
||||
|
|
@ -276,14 +334,17 @@ void controller_profile_apply_button_mask(uint16_t button_mask,
|
|||
}
|
||||
|
||||
uint16_t controller_profile_map_button_mask(
|
||||
uint16_t input_button_mask, const ControllerProfile& profile) {
|
||||
uint16_t input_button_mask, const ControllerProfile& profile,
|
||||
const uint8_t* button_map) {
|
||||
const uint8_t* selected_map =
|
||||
button_map == nullptr ? profile.button_map : button_map;
|
||||
uint16_t output_button_mask = 0;
|
||||
for (uint8_t input = 0;
|
||||
input < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++input) {
|
||||
if ((input_button_mask & static_cast<uint16_t>(1u << input)) == 0) {
|
||||
continue;
|
||||
}
|
||||
const uint8_t output = profile.button_map[input];
|
||||
const uint8_t output = selected_map[input];
|
||||
if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) {
|
||||
output_button_mask |= static_cast<uint16_t>(1u << output);
|
||||
}
|
||||
|
|
@ -292,14 +353,51 @@ uint16_t controller_profile_map_button_mask(
|
|||
}
|
||||
|
||||
ControllerProfileTransformResult controller_profile_transform(
|
||||
const ControllerState& input, const ControllerProfile& profile) {
|
||||
const ControllerState& input, const ControllerProfile& profile,
|
||||
const uint8_t* button_map) {
|
||||
const uint8_t* selected_map =
|
||||
button_map == nullptr ? profile.button_map : button_map;
|
||||
ControllerProfileTransformResult result{};
|
||||
result.state = input;
|
||||
const uint16_t input_button_mask =
|
||||
controller_profile_extract_button_mask(input);
|
||||
controller_profile_apply_button_mask(
|
||||
controller_profile_map_button_mask(input_button_mask, profile),
|
||||
&result.state);
|
||||
uint16_t output_button_mask =
|
||||
controller_profile_map_button_mask(
|
||||
input_button_mask, profile, selected_map);
|
||||
uint16_t output_triggers[2]{};
|
||||
uint16_t output_thresholds[2] = {
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD,
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD,
|
||||
};
|
||||
|
||||
for (uint8_t input_button = 0;
|
||||
input_button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT;
|
||||
++input_button) {
|
||||
if ((input_button_mask &
|
||||
static_cast<uint16_t>(1u << input_button)) == 0) {
|
||||
continue;
|
||||
}
|
||||
const uint8_t output = selected_map[input_button];
|
||||
if (output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) {
|
||||
output_triggers[
|
||||
output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL] =
|
||||
UINT16_MAX;
|
||||
}
|
||||
}
|
||||
|
||||
const uint16_t transformed_triggers[2] = {
|
||||
transform_trigger(input.left_trigger, profile.triggers[0]),
|
||||
transform_trigger(input.right_trigger, profile.triggers[1]),
|
||||
};
|
||||
for (uint8_t input_trigger = 0; input_trigger < 2; ++input_trigger) {
|
||||
route_trigger_output(
|
||||
profile.triggers[input_trigger].output,
|
||||
transformed_triggers[input_trigger],
|
||||
profile.triggers[input_trigger].digital_threshold,
|
||||
&output_button_mask, output_triggers, output_thresholds);
|
||||
}
|
||||
controller_profile_apply_button_mask(output_button_mask, &result.state);
|
||||
|
||||
transform_stick(profile.sticks[0], input.left_stick_x,
|
||||
input.left_stick_y, &result.state.left_stick_x,
|
||||
|
|
@ -307,14 +405,10 @@ ControllerProfileTransformResult controller_profile_transform(
|
|||
transform_stick(profile.sticks[1], input.right_stick_x,
|
||||
input.right_stick_y, &result.state.right_stick_x,
|
||||
&result.state.right_stick_y);
|
||||
result.state.left_trigger = transform_trigger(input.left_trigger,
|
||||
profile.triggers[0]);
|
||||
result.state.right_trigger = transform_trigger(input.right_trigger,
|
||||
profile.triggers[1]);
|
||||
result.left_trigger_digital_threshold =
|
||||
profile.triggers[0].digital_threshold;
|
||||
result.right_trigger_digital_threshold =
|
||||
profile.triggers[1].digital_threshold;
|
||||
result.state.left_trigger = output_triggers[0];
|
||||
result.state.right_trigger = output_triggers[1];
|
||||
result.left_trigger_digital_threshold = output_thresholds[0];
|
||||
result.right_trigger_digital_threshold = output_thresholds[1];
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -329,12 +423,21 @@ uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude,
|
|||
|
||||
ControllerRumbleOutput controller_profile_scale_host_rumble(
|
||||
const ControllerRumbleOutput& input, const ControllerProfile& profile) {
|
||||
return {
|
||||
controller_profile_scale_rumble_magnitude(
|
||||
input.low_frequency_magnitude, profile.strong_rumble_scale),
|
||||
controller_profile_scale_rumble_magnitude(
|
||||
input.high_frequency_magnitude, profile.weak_rumble_scale),
|
||||
};
|
||||
ControllerRumbleOutput output = input;
|
||||
output.low_frequency_magnitude = controller_profile_scale_rumble_magnitude(
|
||||
input.low_frequency_magnitude, profile.strong_rumble_scale);
|
||||
output.high_frequency_magnitude = controller_profile_scale_rumble_magnitude(
|
||||
input.high_frequency_magnitude, profile.weak_rumble_scale);
|
||||
for (SwitchHapticsActuatorFrame& actuator : output.hd.actuators) {
|
||||
for (uint8_t index = 0; index < actuator.sample_count && index < 3; ++index) {
|
||||
SwitchHapticsSample& sample = actuator.samples[index];
|
||||
sample.low_amplitude_q15 = static_cast<uint16_t>(
|
||||
(uint32_t{sample.low_amplitude_q15} * profile.strong_rumble_scale + 127u) / 255u);
|
||||
sample.high_amplitude_q15 = static_cast<uint16_t>(
|
||||
(uint32_t{sample.high_amplitude_q15} * profile.weak_rumble_scale + 127u) / 255u);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
ControllerProfileConfirmationPolicy controller_profile_confirmation_policy(
|
||||
|
|
|
|||
|
|
@ -15,13 +15,19 @@ struct ControllerProfileTransformResult {
|
|||
};
|
||||
|
||||
uint16_t controller_profile_extract_button_mask(const ControllerState& state);
|
||||
uint32_t controller_profile_extract_control_mask(
|
||||
const ControllerState& state, const ControllerProfile& profile);
|
||||
void controller_profile_remove_control_mask(
|
||||
uint32_t control_mask, ControllerState* state);
|
||||
void controller_profile_apply_button_mask(uint16_t button_mask,
|
||||
ControllerState* state);
|
||||
uint16_t controller_profile_map_button_mask(
|
||||
uint16_t input_button_mask, const ControllerProfile& profile);
|
||||
uint16_t input_button_mask, const ControllerProfile& profile,
|
||||
const uint8_t* button_map = nullptr);
|
||||
|
||||
ControllerProfileTransformResult controller_profile_transform(
|
||||
const ControllerState& input, const ControllerProfile& profile);
|
||||
const ControllerState& input, const ControllerProfile& profile,
|
||||
const uint8_t* button_map = nullptr);
|
||||
|
||||
uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude,
|
||||
uint8_t scale);
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@
|
|||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t kTurboTransitionsPerSecond = 30;
|
||||
constexpr uint32_t kTurboPhaseUnitsPerTransition = 1000;
|
||||
constexpr uint32_t kTurboCycleUnits = 1000;
|
||||
|
||||
constexpr uint16_t button_bit(uint8_t button) {
|
||||
return static_cast<uint16_t>(1u << button);
|
||||
}
|
||||
|
||||
bool is_bound_button(uint8_t button) {
|
||||
return button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT;
|
||||
constexpr uint32_t control_bit(uint8_t control) {
|
||||
return static_cast<uint32_t>(1u << control);
|
||||
}
|
||||
|
||||
bool is_bound_control(uint8_t control) {
|
||||
return control < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
|
||||
}
|
||||
|
||||
void clear_binding(ControllerSyntheticBindingState* binding) {
|
||||
|
|
@ -18,87 +21,123 @@ void clear_binding(ControllerSyntheticBindingState* binding) {
|
|||
}
|
||||
|
||||
void start_binding(ControllerSyntheticBindingState* binding,
|
||||
uint32_t now_ms) {
|
||||
uint32_t now_ms, uint8_t burst_count = 0) {
|
||||
*binding = {};
|
||||
binding->active = true;
|
||||
binding->phase_on = true;
|
||||
binding->phase_units = 0;
|
||||
binding->burst_windows_remaining = burst_count;
|
||||
binding->last_update_ms = now_ms;
|
||||
}
|
||||
|
||||
void advance_binding(ControllerSyntheticBindingState* binding,
|
||||
const ControllerProfileTurboSettings& settings,
|
||||
uint32_t now_ms) {
|
||||
const uint32_t elapsed_ms = now_ms - binding->last_update_ms;
|
||||
const uint64_t total_units =
|
||||
static_cast<uint64_t>(binding->phase_units) +
|
||||
static_cast<uint64_t>(elapsed_ms) * kTurboTransitionsPerSecond;
|
||||
const uint64_t transition_count =
|
||||
total_units / kTurboPhaseUnitsPerTransition;
|
||||
if ((transition_count & 1u) != 0) {
|
||||
binding->phase_on = !binding->phase_on;
|
||||
}
|
||||
binding->phase_units = static_cast<uint16_t>(
|
||||
total_units -
|
||||
transition_count * kTurboPhaseUnitsPerTransition);
|
||||
static_cast<uint64_t>(elapsed_ms) * settings.rate_hz;
|
||||
const uint64_t cycles = total_units / kTurboCycleUnits;
|
||||
binding->phase_units =
|
||||
static_cast<uint16_t>(total_units % kTurboCycleUnits);
|
||||
binding->phase_on = binding->phase_units <
|
||||
static_cast<uint32_t>(settings.duty_percent) * 10u;
|
||||
binding->last_update_ms = now_ms;
|
||||
}
|
||||
|
||||
bool deadline_reached(uint32_t now_ms, uint32_t deadline_ms) {
|
||||
return now_ms - deadline_ms < (UINT32_MAX / 2u + 1u);
|
||||
if (binding->burst_windows_remaining != 0) {
|
||||
if (cycles >= binding->burst_windows_remaining) {
|
||||
clear_binding(binding);
|
||||
return;
|
||||
}
|
||||
binding->burst_windows_remaining = static_cast<uint8_t>(
|
||||
binding->burst_windows_remaining - cycles);
|
||||
if (binding->burst_windows_remaining == 1 && !binding->phase_on) {
|
||||
clear_binding(binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void stop_macro(ControllerSyntheticInputContext* context) {
|
||||
context->macro_active = false;
|
||||
context->macro_index = 0;
|
||||
context->macro_step_index = 0;
|
||||
context->macro_deadline_ms = 0;
|
||||
context->macro_cycle_duration_ms = 0;
|
||||
context->macro_cycle_elapsed_ms = 0;
|
||||
context->macro_last_update_ms = 0;
|
||||
context->macro_cycles_remaining = 0;
|
||||
}
|
||||
|
||||
bool start_macro(ControllerSyntheticInputContext* context,
|
||||
const ControllerProfile& profile, uint32_t now_ms) {
|
||||
if (profile.macro_step_count == 0 ||
|
||||
profile.macro_steps[0].type !=
|
||||
ControllerProfileMacroStepType::kState) {
|
||||
stop_macro(context);
|
||||
const ControllerProfile& profile, uint8_t macro_index,
|
||||
uint32_t now_ms) {
|
||||
if (macro_index >= CONTROLLER_PROFILE_MACRO_COUNT) {
|
||||
return false;
|
||||
}
|
||||
const ControllerProfileMacro& macro = profile.macros[macro_index];
|
||||
if (macro.step_count == 0 ||
|
||||
macro.step_count > CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO ||
|
||||
macro.first_step + macro.step_count > profile.macro_step_count ||
|
||||
macro.first_step + macro.step_count >
|
||||
CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) {
|
||||
return false;
|
||||
}
|
||||
uint32_t duration_ms = 0;
|
||||
for (uint8_t step = 0; step < macro.step_count; ++step) {
|
||||
duration_ms += profile.macro_steps[macro.first_step + step].duration_ms;
|
||||
}
|
||||
if (duration_ms == 0 && macro.mode != ControllerProfileMacroMode::kOnce) {
|
||||
return false;
|
||||
}
|
||||
context->macro_active = true;
|
||||
context->macro_index = macro_index;
|
||||
context->macro_step_index = 0;
|
||||
context->macro_deadline_ms =
|
||||
now_ms + profile.macro_steps[0].duration_ms;
|
||||
context->macro_cycle_duration_ms = duration_ms;
|
||||
context->macro_cycle_elapsed_ms = 0;
|
||||
context->macro_last_update_ms = now_ms;
|
||||
context->macro_cycles_remaining =
|
||||
macro.mode == ControllerProfileMacroMode::kOnce ? 1 :
|
||||
macro.mode == ControllerProfileMacroMode::kRepeat ? macro.repeat_count : 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
void advance_macro(ControllerSyntheticInputContext* context,
|
||||
const ControllerProfile& profile, uint32_t now_ms) {
|
||||
for (uint8_t transition = 0;
|
||||
transition < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY;
|
||||
++transition) {
|
||||
if (!context->macro_active ||
|
||||
context->macro_step_index >= profile.macro_step_count) {
|
||||
stop_macro(context);
|
||||
return;
|
||||
}
|
||||
const ControllerProfileMacroStep& current =
|
||||
profile.macro_steps[context->macro_step_index];
|
||||
if (current.type != ControllerProfileMacroStepType::kState) {
|
||||
stop_macro(context);
|
||||
return;
|
||||
}
|
||||
if (!deadline_reached(now_ms, context->macro_deadline_ms)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t next_index =
|
||||
static_cast<uint8_t>(context->macro_step_index + 1u);
|
||||
if (next_index >= profile.macro_step_count ||
|
||||
profile.macro_steps[next_index].type ==
|
||||
ControllerProfileMacroStepType::kEnd) {
|
||||
stop_macro(context);
|
||||
return;
|
||||
}
|
||||
context->macro_step_index = next_index;
|
||||
context->macro_deadline_ms +=
|
||||
profile.macro_steps[next_index].duration_ms;
|
||||
if (!context->macro_active) {
|
||||
return;
|
||||
}
|
||||
const uint32_t duration_ms = context->macro_cycle_duration_ms;
|
||||
if (context->macro_index >= CONTROLLER_PROFILE_MACRO_COUNT ||
|
||||
duration_ms == 0) {
|
||||
stop_macro(context);
|
||||
return;
|
||||
}
|
||||
const uint32_t elapsed_ms = now_ms - context->macro_last_update_ms;
|
||||
const uint64_t total_ms =
|
||||
static_cast<uint64_t>(context->macro_cycle_elapsed_ms) + elapsed_ms;
|
||||
const uint64_t cycles = total_ms / duration_ms;
|
||||
if (context->macro_cycles_remaining != 0) {
|
||||
if (cycles >= context->macro_cycles_remaining) {
|
||||
stop_macro(context);
|
||||
return;
|
||||
}
|
||||
context->macro_cycles_remaining = static_cast<uint8_t>(
|
||||
context->macro_cycles_remaining - cycles);
|
||||
}
|
||||
context->macro_cycle_elapsed_ms =
|
||||
static_cast<uint32_t>(total_ms % duration_ms);
|
||||
context->macro_last_update_ms = now_ms;
|
||||
const ControllerProfileMacro& macro = profile.macros[context->macro_index];
|
||||
uint32_t remaining_ms = context->macro_cycle_elapsed_ms;
|
||||
for (uint8_t step = 0;
|
||||
step < macro.step_count &&
|
||||
step < CONTROLLER_PROFILE_MACRO_STEPS_PER_MACRO; ++step) {
|
||||
const uint16_t step_duration =
|
||||
profile.macro_steps[macro.first_step + step].duration_ms;
|
||||
if (remaining_ms < step_duration) {
|
||||
context->macro_step_index = step;
|
||||
return;
|
||||
}
|
||||
remaining_ms -= step_duration;
|
||||
}
|
||||
stop_macro(context);
|
||||
}
|
||||
|
||||
void apply_macro_override(const ControllerProfileMacroStep& step,
|
||||
|
|
@ -127,41 +166,97 @@ void apply_macro_override(const ControllerProfileMacroStep& step,
|
|||
|
||||
void controller_synthetic_input_cancel(
|
||||
ControllerSyntheticInputContext* context,
|
||||
uint16_t current_input_button_mask) {
|
||||
uint32_t current_input_control_mask) {
|
||||
if (context == nullptr) {
|
||||
return;
|
||||
}
|
||||
*context = {};
|
||||
context->previous_input_button_mask = current_input_button_mask;
|
||||
context->previous_input_control_mask = current_input_control_mask;
|
||||
}
|
||||
|
||||
ControllerProfileTransformResult controller_synthetic_input_apply(
|
||||
ControllerSyntheticInputContext* context, const ControllerState& input,
|
||||
const ControllerProfile& profile, uint32_t now_ms) {
|
||||
const ControllerProfile& profile, uint32_t now_ms,
|
||||
uint32_t suppressed_control_mask, bool suppress_shift) {
|
||||
if (context == nullptr) {
|
||||
return controller_profile_transform(input, profile);
|
||||
}
|
||||
|
||||
const uint16_t input_button_mask =
|
||||
controller_profile_extract_button_mask(input);
|
||||
uint16_t rising_button_mask = static_cast<uint16_t>(
|
||||
input_button_mask & ~context->previous_input_button_mask);
|
||||
const bool cancel_pressed =
|
||||
is_bound_button(profile.macro_cancel) &&
|
||||
(input_button_mask & button_bit(profile.macro_cancel)) != 0;
|
||||
const uint32_t raw_control_mask =
|
||||
controller_profile_extract_control_mask(input, profile);
|
||||
uint32_t consumed_controls = suppressed_control_mask;
|
||||
const uint32_t raw_rising_mask =
|
||||
raw_control_mask & ~context->previous_input_control_mask;
|
||||
if (profile.shift.mode != ControllerProfileShiftMode::kOff &&
|
||||
is_bound_control(profile.shift.modifier)) {
|
||||
const uint32_t modifier = control_bit(profile.shift.modifier);
|
||||
const bool pressed = (raw_control_mask & modifier) != 0;
|
||||
const bool allowed = !suppress_shift &&
|
||||
(suppressed_control_mask & modifier) == 0;
|
||||
if (profile.shift.mode == ControllerProfileShiftMode::kHold) {
|
||||
context->shift_active = pressed && allowed;
|
||||
} else if (allowed && (raw_rising_mask & modifier) != 0) {
|
||||
context->shift_active = !context->shift_active;
|
||||
}
|
||||
consumed_controls |= modifier;
|
||||
} else {
|
||||
context->shift_active = false;
|
||||
}
|
||||
const uint32_t input_control_mask = raw_control_mask & ~consumed_controls;
|
||||
uint32_t rising_control_mask = raw_rising_mask & ~consumed_controls;
|
||||
bool cancel_pressed = false;
|
||||
for (const ControllerProfileMacro& macro : profile.macros) {
|
||||
if (is_bound_control(macro.cancel_control) &&
|
||||
(input_control_mask & control_bit(macro.cancel_control)) != 0) {
|
||||
cancel_pressed = true;
|
||||
consumed_controls |= control_bit(macro.cancel_control);
|
||||
}
|
||||
}
|
||||
if (cancel_pressed) {
|
||||
controller_synthetic_input_cancel(context, input_button_mask);
|
||||
rising_button_mask = 0;
|
||||
controller_synthetic_input_cancel(context, raw_control_mask);
|
||||
rising_control_mask = 0;
|
||||
}
|
||||
|
||||
bool macro_started = false;
|
||||
if (!cancel_pressed && is_bound_button(profile.macro_trigger) &&
|
||||
(rising_button_mask & button_bit(profile.macro_trigger)) != 0) {
|
||||
macro_started = start_macro(context, profile, now_ms);
|
||||
const bool macro_was_active = context->macro_active;
|
||||
if (macro_was_active) {
|
||||
const ControllerProfileMacro& macro =
|
||||
profile.macros[context->macro_index];
|
||||
const bool held = (input_control_mask & macro.trigger_mask) ==
|
||||
macro.trigger_mask;
|
||||
consumed_controls |= macro.trigger_mask;
|
||||
if ((macro.mode == ControllerProfileMacroMode::kWhileHeld && !held) ||
|
||||
(macro.mode == ControllerProfileMacroMode::kToggle && held &&
|
||||
(rising_control_mask & macro.trigger_mask) != 0)) {
|
||||
stop_macro(context);
|
||||
}
|
||||
}
|
||||
if (!cancel_pressed && !macro_was_active) {
|
||||
for (uint8_t macro_index = 0;
|
||||
macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) {
|
||||
const ControllerProfileMacro& macro =
|
||||
profile.macros[macro_index];
|
||||
const bool completed =
|
||||
macro.trigger_mask != 0 && macro.step_count != 0 &&
|
||||
(input_control_mask & macro.trigger_mask) ==
|
||||
macro.trigger_mask &&
|
||||
(rising_control_mask & macro.trigger_mask) != 0;
|
||||
if (completed &&
|
||||
start_macro(context, profile, macro_index, now_ms)) {
|
||||
macro_started = true;
|
||||
consumed_controls |= macro.trigger_mask;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!macro_started) {
|
||||
advance_macro(context, profile, now_ms);
|
||||
}
|
||||
if (context->macro_active &&
|
||||
context->macro_index < CONTROLLER_PROFILE_MACRO_COUNT) {
|
||||
consumed_controls |=
|
||||
profile.macros[context->macro_index].trigger_mask;
|
||||
}
|
||||
|
||||
uint16_t gated_input_button_mask = 0;
|
||||
for (uint8_t input_button = 0;
|
||||
|
|
@ -169,15 +264,17 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
|
|||
++input_button) {
|
||||
ControllerSyntheticBindingState& binding =
|
||||
context->bindings[input_button];
|
||||
if (input_button == profile.macro_trigger ||
|
||||
input_button == profile.macro_cancel) {
|
||||
const uint16_t bit = button_bit(input_button);
|
||||
if ((consumed_controls & bit) != 0) {
|
||||
clear_binding(&binding);
|
||||
continue;
|
||||
}
|
||||
|
||||
const uint16_t bit = button_bit(input_button);
|
||||
const bool pressed = (input_button_mask & bit) != 0;
|
||||
const bool rising = (rising_button_mask & bit) != 0;
|
||||
const bool pressed = (input_control_mask & bit) != 0;
|
||||
const bool rising = (rising_control_mask & bit) != 0;
|
||||
const ControllerProfileTurboSettings& settings =
|
||||
(profile.turbo_override_mask & bit) != 0
|
||||
? profile.turbo_overrides[input_button]
|
||||
: profile.turbo_defaults;
|
||||
switch (profile.turbo_modes[input_button]) {
|
||||
case ControllerProfileTurboMode::kOff:
|
||||
clear_binding(&binding);
|
||||
|
|
@ -193,7 +290,7 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
|
|||
if (!binding.active) {
|
||||
start_binding(&binding, now_ms);
|
||||
} else {
|
||||
advance_binding(&binding, now_ms);
|
||||
advance_binding(&binding, settings, now_ms);
|
||||
}
|
||||
if (binding.phase_on) {
|
||||
gated_input_button_mask |= bit;
|
||||
|
|
@ -207,7 +304,17 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
|
|||
start_binding(&binding, now_ms);
|
||||
}
|
||||
} else if (binding.active) {
|
||||
advance_binding(&binding, now_ms);
|
||||
advance_binding(&binding, settings, now_ms);
|
||||
}
|
||||
if (binding.active && binding.phase_on) {
|
||||
gated_input_button_mask |= bit;
|
||||
}
|
||||
break;
|
||||
case ControllerProfileTurboMode::kBurst:
|
||||
if (rising) {
|
||||
start_binding(&binding, now_ms, settings.burst_count);
|
||||
} else if (binding.active) {
|
||||
advance_binding(&binding, settings, now_ms);
|
||||
}
|
||||
if (binding.active && binding.phase_on) {
|
||||
gated_input_button_mask |= bit;
|
||||
|
|
@ -219,19 +326,27 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
|
|||
ControllerState gated_input = input;
|
||||
controller_profile_apply_button_mask(gated_input_button_mask,
|
||||
&gated_input);
|
||||
controller_profile_remove_control_mask(
|
||||
consumed_controls, &gated_input);
|
||||
|
||||
ControllerProfileTransformResult result =
|
||||
controller_profile_transform(gated_input, profile);
|
||||
controller_profile_transform(
|
||||
gated_input, profile,
|
||||
context->shift_active ? profile.shift.button_map : nullptr);
|
||||
if (context->macro_active &&
|
||||
context->macro_step_index < profile.macro_step_count) {
|
||||
const ControllerProfileMacroStep& step =
|
||||
profile.macro_steps[context->macro_step_index];
|
||||
if (step.type == ControllerProfileMacroStepType::kState) {
|
||||
apply_macro_override(step, &result.state);
|
||||
context->macro_index < CONTROLLER_PROFILE_MACRO_COUNT) {
|
||||
const ControllerProfileMacro& macro =
|
||||
profile.macros[context->macro_index];
|
||||
if (context->macro_step_index < macro.step_count) {
|
||||
apply_macro_override(
|
||||
profile.macro_steps[
|
||||
macro.first_step + context->macro_step_index],
|
||||
&result.state);
|
||||
} else {
|
||||
stop_macro(context);
|
||||
}
|
||||
}
|
||||
|
||||
context->previous_input_button_mask = input_button_mask;
|
||||
context->previous_input_control_mask = raw_control_mask;
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,27 +8,35 @@ struct ControllerSyntheticBindingState {
|
|||
bool active = false;
|
||||
bool phase_on = false;
|
||||
uint16_t phase_units = 0;
|
||||
uint8_t burst_windows_remaining = 0;
|
||||
uint32_t last_update_ms = 0;
|
||||
};
|
||||
|
||||
struct ControllerSyntheticInputContext {
|
||||
bool macro_active = false;
|
||||
uint8_t macro_index = 0;
|
||||
uint8_t macro_step_index = 0;
|
||||
uint32_t macro_deadline_ms = 0;
|
||||
uint16_t previous_input_button_mask = 0;
|
||||
uint32_t macro_cycle_duration_ms = 0;
|
||||
uint32_t macro_cycle_elapsed_ms = 0;
|
||||
uint32_t macro_last_update_ms = 0;
|
||||
uint8_t macro_cycles_remaining = 0;
|
||||
bool shift_active = false;
|
||||
uint32_t previous_input_control_mask = 0;
|
||||
ControllerSyntheticBindingState
|
||||
bindings[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{};
|
||||
};
|
||||
|
||||
// Clear every synthetic source. Inputs already held at cancellation remain
|
||||
// consumed or physical, but edge-triggered Macro and Auto Burst bindings do not
|
||||
// restart until they are released and pressed again.
|
||||
// Clear every synthetic source, including toggle Shift. Inputs already held at
|
||||
// cancellation cannot restart edge-triggered Macro, Burst, Auto Burst or Shift
|
||||
// until they are released and pressed again.
|
||||
void controller_synthetic_input_cancel(
|
||||
ControllerSyntheticInputContext* context,
|
||||
uint16_t current_input_button_mask = 0);
|
||||
uint32_t current_input_control_mask = 0);
|
||||
|
||||
// Apply raw-input consumption, mapped physical contributions, Turbo/Auto Burst
|
||||
// gating, and finally the active macro step's field overrides.
|
||||
// Consume reserved controls and Shift before physical macro/Turbo arbitration.
|
||||
// Only the selected button map changes; base analog tuning and final macro
|
||||
// overrides retain their precedence. Suppressed inputs still track raw edges.
|
||||
ControllerProfileTransformResult controller_synthetic_input_apply(
|
||||
ControllerSyntheticInputContext* context, const ControllerState& input,
|
||||
const ControllerProfile& profile, uint32_t now_ms);
|
||||
const ControllerProfile& profile, uint32_t now_ms,
|
||||
uint32_t suppressed_control_mask = 0, bool suppress_shift = false);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
constexpr uint8_t PROFILE_SERVICE_LIST_CAPACITY =
|
||||
CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1;
|
||||
constexpr size_t PROFILE_SERVICE_METADATA_MAX_BYTES = 31;
|
||||
|
||||
enum class ProfileServiceState : uint8_t {
|
||||
kLoading = 0,
|
||||
|
|
@ -23,6 +24,7 @@ struct ProfileServiceMetadata {
|
|||
|
||||
struct ProfileServiceListRow {
|
||||
ControllerIdentity identity{};
|
||||
char alias[PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{};
|
||||
uint8_t active_profile = 0;
|
||||
};
|
||||
|
||||
|
|
@ -42,6 +44,17 @@ struct ProfileServiceSelectedSnapshot {
|
|||
ControllerProfile profile{};
|
||||
};
|
||||
|
||||
struct ProfileServiceMetadataSnapshot {
|
||||
ProfileServiceMetadata metadata{};
|
||||
bool valid = false;
|
||||
ConfigurationTransactionStatus status =
|
||||
ConfigurationTransactionStatus::kIdle;
|
||||
ControllerIdentity identity{};
|
||||
char alias[PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{};
|
||||
char profile_names[CONTROLLER_PROFILE_COUNT]
|
||||
[PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{};
|
||||
};
|
||||
|
||||
struct ProfileServiceActiveProfileSnapshot {
|
||||
ProfileServiceMetadata metadata{};
|
||||
bool valid = false;
|
||||
|
|
@ -79,6 +92,9 @@ ConfigurationTransactionStatus profile_service_reset(
|
|||
ConfigurationTransactionStatus profile_service_activate(
|
||||
uint32_t transaction_id, const ControllerIdentity& identity,
|
||||
uint8_t profile_index);
|
||||
ConfigurationTransactionStatus profile_service_set_metadata(
|
||||
uint32_t transaction_id, const ControllerIdentity& identity,
|
||||
uint8_t profile_index, const char* value, size_t value_size);
|
||||
// Queue a controller-originated activation without replacing the host-visible
|
||||
// transaction snapshot. transaction_id must be nonzero with its high bit set.
|
||||
ConfigurationTransactionStatus profile_service_activate_internal(
|
||||
|
|
@ -88,6 +104,8 @@ ConfigurationTransactionStatus profile_service_activate_internal(
|
|||
void profile_service_list_snapshot(ProfileServiceListSnapshot* output);
|
||||
void profile_service_selected_snapshot(
|
||||
ProfileServiceSelectedSnapshot* output);
|
||||
void profile_service_metadata_snapshot(
|
||||
ProfileServiceMetadataSnapshot* output);
|
||||
void profile_service_transaction_snapshot(
|
||||
ProfileServiceTransactionSnapshot* output);
|
||||
uint32_t profile_service_database_generation();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,79 +5,167 @@
|
|||
|
||||
#include "profile/controller_profile.h"
|
||||
|
||||
constexpr uint8_t PROFILE_STORAGE_BANK_COUNT = 2;
|
||||
constexpr uint8_t PROFILE_STORAGE_ARENA_COUNT = 2;
|
||||
constexpr size_t PROFILE_STORAGE_SECTOR_SIZE = 4096;
|
||||
constexpr size_t PROFILE_STORAGE_SECTORS_PER_BANK = 5;
|
||||
constexpr size_t PROFILE_STORAGE_BANK_SIZE =
|
||||
PROFILE_STORAGE_SECTOR_SIZE * PROFILE_STORAGE_SECTORS_PER_BANK;
|
||||
constexpr size_t PROFILE_STORAGE_TOTAL_SIZE =
|
||||
PROFILE_STORAGE_BANK_COUNT * PROFILE_STORAGE_BANK_SIZE;
|
||||
constexpr size_t PROFILE_STORAGE_PAGE_SIZE = 256;
|
||||
constexpr size_t PROFILE_STORAGE_RECORD_HEADER_SIZE =
|
||||
PROFILE_STORAGE_PAGE_SIZE;
|
||||
constexpr size_t PROFILE_STORAGE_ARENA_SIZE = 128 * 1024;
|
||||
constexpr size_t PROFILE_STORAGE_TOTAL_SIZE =
|
||||
PROFILE_STORAGE_ARENA_COUNT * PROFILE_STORAGE_ARENA_SIZE;
|
||||
constexpr size_t PROFILE_STORAGE_SUPERBLOCK_SIZE = PROFILE_STORAGE_PAGE_SIZE;
|
||||
constexpr size_t PROFILE_STORAGE_RECORD_SIZE = 2 * PROFILE_STORAGE_PAGE_SIZE;
|
||||
constexpr size_t PROFILE_STORAGE_RECORDS_OFFSET = PROFILE_STORAGE_SECTOR_SIZE;
|
||||
constexpr uint32_t PROFILE_STORAGE_NO_RECORD = UINT32_MAX;
|
||||
constexpr uint8_t PROFILE_STORAGE_METADATA_MAX_BYTES = 31;
|
||||
constexpr size_t PROFILE_STORAGE_METADATA_PAYLOAD_SIZE =
|
||||
PROFILE_STORAGE_METADATA_MAX_BYTES + 1;
|
||||
constexpr size_t PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE =
|
||||
CONTROLLER_PROFILE_COUNT * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE;
|
||||
constexpr size_t PROFILE_STORAGE_MAX_LIVE_RECORDS =
|
||||
(CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) *
|
||||
(CONTROLLER_PROFILE_COUNT + 3);
|
||||
constexpr size_t PROFILE_STORAGE_RECORD_CAPACITY =
|
||||
(PROFILE_STORAGE_ARENA_SIZE - PROFILE_STORAGE_RECORDS_OFFSET) /
|
||||
PROFILE_STORAGE_RECORD_SIZE;
|
||||
static_assert(PROFILE_STORAGE_MAX_LIVE_RECORDS <=
|
||||
PROFILE_STORAGE_RECORD_CAPACITY,
|
||||
"all live profile records must fit during compaction");
|
||||
static_assert(PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE == 256);
|
||||
static_assert(PROFILE_STORAGE_RECORD_CAPACITY == 248);
|
||||
static_assert(PROFILE_STORAGE_MAX_LIVE_RECORDS == 187);
|
||||
|
||||
static_assert(PROFILE_STORAGE_BANK_SIZE == 20 * 1024);
|
||||
static_assert(PROFILE_STORAGE_TOTAL_SIZE == 40 * 1024);
|
||||
static_assert(PROFILE_STORAGE_RECORD_HEADER_SIZE +
|
||||
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE <=
|
||||
PROFILE_STORAGE_BANK_SIZE,
|
||||
"profile database does not fit a storage bank");
|
||||
// Layout of the retired v1/v2 whole-database store. The indexed catalog reads
|
||||
// this region once during migration; new firmware never writes it.
|
||||
constexpr uint8_t PROFILE_STORAGE_LEGACY_BANK_COUNT = 2;
|
||||
constexpr size_t PROFILE_STORAGE_LEGACY_BANK_SIZE = 20 * 1024;
|
||||
constexpr size_t PROFILE_STORAGE_LEGACY_TOTAL_SIZE =
|
||||
PROFILE_STORAGE_LEGACY_BANK_COUNT * PROFILE_STORAGE_LEGACY_BANK_SIZE;
|
||||
constexpr size_t PROFILE_STORAGE_LEGACY_HEADER_SIZE = PROFILE_STORAGE_PAGE_SIZE;
|
||||
constexpr uint8_t PROFILE_STORAGE_LEGACY_PROFILE_COUNT = 4;
|
||||
constexpr size_t PROFILE_STORAGE_LEGACY_DATABASE_SIZE = 17696;
|
||||
|
||||
enum class ProfileStorageResult : uint8_t {
|
||||
kOk = 0,
|
||||
kUnchanged = 1,
|
||||
kInvalidArgument = 2,
|
||||
kIoError = 3,
|
||||
};
|
||||
static_assert(PROFILE_STORAGE_ARENA_SIZE % PROFILE_STORAGE_SECTOR_SIZE == 0);
|
||||
static_assert(PROFILE_STORAGE_RECORDS_OFFSET % PROFILE_STORAGE_RECORD_SIZE ==
|
||||
0);
|
||||
|
||||
struct ProfileStorageIo {
|
||||
void* context = nullptr;
|
||||
size_t bank_size = 0;
|
||||
size_t sector_size = 0;
|
||||
size_t page_size = 0;
|
||||
bool (*read)(void* context, uint8_t bank, size_t offset,
|
||||
uint8_t* output, size_t size) = nullptr;
|
||||
// Replaces one bank and verifies the payload before publishing the
|
||||
// complete header page.
|
||||
bool (*replace_bank)(void* context, uint8_t bank,
|
||||
const uint8_t* payload, size_t payload_size,
|
||||
const uint8_t* header, size_t header_size) = nullptr;
|
||||
void *context = nullptr;
|
||||
size_t arena_size = 0;
|
||||
size_t sector_size = 0;
|
||||
size_t page_size = 0;
|
||||
bool (*read)(void *context, uint8_t arena, size_t offset, uint8_t *output,
|
||||
size_t size) = nullptr;
|
||||
bool (*erase_arena)(void *context, uint8_t arena) = nullptr;
|
||||
bool (*program_page)(void *context, uint8_t arena, size_t offset,
|
||||
const uint8_t *page, size_t size) = nullptr;
|
||||
};
|
||||
|
||||
struct ProfileStorageSnapshot {
|
||||
bool valid = false;
|
||||
uint32_t generation = 0;
|
||||
uint32_t payload_crc = 0;
|
||||
uint8_t active_bank = 0;
|
||||
bool valid = false;
|
||||
uint32_t generation = 0;
|
||||
uint32_t payload_crc = 0;
|
||||
uint8_t active_bank = 0;
|
||||
};
|
||||
|
||||
uint32_t profile_storage_crc32(const uint8_t* data, size_t size);
|
||||
enum class ProfileStorageResult : uint8_t {
|
||||
kOk = 0,
|
||||
kUnchanged = 1,
|
||||
kInvalidArgument = 2,
|
||||
kIoError = 3,
|
||||
kFull = 4,
|
||||
};
|
||||
|
||||
struct ProfileStorageIdentityIndex {
|
||||
bool used = false;
|
||||
ControllerIdentity identity{};
|
||||
uint8_t active_profile = 0;
|
||||
uint32_t active_generation = 0;
|
||||
uint32_t profile_generation[CONTROLLER_PROFILE_COUNT]{};
|
||||
uint32_t profile_record[CONTROLLER_PROFILE_COUNT]{};
|
||||
uint32_t alias_generation = 0;
|
||||
uint32_t alias_record = PROFILE_STORAGE_NO_RECORD;
|
||||
uint32_t profile_names_generation = 0;
|
||||
uint32_t profile_names_record = PROFILE_STORAGE_NO_RECORD;
|
||||
};
|
||||
|
||||
uint32_t profile_storage_crc32(const uint8_t *data, size_t size);
|
||||
|
||||
class ProfileStorage {
|
||||
public:
|
||||
bool initialize(const ProfileStorageIo& io,
|
||||
ControllerProfileDatabase* database);
|
||||
ProfileStorageResult commit(
|
||||
const ControllerProfileDatabase& database,
|
||||
uint8_t* encoded_database,
|
||||
size_t encoded_database_size);
|
||||
const ProfileStorageSnapshot& snapshot() const;
|
||||
bool initialize(const ProfileStorageIo &io);
|
||||
ProfileStorageResult ensure_identity(const ControllerIdentity &identity);
|
||||
ProfileStorageResult get(const ControllerIdentity &identity,
|
||||
uint8_t profile_index,
|
||||
ControllerProfile *output) const;
|
||||
ProfileStorageResult set(const ControllerIdentity &identity,
|
||||
uint8_t profile_index,
|
||||
const ControllerProfile &profile);
|
||||
ProfileStorageResult reset(const ControllerIdentity &identity,
|
||||
uint8_t profile_index);
|
||||
ProfileStorageResult activate(const ControllerIdentity &identity,
|
||||
uint8_t profile_index);
|
||||
ProfileStorageResult get_alias(
|
||||
const ControllerIdentity &identity, char *output,
|
||||
size_t output_size) const;
|
||||
ProfileStorageResult set_alias(
|
||||
const ControllerIdentity &identity, const char *value,
|
||||
size_t value_size);
|
||||
ProfileStorageResult get_profile_name(
|
||||
const ControllerIdentity &identity, uint8_t profile_index,
|
||||
char *output, size_t output_size) const;
|
||||
ProfileStorageResult set_profile_name(
|
||||
const ControllerIdentity &identity, uint8_t profile_index,
|
||||
const char *value, size_t value_size);
|
||||
|
||||
uint8_t identity_count() const;
|
||||
const ProfileStorageIdentityIndex *identity(uint8_t index) const;
|
||||
const ProfileStorageIdentityIndex *
|
||||
find(const ControllerIdentity &identity) const;
|
||||
const ProfileStorageSnapshot &snapshot() const;
|
||||
|
||||
private:
|
||||
struct BankHeader {
|
||||
uint32_t generation = 0;
|
||||
uint32_t payload_crc = 0;
|
||||
};
|
||||
enum class RecordType : uint8_t {
|
||||
kProfile = 1,
|
||||
kReset = 2,
|
||||
kResetAll = 3,
|
||||
kActivate = 4,
|
||||
kAlias = 5,
|
||||
kProfileNames = 6,
|
||||
};
|
||||
|
||||
bool read_header(uint8_t bank, BankHeader* output) const;
|
||||
bool validate_payload(uint8_t bank, uint32_t expected_crc) const;
|
||||
bool decode_bank(uint8_t bank,
|
||||
ControllerProfileDatabase* database) const;
|
||||
bool payload_matches_encoded(uint8_t bank,
|
||||
const uint8_t* payload,
|
||||
size_t payload_size) const;
|
||||
ProfileStorageResult scan_arena(
|
||||
uint8_t arena, uint16_t *version, uint32_t *epoch,
|
||||
uint32_t *generation, uint32_t *payload_crc, size_t *next_offset,
|
||||
ProfileStorageIdentityIndex *index, uint8_t *identity_count) const;
|
||||
bool validate_record(const uint8_t *record, uint16_t version) const;
|
||||
bool read_record_payload(uint32_t record, RecordType type, uint8_t *output,
|
||||
size_t capacity, size_t *size = nullptr) const;
|
||||
bool read_profile_record(uint32_t record, ControllerProfile *output) const;
|
||||
bool read_metadata_record(uint32_t record, char *output,
|
||||
size_t output_size) const;
|
||||
ProfileStorageResult append(RecordType type,
|
||||
const ControllerIdentity &identity,
|
||||
uint8_t profile_index, const uint8_t *payload,
|
||||
size_t payload_size);
|
||||
ProfileStorageResult compact();
|
||||
ProfileStorageResult migrate_legacy();
|
||||
bool publish_arena(uint8_t arena, uint32_t epoch, size_t records_end) const;
|
||||
bool publish_empty_arena(uint8_t arena, uint32_t epoch);
|
||||
bool write_record(uint8_t arena, size_t offset, RecordType type,
|
||||
const ControllerIdentity &identity, uint8_t profile_index,
|
||||
uint32_t generation, const uint8_t *payload,
|
||||
size_t payload_size) const;
|
||||
void apply_record(ProfileStorageIdentityIndex *index, uint8_t *identity_count,
|
||||
RecordType type, const ControllerIdentity &identity,
|
||||
uint8_t profile_index, uint32_t generation,
|
||||
uint32_t record) const;
|
||||
|
||||
ProfileStorageIo io_{};
|
||||
ProfileStorageSnapshot snapshot_{};
|
||||
bool initialized_ = false;
|
||||
ProfileStorageIo io_{};
|
||||
ProfileStorageSnapshot snapshot_{};
|
||||
ProfileStorageIdentityIndex
|
||||
index_[CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1]{};
|
||||
uint8_t identity_count_ = 0;
|
||||
uint32_t epoch_ = 0;
|
||||
uint16_t catalog_version_ = 0;
|
||||
size_t next_offset_ = PROFILE_STORAGE_RECORDS_OFFSET;
|
||||
bool initialized_ = false;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#include "usb/switch/switch_haptics.h"
|
||||
#include <cmath>
|
||||
#include "usb/switch/switch_haptics_amplitudes.h"
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
|
@ -149,16 +149,31 @@ void SwitchHapticsDecoder::reset() {
|
|||
reset_actuator(actuators_[1]);
|
||||
}
|
||||
|
||||
void SwitchHapticsDecoder::append_sample(
|
||||
const ActuatorState& state, SwitchHapticsActuatorFrame& output) {
|
||||
if (output.sample_count >= 3) {
|
||||
return;
|
||||
}
|
||||
output.samples[output.sample_count++] = {
|
||||
state.low_frequency, state.high_frequency,
|
||||
SwitchHapticsTables::kAmplitudeQ15[state.low_amplitude],
|
||||
SwitchHapticsTables::kAmplitudeQ15[state.high_amplitude],
|
||||
};
|
||||
}
|
||||
|
||||
SwitchHapticsDecoder::AmplitudePeak SwitchHapticsDecoder::decode_actuator(
|
||||
ActuatorState& state, uint32_t word) {
|
||||
ActuatorState& state, uint32_t word, SwitchHapticsActuatorFrame& output) {
|
||||
output = {};
|
||||
if (word == 0 || word == kNeutralWord) {
|
||||
reset_actuator(state);
|
||||
state.last_word = word;
|
||||
state.have_last_word = true;
|
||||
append_sample(state, output);
|
||||
return {0, 0};
|
||||
}
|
||||
|
||||
if (state.have_last_word && state.last_word == word) {
|
||||
append_sample(state, output);
|
||||
return {state.low_amplitude, state.high_amplitude};
|
||||
}
|
||||
state.last_word = word;
|
||||
|
|
@ -171,10 +186,12 @@ SwitchHapticsDecoder::AmplitudePeak SwitchHapticsDecoder::decode_actuator(
|
|||
|
||||
if (frame_count == 0) {
|
||||
state.high_amplitude = 0;
|
||||
append_sample(state, output);
|
||||
return {state.low_amplitude, 0};
|
||||
}
|
||||
|
||||
const auto record_sample = [&]() {
|
||||
append_sample(state, output);
|
||||
if (state.low_amplitude > peak.low) {
|
||||
peak.low = state.low_amplitude;
|
||||
}
|
||||
|
|
@ -269,37 +286,33 @@ SwitchHapticsDecoder::AmplitudePeak SwitchHapticsDecoder::decode_actuator(
|
|||
}
|
||||
|
||||
if (!decoded) {
|
||||
append_sample(state, output);
|
||||
return {state.low_amplitude, state.high_amplitude};
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
uint8_t SwitchHapticsDecoder::amplitude_to_magnitude(uint8_t amplitude_index) {
|
||||
if (amplitude_index < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const double exponent = -8.0 + static_cast<double>(amplitude_index) / 32.0;
|
||||
const double scaled = std::exp2(exponent) * 255.0;
|
||||
unsigned magnitude = static_cast<unsigned>(scaled + 0.5);
|
||||
if (magnitude > 255u) {
|
||||
magnitude = 255u;
|
||||
}
|
||||
return static_cast<uint8_t>(magnitude);
|
||||
return SwitchHapticsTables::kMagnitude[amplitude_index];
|
||||
}
|
||||
|
||||
ControllerRumbleOutput SwitchHapticsDecoder::decode(const uint8_t payload[8]) {
|
||||
ControllerRumbleOutput output{};
|
||||
AmplitudePeak peaks[2] = {
|
||||
{actuators_[0].low_amplitude, actuators_[0].high_amplitude},
|
||||
{actuators_[1].low_amplitude, actuators_[1].high_amplitude},
|
||||
};
|
||||
|
||||
if (payload != nullptr) {
|
||||
peaks[0] = decode_actuator(actuators_[0], load_little_endian_word(payload));
|
||||
peaks[1] = decode_actuator(actuators_[1], load_little_endian_word(payload + 4));
|
||||
peaks[0] = decode_actuator(
|
||||
actuators_[0], load_little_endian_word(payload), output.hd.actuators[0]);
|
||||
peaks[1] = decode_actuator(
|
||||
actuators_[1], load_little_endian_word(payload + 4), output.hd.actuators[1]);
|
||||
}
|
||||
|
||||
const uint8_t low_peak = peaks[0].low > peaks[1].low ? peaks[0].low : peaks[1].low;
|
||||
const uint8_t high_peak = peaks[0].high > peaks[1].high ? peaks[0].high : peaks[1].high;
|
||||
return {amplitude_to_magnitude(low_peak), amplitude_to_magnitude(high_peak)};
|
||||
output.low_frequency_magnitude = amplitude_to_magnitude(low_peak);
|
||||
output.high_frequency_magnitude = amplitude_to_magnitude(high_peak);
|
||||
return output;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,31 @@
|
|||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// Decoded indices are logarithmic frequencies; amplitudes are linear Q0.15.
|
||||
// Low/high frequency index 64 means 160/320 Hz respectively.
|
||||
struct SwitchHapticsSample {
|
||||
uint8_t low_frequency_index = 64;
|
||||
uint8_t high_frequency_index = 64;
|
||||
uint16_t low_amplitude_q15 = 0;
|
||||
uint16_t high_amplitude_q15 = 0;
|
||||
};
|
||||
|
||||
struct SwitchHapticsActuatorFrame {
|
||||
uint8_t sample_count = 0;
|
||||
SwitchHapticsSample samples[3]{};
|
||||
};
|
||||
|
||||
struct SwitchHapticsFrame {
|
||||
SwitchHapticsActuatorFrame actuators[2]{}; // Left, right.
|
||||
};
|
||||
|
||||
struct ControllerRumbleOutput {
|
||||
uint8_t low_frequency_magnitude;
|
||||
uint8_t high_frequency_magnitude;
|
||||
// Counts are zero for conventional rumble (XInput/UART/local feedback).
|
||||
// Switch packets carry ordered per-side substeps in addition to the
|
||||
// compatibility magnitudes consumed by existing non-native backends.
|
||||
SwitchHapticsFrame hd{};
|
||||
};
|
||||
typedef void (*ControllerRumbleCallback)(
|
||||
uint8_t instance, const ControllerRumbleOutput& rumble);
|
||||
|
|
@ -40,7 +62,10 @@ private:
|
|||
};
|
||||
|
||||
static void reset_actuator(ActuatorState& state);
|
||||
static AmplitudePeak decode_actuator(ActuatorState& state, uint32_t word);
|
||||
static AmplitudePeak decode_actuator(ActuatorState& state, uint32_t word,
|
||||
SwitchHapticsActuatorFrame& output);
|
||||
static void append_sample(const ActuatorState& state,
|
||||
SwitchHapticsActuatorFrame& output);
|
||||
static uint8_t amplitude_to_magnitude(uint8_t amplitude_index);
|
||||
|
||||
ActuatorState actuators_[2];
|
||||
|
|
|
|||
44
src/firmware/usb/switch/switch_haptics_amplitudes.h
Normal file
44
src/firmware/usb/switch/switch_haptics_amplitudes.h
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// Generated from the decoder's existing envelope: round(2^(-8+i/32)*scale),
|
||||
// with indices 0 and 1 silent. Avoid transcendental math on USB report delivery.
|
||||
namespace SwitchHapticsTables {
|
||||
constexpr uint16_t kAmplitudeQ15[256] = {
|
||||
0, 0, 134, 137, 140, 143, 146, 149, 152, 156, 159, 162, 166, 170, 173, 177,
|
||||
181, 185, 189, 193, 197, 202, 206, 211, 215, 220, 225, 230, 235, 240, 245, 251,
|
||||
256, 262, 267, 273, 279, 285, 292, 298, 304, 311, 318, 325, 332, 339, 347, 354,
|
||||
362, 370, 378, 386, 395, 403, 412, 421, 431, 440, 450, 459, 470, 480, 490, 501,
|
||||
512, 523, 535, 546, 558, 571, 583, 596, 609, 622, 636, 650, 664, 679, 693, 709,
|
||||
724, 740, 756, 773, 790, 807, 825, 843, 861, 880, 899, 919, 939, 960, 981, 1002,
|
||||
1024, 1046, 1069, 1093, 1117, 1141, 1166, 1192, 1218, 1244, 1272, 1300, 1328, 1357, 1387, 1417,
|
||||
1448, 1480, 1512, 1545, 1579, 1614, 1649, 1685, 1722, 1760, 1798, 1838, 1878, 1919, 1961, 2004,
|
||||
2048, 2093, 2139, 2186, 2233, 2282, 2332, 2383, 2435, 2489, 2543, 2599, 2656, 2714, 2774, 2834,
|
||||
2896, 2960, 3025, 3091, 3158, 3228, 3298, 3371, 3444, 3520, 3597, 3676, 3756, 3838, 3922, 4008,
|
||||
4096, 4186, 4277, 4371, 4467, 4565, 4664, 4767, 4871, 4978, 5087, 5198, 5312, 5428, 5547, 5668,
|
||||
5793, 5919, 6049, 6182, 6317, 6455, 6597, 6741, 6889, 7039, 7194, 7351, 7512, 7677, 7845, 8016,
|
||||
8192, 8371, 8555, 8742, 8933, 9129, 9329, 9533, 9742, 9955, 10173, 10396, 10624, 10856, 11094, 11337,
|
||||
11585, 11839, 12098, 12363, 12634, 12910, 13193, 13482, 13777, 14079, 14387, 14702, 15024, 15353, 15689, 16033,
|
||||
16384, 16743, 17109, 17484, 17867, 18258, 18658, 19066, 19484, 19911, 20347, 20792, 21247, 21713, 22188, 22674,
|
||||
23170, 23678, 24196, 24726, 25268, 25821, 26386, 26964, 27554, 28158, 28774, 29405, 30048, 30706, 31379, 32066,
|
||||
};
|
||||
constexpr uint8_t kMagnitude[256] = {
|
||||
0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
||||
1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
|
||||
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3,
|
||||
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4,
|
||||
4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6,
|
||||
6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 8, 8,
|
||||
8, 8, 8, 9, 9, 9, 9, 9, 9, 10, 10, 10, 10, 11, 11, 11,
|
||||
11, 12, 12, 12, 12, 13, 13, 13, 13, 14, 14, 14, 15, 15, 15, 16,
|
||||
16, 16, 17, 17, 17, 18, 18, 19, 19, 19, 20, 20, 21, 21, 22, 22,
|
||||
23, 23, 24, 24, 25, 25, 26, 26, 27, 27, 28, 29, 29, 30, 31, 31,
|
||||
32, 33, 33, 34, 35, 36, 36, 37, 38, 39, 40, 40, 41, 42, 43, 44,
|
||||
45, 46, 47, 48, 49, 50, 51, 52, 54, 55, 56, 57, 58, 60, 61, 62,
|
||||
64, 65, 67, 68, 70, 71, 73, 74, 76, 77, 79, 81, 83, 84, 86, 88,
|
||||
90, 92, 94, 96, 98, 100, 103, 105, 107, 110, 112, 114, 117, 119, 122, 125,
|
||||
128, 130, 133, 136, 139, 142, 145, 148, 152, 155, 158, 162, 165, 169, 173, 176,
|
||||
180, 184, 188, 192, 197, 201, 205, 210, 214, 219, 224, 229, 234, 239, 244, 250,
|
||||
};
|
||||
} // namespace SwitchHapticsTables
|
||||
|
|
@ -47,6 +47,8 @@ struct SwitchProContext {
|
|||
SwitchProReport switch_report{};
|
||||
uint8_t last_report_counter = 0;
|
||||
uint32_t last_report_timer = 0;
|
||||
uint32_t last_imu_report_timer = 0; // Logical 15 ms cadence, not ACK timing.
|
||||
bool last_report_was_reply = false;
|
||||
bool is_ready = false;
|
||||
bool is_initialized = false;
|
||||
bool is_report_queued = false;
|
||||
|
|
@ -430,6 +432,8 @@ static void reset_context_runtime(SwitchProContext& context, uint32_t now,
|
|||
update_switch_report_from_state(context);
|
||||
context.last_report_counter = 0;
|
||||
context.last_report_timer = now;
|
||||
context.last_imu_report_timer = now;
|
||||
context.last_report_was_reply = false;
|
||||
context.is_ready = ready_before_mount;
|
||||
context.is_initialized = ready_before_mount;
|
||||
context.is_report_queued = false;
|
||||
|
|
@ -466,8 +470,9 @@ static bool send_report(uint8_t instance, SwitchProContext& context,
|
|||
uint16_t report_length) {
|
||||
bool result =
|
||||
tud_hid_n_report(instance, report_id, report_data, report_length);
|
||||
++context.last_report_counter;
|
||||
if (!result) {
|
||||
if (result) {
|
||||
++context.last_report_counter;
|
||||
} else {
|
||||
LOG_PRINTF("[HID %u] send_report failed id=%u len=%u\n", instance,
|
||||
report_id, report_length);
|
||||
}
|
||||
|
|
@ -832,59 +837,76 @@ bool switch_pro_task(uint8_t instance) {
|
|||
return false;
|
||||
}
|
||||
|
||||
uint32_t now = to_ms_since_boot(get_absolute_time());
|
||||
bool report_sent = false;
|
||||
bool regular_report_sent = false;
|
||||
|
||||
const uint32_t now = to_ms_since_boot(get_absolute_time());
|
||||
update_switch_report_from_state(*context);
|
||||
|
||||
if (tud_suspended()) {
|
||||
tud_remote_wakeup();
|
||||
}
|
||||
|
||||
if (context->is_report_queued) {
|
||||
if ((now - context->last_report_timer) >
|
||||
SWITCH_PRO_KEEPALIVE_TIMER) {
|
||||
if (tud_hid_n_ready(instance) &&
|
||||
send_report(instance, *context, 0, context->report_buffer,
|
||||
// Busy USB is not a sent sample. Keep the overdue deadline and the
|
||||
// unconsumed sensor state so the next ready opportunity uses fresh input.
|
||||
if (!tud_hid_n_ready(instance)) {
|
||||
return false;
|
||||
}
|
||||
const bool imu_due = context->is_ready &&
|
||||
(now - context->last_imu_report_timer) >= SWITCH_PRO_IMU_REPORT_TIMER;
|
||||
const bool prefer_imu = imu_due && context->last_report_was_reply &&
|
||||
context->report_buffer[0] == REPORT_OUTPUT_21;
|
||||
if (context->is_report_queued && !prefer_imu) {
|
||||
if ((now - context->last_report_timer) > SWITCH_PRO_KEEPALIVE_TIMER) {
|
||||
// A due IMU report may have interleaved since this ACK was built.
|
||||
if (context->report_buffer[0] == REPORT_OUTPUT_21) {
|
||||
context->report_buffer[1] = context->last_report_counter;
|
||||
}
|
||||
if (send_report(instance, *context, 0, context->report_buffer,
|
||||
SWITCH_PRO_ENDPOINT_SIZE)) {
|
||||
context->is_report_queued = false;
|
||||
context->last_report_timer = now;
|
||||
context->last_report_was_reply =
|
||||
context->report_buffer[0] == REPORT_OUTPUT_21;
|
||||
}
|
||||
}
|
||||
report_sent = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context->is_ready && !report_sent) {
|
||||
if ((now - context->last_report_timer) >=
|
||||
SWITCH_PRO_IMU_REPORT_TIMER) {
|
||||
// One timer tick per 5ms IMU frame; three frames per report.
|
||||
fill_imu_report_data(*context, context->input_state, now);
|
||||
context->switch_report.timestamp += 3;
|
||||
if (tud_hid_n_ready(instance) &&
|
||||
send_report(instance, *context, 0, &context->switch_report,
|
||||
sizeof(context->switch_report))) {
|
||||
context->input_state.motion_sample_count = 0;
|
||||
regular_report_sent = true;
|
||||
}
|
||||
if (imu_due) {
|
||||
const uint32_t periods =
|
||||
(now - context->last_imu_report_timer) / SWITCH_PRO_IMU_REPORT_TIMER;
|
||||
const MotionQuaternion previous_quaternion = context->motion_quaternion;
|
||||
const uint8_t previous_timestamp = context->switch_report.timestamp;
|
||||
uint8_t previous_imu[sizeof(context->switch_report.imuData)];
|
||||
memcpy(previous_imu, context->switch_report.imuData, sizeof(previous_imu));
|
||||
fill_imu_report_data(*context, context->input_state, now);
|
||||
context->switch_report.timestamp += static_cast<uint8_t>(periods * 3);
|
||||
if (send_report(instance, *context, 0, &context->switch_report,
|
||||
sizeof(context->switch_report))) {
|
||||
context->input_state.motion_sample_count = 0;
|
||||
// Stay on the 15 ms clock across 8 ms USB polling quantization.
|
||||
// Long stalls skip obsolete periods, never replay a motion burst.
|
||||
context->last_imu_report_timer += periods * SWITCH_PRO_IMU_REPORT_TIMER;
|
||||
context->last_report_timer = now;
|
||||
context->last_report_was_reply = false;
|
||||
return true;
|
||||
}
|
||||
} else if (!context->is_initialized) {
|
||||
send_identify(*context);
|
||||
if (tud_hid_n_ready(instance)) {
|
||||
bool result = tud_hid_n_report(
|
||||
instance, 0, context->report_buffer,
|
||||
SWITCH_PRO_ENDPOINT_SIZE);
|
||||
if (result) {
|
||||
context->is_initialized = true;
|
||||
} else {
|
||||
LOG_PRINTF("[HID %u] send_report failed id=0 len=%u\n",
|
||||
instance, SWITCH_PRO_ENDPOINT_SIZE);
|
||||
}
|
||||
}
|
||||
context->last_report_timer = now;
|
||||
// Readiness can change before queuing. Failed transmission must not
|
||||
// advance quaternion integration or expose an unsent timestamp.
|
||||
context->motion_quaternion = previous_quaternion;
|
||||
context->switch_report.timestamp = previous_timestamp;
|
||||
memcpy(context->switch_report.imuData, previous_imu, sizeof(previous_imu));
|
||||
return false;
|
||||
}
|
||||
return regular_report_sent;
|
||||
|
||||
if (!context->is_initialized) {
|
||||
send_identify(*context);
|
||||
if (tud_hid_n_report(instance, 0, context->report_buffer,
|
||||
SWITCH_PRO_ENDPOINT_SIZE)) {
|
||||
context->is_initialized = true;
|
||||
context->last_report_timer = now;
|
||||
} else {
|
||||
LOG_PRINTF("[HID %u] send_report failed id=0 len=%u\n", instance,
|
||||
SWITCH_PRO_ENDPOINT_SIZE);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length,
|
||||
|
|
|
|||
|
|
@ -5,12 +5,19 @@
|
|||
#include "adapter/adapter_host_probe.h"
|
||||
#include "adapter/adapter_reboot.h"
|
||||
#include "adapter/adapter_usb_mode.h"
|
||||
#include "input/haptics_experiment.h"
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
#include "input/haptics_transport_probe.h"
|
||||
#endif
|
||||
#include "tusb.h"
|
||||
#include "usb/usb_output_driver.h"
|
||||
|
||||
namespace UsbConfigurationManagement {
|
||||
namespace {
|
||||
|
||||
uint32_t g_capture_read_run = 0;
|
||||
uint16_t g_capture_read_index = 0;
|
||||
|
||||
uint16_t read_u16(const uint8_t* input) {
|
||||
return static_cast<uint16_t>(input[0]) |
|
||||
static_cast<uint16_t>(input[1] << 8);
|
||||
|
|
@ -74,6 +81,10 @@ Status profile_service_status(const ProfileServiceMetadata& metadata) {
|
|||
|
||||
bool valid_out_size(Operation operation, size_t size) {
|
||||
switch (operation) {
|
||||
case Operation::kMacroCapture:
|
||||
return size == kRequestHeaderSize + 5 ||
|
||||
size == kRequestHeaderSize + 7 ||
|
||||
size == kRequestHeaderSize + 16;
|
||||
case Operation::kModeSet:
|
||||
return size == kRequestHeaderSize + 5;
|
||||
case Operation::kReboot:
|
||||
|
|
@ -100,9 +111,22 @@ bool valid_out_size(Operation operation, size_t size) {
|
|||
case Operation::kProfileReset:
|
||||
case Operation::kProfileActivate:
|
||||
return size == kRequestHeaderSize + 19;
|
||||
case Operation::kProfileMetadataSet:
|
||||
return size >= kRequestHeaderSize + 20 &&
|
||||
size <= kRequestHeaderSize + 20 +
|
||||
PROFILE_SERVICE_METADATA_MAX_BYTES;
|
||||
case Operation::kProfileIdentify:
|
||||
return size == kRequestHeaderSize +
|
||||
CONTROLLER_IDENTITY_ENCODED_SIZE;
|
||||
case Operation::kPairingRefresh:
|
||||
case Operation::kPairingClear:
|
||||
return size == kRequestHeaderSize;
|
||||
case Operation::kHapticsExperiment:
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
return size == kRequestHeaderSize + 2;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
|
@ -183,6 +207,149 @@ size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) {
|
|||
output_size);
|
||||
}
|
||||
|
||||
size_t encode_haptics_experiment(uint8_t* output, size_t output_size) {
|
||||
HapticsExperimentDiagnostics diagnostics{};
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
haptics_experiment_snapshot(&diagnostics);
|
||||
#else
|
||||
diagnostics.state = HapticsExperimentState::kUnsupported;
|
||||
#endif
|
||||
uint8_t payload[kHapticsExperimentPayloadSize]{};
|
||||
write_u32(&payload[0], diagnostics.run_id);
|
||||
write_u32(&payload[4], diagnostics.connection_generation);
|
||||
write_u32(&payload[8], diagnostics.start_us);
|
||||
write_u32(&payload[12], diagnostics.generated_packets);
|
||||
write_u32(&payload[16], diagnostics.sent_packets);
|
||||
write_u32(&payload[20], diagnostics.skipped_packets);
|
||||
write_u32(&payload[24], diagnostics.send_failures);
|
||||
write_u32(&payload[28], diagnostics.can_send_requests);
|
||||
write_u32(&payload[32], diagnostics.synchronous_callbacks);
|
||||
write_u32(&payload[36], diagnostics.max_generate_us);
|
||||
write_u32(&payload[40], diagnostics.max_send_gap_us);
|
||||
write_u32(&payload[44], diagnostics.max_lateness_us);
|
||||
write_u32(&payload[48], diagnostics.max_request_wait_us);
|
||||
write_u32(&payload[52], diagnostics.first_tone_due_us);
|
||||
write_u32(&payload[56], diagnostics.first_tone_sent_us);
|
||||
write_u32(&payload[60], diagnostics.last_sent_us);
|
||||
write_u32(&payload[64], diagnostics.elapsed_us);
|
||||
payload[68] = static_cast<uint8_t>(diagnostics.state);
|
||||
payload[69] = diagnostics.slot;
|
||||
payload[70] = diagnostics.last_error;
|
||||
payload[72] = diagnostics.mode;
|
||||
write_u32(&payload[76], diagnostics.host_updates);
|
||||
write_u32(&payload[80], diagnostics.dropped_updates);
|
||||
payload[73] = diagnostics.packet_frames;
|
||||
payload[74] = diagnostics.last_packet_nonzero ? 1 : 0;
|
||||
return encode_response(
|
||||
Operation::kHapticsExperiment, Status::kOk, 0,
|
||||
kHapticsExperimentSchemaVersion, diagnostics.run_id,
|
||||
payload, sizeof(payload), output, output_size);
|
||||
}
|
||||
|
||||
size_t encode_macro_capture(uint8_t* output, size_t output_size) {
|
||||
Bluepad32CaptureSnapshot capture{};
|
||||
if (!bluepad32_input_backend_capture_page(
|
||||
g_capture_read_run, g_capture_read_index, &capture)) {
|
||||
return encode_response(Operation::kMacroCapture, Status::kMalformed, 0,
|
||||
kMacroCaptureSchemaVersion, 0,
|
||||
nullptr, 0, output, output_size);
|
||||
}
|
||||
uint8_t payload[kMacroCaptureHeaderSize +
|
||||
BLUEPAD32_CAPTURE_PAGE_EVENTS * kMacroCaptureEventSize]{};
|
||||
write_u32(payload, capture.run_id);
|
||||
write_u32(payload + 4, capture.connection_generation);
|
||||
write_u32(payload + 8, capture.elapsed_us);
|
||||
payload[12] = capture.slot;
|
||||
payload[13] = static_cast<uint8_t>(capture.state);
|
||||
payload[14] = capture.options.channels;
|
||||
payload[15] = capture.event_count;
|
||||
write_u16(payload + 16, capture.total_events);
|
||||
write_u16(payload + 18, capture.first_index);
|
||||
write_u16(payload + 20, capture.options.axis_quantum);
|
||||
write_u16(payload + 22, capture.options.trigger_quantum);
|
||||
write_u32(payload + 24, capture.options.max_duration_ms);
|
||||
payload[28] = capture.options.max_events;
|
||||
for (uint8_t index = 0; index < capture.event_count; ++index) {
|
||||
const CaptureEvent& event = capture.events[index];
|
||||
uint8_t* encoded = payload + kMacroCaptureHeaderSize +
|
||||
index * kMacroCaptureEventSize;
|
||||
write_u32(encoded, event.at_us);
|
||||
write_u16(encoded + 4, event.buttons);
|
||||
write_u16(encoded + 6, static_cast<uint16_t>(event.left_x));
|
||||
write_u16(encoded + 8, static_cast<uint16_t>(event.left_y));
|
||||
write_u16(encoded + 10, static_cast<uint16_t>(event.right_x));
|
||||
write_u16(encoded + 12, static_cast<uint16_t>(event.right_y));
|
||||
write_u16(encoded + 14, event.left_trigger);
|
||||
write_u16(encoded + 16, event.right_trigger);
|
||||
}
|
||||
return encode_response(
|
||||
Operation::kMacroCapture, Status::kOk, 0, kMacroCaptureSchemaVersion,
|
||||
capture.run_id, payload,
|
||||
kMacroCaptureHeaderSize + capture.event_count * kMacroCaptureEventSize,
|
||||
output, output_size);
|
||||
}
|
||||
|
||||
size_t encode_haptics_transport_probe(uint8_t* output, size_t output_size) {
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
HapticsTransportProbe probe{};
|
||||
haptics_transport_probe_snapshot(&probe);
|
||||
uint8_t payload[kHapticsTransportProbePayloadSize]{};
|
||||
write_u32(&payload[0], probe.run_id);
|
||||
write_u32(&payload[4], probe.connection_generation);
|
||||
write_u32(&payload[8], probe.connection_handle);
|
||||
write_u32(&payload[12], probe.timer_wakes);
|
||||
write_u32(&payload[16], probe.max_timer_lateness_us);
|
||||
write_u32(&payload[20], probe.total_timer_lateness_us);
|
||||
write_u32(&payload[24], probe.send_calls);
|
||||
write_u32(&payload[28], probe.max_send_us);
|
||||
write_u32(&payload[32], probe.total_send_us);
|
||||
write_u32(&payload[36], probe.write_calls);
|
||||
write_u32(&payload[40], probe.max_write_us);
|
||||
write_u32(&payload[44], probe.total_write_us);
|
||||
write_u32(&payload[48], probe.read_calls);
|
||||
write_u32(&payload[52], probe.read_packets);
|
||||
write_u32(&payload[56], probe.max_read_us);
|
||||
write_u32(&payload[60], probe.total_read_us);
|
||||
write_u32(&payload[64], probe.poll_calls);
|
||||
write_u32(&payload[68], probe.max_poll_us);
|
||||
write_u32(&payload[72], probe.total_poll_us);
|
||||
write_u32(&payload[76], probe.completion_events);
|
||||
write_u32(&payload[80], probe.completed_packets);
|
||||
write_u32(&payload[84], probe.max_completion_gap_us);
|
||||
write_u32(&payload[88], probe.max_outstanding_acl);
|
||||
write_u32(&payload[92], probe.min_free_acl);
|
||||
write_u32(&payload[96], probe.first_tone_send_return_us);
|
||||
write_u32(&payload[100], probe.active);
|
||||
write_u32(&payload[104], probe.max_permission_wait_us);
|
||||
write_u32(&payload[108], probe.total_permission_wait_us);
|
||||
write_u32(&payload[112], probe.permission_callbacks);
|
||||
write_u32(&payload[116], probe.max_poll_gap_us);
|
||||
write_u32(&payload[120], probe.controller_acl_packet_bytes);
|
||||
write_u32(&payload[124], probe.controller_acl_packet_count);
|
||||
write_u32(&payload[128], probe.requested_sys_khz);
|
||||
write_u32(&payload[132], probe.measured_sys_khz);
|
||||
write_u32(&payload[136], probe.measured_usb_khz);
|
||||
write_u32(&payload[140], probe.core_voltage_mv);
|
||||
write_u32(&payload[144], probe.flash_clock_divider);
|
||||
write_u32(&payload[148], probe.cyw43_pio_divider256);
|
||||
write_u32(&payload[152], static_cast<uint32_t>(probe.temperature_millicelsius));
|
||||
write_u32(&payload[156], probe.host_completed_writes);
|
||||
write_u32(&payload[160], probe.acl_writes);
|
||||
write_u32(&payload[164], probe.other_writes);
|
||||
write_u32(&payload[168], probe.write_failures);
|
||||
write_u32(&payload[172], probe.packet_read_optimized);
|
||||
return encode_response(
|
||||
Operation::kHapticsTransportProbe, Status::kOk, 0,
|
||||
kHapticsTransportProbeSchemaVersion, probe.run_id,
|
||||
payload, sizeof(payload), output, output_size);
|
||||
#else
|
||||
return encode_response(
|
||||
Operation::kHapticsTransportProbe, Status::kUnsupportedSchema, 0,
|
||||
kHapticsTransportProbeSchemaVersion, 0,
|
||||
nullptr, 0, output, output_size);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool decode_request(Operation setup_operation, const uint8_t* input,
|
||||
|
|
@ -271,15 +438,20 @@ size_t encode_profile_list(const ProfileServiceListSnapshot& snapshot,
|
|||
payload[0] = snapshot.count;
|
||||
size_t offset = 1;
|
||||
for (uint8_t index = 0; index < snapshot.count; ++index) {
|
||||
const size_t alias_size = strlen(snapshot.rows[index].alias);
|
||||
if (!controller_identity_encode(snapshot.rows[index].identity,
|
||||
&payload[offset],
|
||||
CONTROLLER_IDENTITY_ENCODED_SIZE) ||
|
||||
snapshot.rows[index].active_profile >=
|
||||
CONTROLLER_PROFILE_COUNT) {
|
||||
CONTROLLER_PROFILE_COUNT ||
|
||||
alias_size > PROFILE_SERVICE_METADATA_MAX_BYTES) {
|
||||
return 0;
|
||||
}
|
||||
payload[offset + 14] = snapshot.rows[index].active_profile;
|
||||
offset += 16;
|
||||
payload[offset + 16] = static_cast<uint8_t>(alias_size);
|
||||
memcpy(&payload[offset + 17], snapshot.rows[index].alias,
|
||||
alias_size);
|
||||
offset += kProfileListRowSize;
|
||||
}
|
||||
return encode_response(
|
||||
Operation::kProfileList, profile_service_status(snapshot.metadata),
|
||||
|
|
@ -287,6 +459,58 @@ size_t encode_profile_list(const ProfileServiceListSnapshot& snapshot,
|
|||
payload, offset, output, output_size);
|
||||
}
|
||||
|
||||
size_t encode_profile_playtest(
|
||||
uint8_t slot, const Bluepad32PlaytestSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size) {
|
||||
uint8_t payload[kProfilePlaytestPayloadSize]{};
|
||||
payload[1] = 0xff;
|
||||
if (snapshot.active) {
|
||||
if (slot >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT ||
|
||||
!controller_identity_encode(
|
||||
snapshot.identity, &payload[12],
|
||||
CONTROLLER_IDENTITY_ENCODED_SIZE) ||
|
||||
snapshot.state.motion_sample_count >
|
||||
CONTROLLER_MOTION_SAMPLE_CAPACITY) {
|
||||
return 0;
|
||||
}
|
||||
payload[0] = 1;
|
||||
payload[1] = slot;
|
||||
write_u16(&payload[2], snapshot.physical_button_mask);
|
||||
write_u32(&payload[4], snapshot.connection_generation);
|
||||
write_u32(&payload[8], snapshot.state_generation);
|
||||
write_u16(&payload[26],
|
||||
static_cast<uint16_t>(snapshot.state.left_stick_x));
|
||||
write_u16(&payload[28],
|
||||
static_cast<uint16_t>(snapshot.state.left_stick_y));
|
||||
write_u16(&payload[30],
|
||||
static_cast<uint16_t>(snapshot.state.right_stick_x));
|
||||
write_u16(&payload[32],
|
||||
static_cast<uint16_t>(snapshot.state.right_stick_y));
|
||||
write_u16(&payload[34], snapshot.state.left_trigger);
|
||||
write_u16(&payload[36], snapshot.state.right_trigger);
|
||||
payload[38] = snapshot.state.motion_sample_count;
|
||||
payload[39] = snapshot.battery;
|
||||
payload[40] = snapshot.capabilities;
|
||||
if (snapshot.state.motion_sample_count != 0) {
|
||||
payload[0] |= 2;
|
||||
const ControllerMotionSample& motion =
|
||||
snapshot.state.motion_samples[
|
||||
snapshot.state.motion_sample_count - 1u];
|
||||
write_u16(&payload[42], static_cast<uint16_t>(motion.accel_x));
|
||||
write_u16(&payload[44], static_cast<uint16_t>(motion.accel_y));
|
||||
write_u16(&payload[46], static_cast<uint16_t>(motion.accel_z));
|
||||
write_u16(&payload[48], static_cast<uint16_t>(motion.gyro_x));
|
||||
write_u16(&payload[50], static_cast<uint16_t>(motion.gyro_y));
|
||||
write_u16(&payload[52], static_cast<uint16_t>(motion.gyro_z));
|
||||
}
|
||||
}
|
||||
return encode_response(
|
||||
Operation::kProfilePlaytest, Status::kOk, payload[0],
|
||||
kProfilePlaytestSchemaVersion,
|
||||
snapshot.active ? snapshot.state_generation : 0,
|
||||
payload, sizeof(payload), output, output_size);
|
||||
}
|
||||
|
||||
size_t encode_profile_read(const ProfileServiceSelectedSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size) {
|
||||
Status status = profile_service_status(snapshot.metadata);
|
||||
|
|
@ -308,6 +532,41 @@ size_t encode_profile_read(const ProfileServiceSelectedSnapshot& snapshot,
|
|||
payload, payload_size, output, output_size);
|
||||
}
|
||||
|
||||
size_t encode_profile_metadata(
|
||||
const ProfileServiceMetadataSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size) {
|
||||
Status status = profile_service_status(snapshot.metadata);
|
||||
if (status == Status::kOk) {
|
||||
status = transaction_status(snapshot.status);
|
||||
}
|
||||
uint8_t payload[kProfileMetadataPayloadSize]{};
|
||||
if (snapshot.valid) {
|
||||
const char* values[CONTROLLER_PROFILE_COUNT + 1] = {
|
||||
snapshot.alias,
|
||||
snapshot.profile_names[0], snapshot.profile_names[1],
|
||||
snapshot.profile_names[2], snapshot.profile_names[3],
|
||||
snapshot.profile_names[4], snapshot.profile_names[5],
|
||||
snapshot.profile_names[6], snapshot.profile_names[7],
|
||||
};
|
||||
for (size_t index = 0;
|
||||
index < CONTROLLER_PROFILE_COUNT + 1; ++index) {
|
||||
const size_t size = strlen(values[index]);
|
||||
if (size > PROFILE_SERVICE_METADATA_MAX_BYTES) {
|
||||
return 0;
|
||||
}
|
||||
const size_t offset =
|
||||
index * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1);
|
||||
payload[offset] = static_cast<uint8_t>(size);
|
||||
memcpy(&payload[offset + 1], values[index], size);
|
||||
}
|
||||
}
|
||||
return encode_response(
|
||||
Operation::kProfileMetadataRead, status, 0,
|
||||
kProfileMetadataSchemaVersion, snapshot.metadata.generation,
|
||||
payload, snapshot.valid ? sizeof(payload) : 0,
|
||||
output, output_size);
|
||||
}
|
||||
|
||||
size_t encode_profile_transaction(
|
||||
const ProfileServiceTransactionSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size) {
|
||||
|
|
@ -339,6 +598,7 @@ uint8_t g_request_buffer[
|
|||
UsbConfigurationManagement::Operation g_pending_operation =
|
||||
UsbConfigurationManagement::Operation::kInfo;
|
||||
bool g_out_pending = false;
|
||||
bool g_out_processed = false;
|
||||
size_t g_pending_request_size = 0;
|
||||
|
||||
bool process_out_request() {
|
||||
|
|
@ -351,6 +611,35 @@ bool process_out_request() {
|
|||
|
||||
const uint8_t* payload = request.payload;
|
||||
switch (request.operation) {
|
||||
case Operation::kMacroCapture: {
|
||||
if (request.payload_size == 16 && payload[0] == 1) {
|
||||
CaptureOptions options{};
|
||||
options.channels = payload[6];
|
||||
options.max_events = payload[7];
|
||||
options.axis_quantum = UsbConfigurationManagement::read_u16(payload + 8);
|
||||
options.trigger_quantum = UsbConfigurationManagement::read_u16(payload + 10);
|
||||
options.max_duration_ms = UsbConfigurationManagement::read_u32(payload + 12);
|
||||
if (!bluepad32_input_backend_capture_start(
|
||||
payload[1], UsbConfigurationManagement::read_u32(payload + 2), options)) return false;
|
||||
g_capture_read_run = 0;
|
||||
g_capture_read_index = 0;
|
||||
return true;
|
||||
}
|
||||
if (request.payload_size == 5 && payload[0] == 0) {
|
||||
return bluepad32_input_backend_capture_stop(UsbConfigurationManagement::read_u32(payload + 1));
|
||||
}
|
||||
if (request.payload_size == 7 && payload[0] == 2) {
|
||||
const uint32_t run = UsbConfigurationManagement::read_u32(payload + 1);
|
||||
const uint16_t index = UsbConfigurationManagement::read_u16(payload + 5);
|
||||
Bluepad32CaptureSnapshot snapshot{};
|
||||
if (!bluepad32_input_backend_capture_page(run, index, &snapshot))
|
||||
return false;
|
||||
g_capture_read_run = run;
|
||||
g_capture_read_index = index;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case Operation::kModeSet: {
|
||||
const uint32_t transaction_id =
|
||||
static_cast<uint32_t>(payload[0]) |
|
||||
|
|
@ -525,12 +814,58 @@ bool process_out_request() {
|
|||
status == ConfigurationTransactionStatus::kUnchanged ||
|
||||
status == ConfigurationTransactionStatus::kCommitted;
|
||||
}
|
||||
case Operation::kProfileMetadataSet: {
|
||||
const uint32_t transaction_id =
|
||||
static_cast<uint32_t>(payload[0]) |
|
||||
(static_cast<uint32_t>(payload[1]) << 8) |
|
||||
(static_cast<uint32_t>(payload[2]) << 16) |
|
||||
(static_cast<uint32_t>(payload[3]) << 24);
|
||||
ControllerIdentity identity{};
|
||||
const uint8_t profile_index = payload[18];
|
||||
const size_t value_size = payload[19];
|
||||
if (transaction_id == 0 ||
|
||||
(profile_index != CONTROLLER_PROFILE_ALL &&
|
||||
profile_index >= CONTROLLER_PROFILE_COUNT) ||
|
||||
value_size > PROFILE_SERVICE_METADATA_MAX_BYTES ||
|
||||
request.payload_size != 20 + value_size ||
|
||||
!controller_identity_decode(
|
||||
&payload[4], CONTROLLER_IDENTITY_ENCODED_SIZE,
|
||||
&identity)) {
|
||||
return false;
|
||||
}
|
||||
const ConfigurationTransactionStatus status =
|
||||
profile_service_set_metadata(
|
||||
transaction_id, identity, profile_index,
|
||||
reinterpret_cast<const char*>(&payload[20]),
|
||||
value_size);
|
||||
return status == ConfigurationTransactionStatus::kPending ||
|
||||
status == ConfigurationTransactionStatus::kUnchanged ||
|
||||
status == ConfigurationTransactionStatus::kCommitted;
|
||||
}
|
||||
case Operation::kProfileIdentify: {
|
||||
ControllerIdentity identity{};
|
||||
return controller_identity_decode(
|
||||
payload, CONTROLLER_IDENTITY_ENCODED_SIZE,
|
||||
&identity) &&
|
||||
bluepad32_input_backend_identify(identity);
|
||||
}
|
||||
case Operation::kPairingRefresh:
|
||||
bluepad32_input_backend_request_pairing_snapshot();
|
||||
return true;
|
||||
case Operation::kPairingClear:
|
||||
bluepad32_input_backend_clear_pairings();
|
||||
return true;
|
||||
case Operation::kHapticsExperiment:
|
||||
if (request.payload_size != 2 ||
|
||||
payload[0] > 2 ||
|
||||
payload[1] >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT) {
|
||||
return false;
|
||||
}
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
return haptics_experiment_request(payload[0], payload[1]);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
|
@ -541,6 +876,11 @@ bool process_out_request() {
|
|||
bool usb_configuration_management_vendor_control(
|
||||
uint8_t rhport, uint8_t stage,
|
||||
tusb_control_request_t const* request) {
|
||||
if (stage == CONTROL_STAGE_SETUP) {
|
||||
g_out_pending = false;
|
||||
g_out_processed = false;
|
||||
g_pending_request_size = 0;
|
||||
}
|
||||
if (adapter_host_probe_vendor_control(rhport, stage, request)) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -555,6 +895,10 @@ bool usb_configuration_management_vendor_control(
|
|||
|
||||
const Operation operation =
|
||||
static_cast<Operation>(request->bRequest);
|
||||
if (operation == Operation::kHapticsTransportProbe &&
|
||||
request->bmRequestType_bit.direction != TUSB_DIR_IN) {
|
||||
return false;
|
||||
}
|
||||
if (stage == CONTROL_STAGE_ACK) {
|
||||
if (request->bmRequestType_bit.direction == TUSB_DIR_IN) {
|
||||
return true;
|
||||
|
|
@ -563,9 +907,22 @@ bool usb_configuration_management_vendor_control(
|
|||
return false;
|
||||
}
|
||||
g_out_pending = false;
|
||||
if (operation == Operation::kHapticsExperiment) {
|
||||
return g_out_processed;
|
||||
}
|
||||
return process_out_request();
|
||||
}
|
||||
if (stage == CONTROL_STAGE_DATA) {
|
||||
if (operation == Operation::kHapticsExperiment &&
|
||||
request->bmRequestType_bit.direction == TUSB_DIR_OUT) {
|
||||
if (!g_out_pending || operation != g_pending_operation ||
|
||||
g_out_processed) {
|
||||
return false;
|
||||
}
|
||||
// Reject before the USB status ACK, and never enqueue twice.
|
||||
g_out_processed = process_out_request();
|
||||
return g_out_processed;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (stage != CONTROL_STAGE_SETUP) {
|
||||
|
|
@ -579,6 +936,7 @@ bool usb_configuration_management_vendor_control(
|
|||
g_pending_operation = operation;
|
||||
g_pending_request_size = request->wLength;
|
||||
g_out_pending = true;
|
||||
g_out_processed = false;
|
||||
return tud_control_xfer(rhport, request, g_request_buffer,
|
||||
request->wLength);
|
||||
}
|
||||
|
|
@ -608,6 +966,17 @@ bool usb_configuration_management_vendor_control(
|
|||
response_size =
|
||||
encode_runtime_diagnostics(response, sizeof(response));
|
||||
break;
|
||||
case Operation::kHapticsExperiment:
|
||||
response_size =
|
||||
encode_haptics_experiment(response, sizeof(response));
|
||||
break;
|
||||
case Operation::kHapticsTransportProbe:
|
||||
response_size =
|
||||
encode_haptics_transport_probe(response, sizeof(response));
|
||||
break;
|
||||
case Operation::kMacroCapture:
|
||||
response_size = encode_macro_capture(response, sizeof(response));
|
||||
break;
|
||||
case Operation::kProfileList: {
|
||||
ProfileServiceListSnapshot snapshot{};
|
||||
profile_service_list_snapshot(&snapshot);
|
||||
|
|
@ -622,6 +991,37 @@ bool usb_configuration_management_vendor_control(
|
|||
snapshot, response, sizeof(response));
|
||||
break;
|
||||
}
|
||||
case Operation::kProfilePlaytest: {
|
||||
ProfileServiceSelectedSnapshot selected{};
|
||||
profile_service_selected_snapshot(&selected);
|
||||
Bluepad32PlaytestSnapshot playtest{};
|
||||
uint8_t selected_slot = 0xff;
|
||||
for (uint8_t slot = 0;
|
||||
slot < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++slot) {
|
||||
Bluepad32PlaytestSnapshot candidate{};
|
||||
bluepad32_input_backend_playtest_snapshot(
|
||||
slot, &candidate);
|
||||
if (!candidate.active ||
|
||||
(!controller_identity_is_global(selected.identity) &&
|
||||
!controller_identity_equal(
|
||||
selected.identity, candidate.identity))) {
|
||||
continue;
|
||||
}
|
||||
playtest = candidate;
|
||||
selected_slot = slot;
|
||||
break;
|
||||
}
|
||||
response_size = encode_profile_playtest(
|
||||
selected_slot, playtest, response, sizeof(response));
|
||||
break;
|
||||
}
|
||||
case Operation::kProfileMetadataRead: {
|
||||
ProfileServiceMetadataSnapshot snapshot{};
|
||||
profile_service_metadata_snapshot(&snapshot);
|
||||
response_size = encode_profile_metadata(
|
||||
snapshot, response, sizeof(response));
|
||||
break;
|
||||
}
|
||||
case Operation::kProfileTransactionStatus: {
|
||||
ProfileServiceTransactionSnapshot snapshot{};
|
||||
profile_service_transaction_snapshot(&snapshot);
|
||||
|
|
|
|||
|
|
@ -18,14 +18,29 @@ constexpr size_t kRequestHeaderSize = 16;
|
|||
constexpr size_t kResponseHeaderSize = 20;
|
||||
constexpr size_t kPairingRecordSize = 8;
|
||||
constexpr size_t kPairingPayloadHeaderSize = 4;
|
||||
constexpr size_t kMaximumRequestSize = 64;
|
||||
constexpr size_t kMaximumRequestSize = 80;
|
||||
constexpr size_t kProfileListRowSize =
|
||||
16 + PROFILE_SERVICE_METADATA_MAX_BYTES + 1;
|
||||
constexpr size_t kProfileListPayloadSize =
|
||||
1 + PROFILE_SERVICE_LIST_CAPACITY * 16;
|
||||
1 + PROFILE_SERVICE_LIST_CAPACITY * kProfileListRowSize;
|
||||
constexpr uint16_t kProfilePlaytestSchemaVersion = 2;
|
||||
constexpr size_t kProfilePlaytestPayloadSize = 54;
|
||||
constexpr size_t kProfileMetadataPayloadSize =
|
||||
(CONTROLLER_PROFILE_COUNT + 1) *
|
||||
(PROFILE_SERVICE_METADATA_MAX_BYTES + 1);
|
||||
constexpr uint16_t kProfileMetadataSchemaVersion = 1;
|
||||
constexpr uint16_t kHapticsExperimentSchemaVersion = 5;
|
||||
constexpr size_t kHapticsExperimentPayloadSize = 84;
|
||||
constexpr uint16_t kHapticsTransportProbeSchemaVersion = 3;
|
||||
constexpr size_t kHapticsTransportProbePayloadSize = 176;
|
||||
constexpr uint16_t kMacroCaptureSchemaVersion = 1;
|
||||
constexpr size_t kMacroCaptureHeaderSize = 32;
|
||||
constexpr size_t kMacroCaptureEventSize = 20;
|
||||
constexpr size_t kMaximumResponseSize =
|
||||
kResponseHeaderSize + kProfileListPayloadSize;
|
||||
constexpr size_t kMaximumChunkSize =
|
||||
kMaximumRequestSize - kRequestHeaderSize - 8;
|
||||
static_assert(kMaximumResponseSize == 293,
|
||||
static_assert(kMaximumResponseSize == 837,
|
||||
"profile list no longer fits the EP0 response buffer");
|
||||
|
||||
enum class Operation : uint8_t {
|
||||
|
|
@ -52,6 +67,13 @@ enum class Operation : uint8_t {
|
|||
kProfileReset = 0x36,
|
||||
kProfileActivate = 0x37,
|
||||
kProfileTransactionStatus = 0x38,
|
||||
kProfilePlaytest = 0x39,
|
||||
kProfileMetadataRead = 0x3a,
|
||||
kProfileMetadataSet = 0x3b,
|
||||
kProfileIdentify = 0x3c,
|
||||
kHapticsExperiment = 0x40,
|
||||
kHapticsTransportProbe = 0x41,
|
||||
kMacroCapture = 0x42,
|
||||
};
|
||||
|
||||
enum class Status : uint8_t {
|
||||
|
|
@ -84,9 +106,15 @@ size_t encode_profile_list(const ProfileServiceListSnapshot& snapshot,
|
|||
uint8_t* output, size_t output_size);
|
||||
size_t encode_profile_read(const ProfileServiceSelectedSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size);
|
||||
size_t encode_profile_playtest(
|
||||
uint8_t slot, const Bluepad32PlaytestSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size);
|
||||
size_t encode_profile_transaction(
|
||||
const ProfileServiceTransactionSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size);
|
||||
size_t encode_profile_metadata(
|
||||
const ProfileServiceMetadataSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size);
|
||||
|
||||
} // namespace UsbConfigurationManagement
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ struct XInputContext {
|
|||
uint8_t endpoint_in = 0;
|
||||
uint8_t endpoint_out = 0;
|
||||
bool configured = false;
|
||||
bool host_rumble_active = false;
|
||||
};
|
||||
|
||||
XInputContext g_contexts[SWITCH_PICO_HID_INSTANCE_COUNT]{};
|
||||
|
|
@ -41,7 +42,18 @@ XInputContext *context_for_endpoint(uint8_t endpoint) {
|
|||
return nullptr;
|
||||
}
|
||||
|
||||
void stop_context_rumble(XInputContext& context) {
|
||||
if (!context.host_rumble_active) return;
|
||||
context.host_rumble_active = false;
|
||||
if (context.rumble_callback != nullptr) {
|
||||
context.rumble_callback(static_cast<uint8_t>(&context - g_contexts),
|
||||
ControllerRumbleOutput{});
|
||||
}
|
||||
}
|
||||
|
||||
void reset_context(XInputContext &context) {
|
||||
context.configured = false;
|
||||
stop_context_rumble(context);
|
||||
const ControllerRumbleCallback callback = context.rumble_callback;
|
||||
context = {};
|
||||
context.rumble_callback = callback;
|
||||
|
|
@ -133,7 +145,7 @@ bool driver_control(uint8_t rhport, uint8_t stage,
|
|||
bool driver_transfer(uint8_t rhport, uint8_t endpoint, xfer_result_t result,
|
||||
uint32_t transferred) {
|
||||
XInputContext *context = context_for_endpoint(endpoint);
|
||||
if (context == nullptr || result != XFER_RESULT_SUCCESS) {
|
||||
if (context == nullptr || !context->configured || result != XFER_RESULT_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
if (endpoint == context->endpoint_out) {
|
||||
|
|
@ -142,6 +154,8 @@ bool driver_transfer(uint8_t rhport, uint8_t endpoint, xfer_result_t result,
|
|||
&rumble) &&
|
||||
context->rumble_callback != nullptr) {
|
||||
const uint8_t instance = static_cast<uint8_t>(context - g_contexts);
|
||||
context->host_rumble_active = rumble.low_frequency_magnitude != 0 ||
|
||||
rumble.high_frequency_magnitude != 0;
|
||||
context->rumble_callback(instance, rumble);
|
||||
}
|
||||
memset(context->output_report, 0, sizeof(context->output_report));
|
||||
|
|
@ -205,6 +219,10 @@ bool xinput_is_ready(uint8_t instance) {
|
|||
return context != nullptr && context->configured && tud_ready();
|
||||
}
|
||||
|
||||
void xinput_stop_rumble() {
|
||||
for (XInputContext& context : g_contexts) stop_context_rumble(context);
|
||||
}
|
||||
|
||||
usbd_class_driver_t const* xinput_class_driver() {
|
||||
return &kDriver;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,5 +12,7 @@ void xinput_set_rumble_callback(uint8_t instance,
|
|||
void xinput_set_input(uint8_t instance, const ControllerState& state);
|
||||
bool xinput_task(uint8_t instance);
|
||||
bool xinput_is_ready(uint8_t instance);
|
||||
// Stop held host output on USB reset, unmount, or suspend.
|
||||
void xinput_stop_rumble();
|
||||
|
||||
usbd_class_driver_t const* xinput_class_driver();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
985
src/switch_pico_bridge/profile_web.py
Normal file
985
src/switch_pico_bridge/profile_web.py
Normal file
|
|
@ -0,0 +1,985 @@
|
|||
"""Local-only HTTP backend for the graphical controller profile editor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import webbrowser
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from importlib import resources
|
||||
from typing import Any, Callable, cast
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import usb.core
|
||||
|
||||
from . import config_manager
|
||||
|
||||
_MAXIMUM_REQUEST_BYTES = 64 * 1024
|
||||
_ASSET_TYPES = {
|
||||
"/": ("profile_editor.html", "text/html; charset=utf-8"),
|
||||
"/app.js": ("profile_editor.js", "text/javascript; charset=utf-8"),
|
||||
"/playtest.js": (
|
||||
"profile_playtest.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
"/style.css": ("profile_editor.css", "text/css; charset=utf-8"),
|
||||
"/assets/controller-switch-pro.svg": (
|
||||
"assets/controller-switch-pro.svg",
|
||||
"image/svg+xml",
|
||||
),
|
||||
"/assets/controller-dualsense.svg": (
|
||||
"assets/controller-dualsense.svg",
|
||||
"image/svg+xml",
|
||||
),
|
||||
"/assets/controller-xbox.svg": (
|
||||
"assets/controller-xbox.svg",
|
||||
"image/svg+xml",
|
||||
),
|
||||
}
|
||||
|
||||
_CONTROL_LABELS = {
|
||||
"generic": {
|
||||
"north": "Y",
|
||||
"east": "B",
|
||||
"south": "A",
|
||||
"west": "X",
|
||||
"left_shoulder": "LB",
|
||||
"right_shoulder": "RB",
|
||||
"left_trigger": "LT",
|
||||
"right_trigger": "RT",
|
||||
"select": "View",
|
||||
"start": "Menu",
|
||||
"capture": "Share",
|
||||
"system": "Xbox",
|
||||
"left_stick": "Left Stick",
|
||||
"right_stick": "Right Stick",
|
||||
},
|
||||
"xbox": {
|
||||
"north": "Y",
|
||||
"east": "B",
|
||||
"south": "A",
|
||||
"west": "X",
|
||||
"left_shoulder": "LB",
|
||||
"right_shoulder": "RB",
|
||||
"left_trigger": "LT",
|
||||
"right_trigger": "RT",
|
||||
"select": "View",
|
||||
"start": "Menu",
|
||||
"capture": "Share",
|
||||
"system": "Xbox",
|
||||
"left_stick": "Left Stick",
|
||||
"right_stick": "Right Stick",
|
||||
},
|
||||
"switch": {
|
||||
"north": "X",
|
||||
"east": "A",
|
||||
"south": "B",
|
||||
"west": "Y",
|
||||
"left_shoulder": "L",
|
||||
"right_shoulder": "R",
|
||||
"left_trigger": "ZL",
|
||||
"right_trigger": "ZR",
|
||||
"select": "Minus",
|
||||
"start": "Plus",
|
||||
"capture": "Capture",
|
||||
"system": "Home",
|
||||
"left_stick": "Left Stick",
|
||||
"right_stick": "Right Stick",
|
||||
},
|
||||
"playstation": {
|
||||
"north": "Triangle",
|
||||
"east": "Circle",
|
||||
"south": "Cross",
|
||||
"west": "Square",
|
||||
"left_shoulder": "L1",
|
||||
"right_shoulder": "R1",
|
||||
"left_trigger": "L2",
|
||||
"right_trigger": "R2",
|
||||
"select": "Create",
|
||||
"start": "Options",
|
||||
"capture": "Touchpad",
|
||||
"system": "PS",
|
||||
"left_stick": "L3",
|
||||
"right_stick": "R3",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _controller_presentation(
|
||||
identity: config_manager.ControllerIdentity,
|
||||
) -> dict[str, str]:
|
||||
if identity.is_global_fallback:
|
||||
return {"model": "Generic controller", "style": "generic"}
|
||||
|
||||
known = {
|
||||
(0x057E, 0x2009): ("Nintendo Switch Pro Controller", "switch"),
|
||||
(0x054C, 0x0CE6): ("Sony DualSense", "playstation"),
|
||||
(0x054C, 0x09CC): ("Sony DualShock 4", "playstation"),
|
||||
}
|
||||
exact = known.get((identity.vendor_id, identity.product_id))
|
||||
if exact is not None:
|
||||
model, style = exact
|
||||
return {"model": model, "style": style}
|
||||
if identity.vendor_id == 0x054C:
|
||||
return {"model": "Sony controller", "style": "playstation"}
|
||||
if identity.vendor_id == 0x045E:
|
||||
return {"model": "Xbox controller", "style": "xbox"}
|
||||
if identity.vendor_id == 0x2DC8:
|
||||
return {"model": "8BitDo controller", "style": "switch"}
|
||||
return {"model": "Connected controller", "style": "generic"}
|
||||
|
||||
|
||||
def _controller_label(
|
||||
identity: config_manager.ControllerIdentity, alias: str = ""
|
||||
) -> str:
|
||||
if alias:
|
||||
return alias
|
||||
if identity.is_global_fallback:
|
||||
return "Default profile"
|
||||
presentation = _controller_presentation(identity)
|
||||
friendly_models = {
|
||||
"Nintendo Switch Pro Controller": "Switch Pro",
|
||||
"Sony DualSense": "DualSense",
|
||||
"Sony DualShock 4": "DualShock 4",
|
||||
"Sony controller": "PlayStation Controller",
|
||||
"Xbox controller": "Xbox",
|
||||
"8BitDo controller": "8BitDo",
|
||||
"Connected controller": "Controller",
|
||||
}
|
||||
model = friendly_models.get(presentation["model"], presentation["model"])
|
||||
address_suffix = ":".join(f"{octet:02X}" for octet in identity.address[-2:])
|
||||
return f"{model} · {address_suffix}"
|
||||
|
||||
|
||||
class ProfileEditorServer(HTTPServer):
|
||||
"""Single-threaded server that serializes USB profile transactions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address: tuple[str, int],
|
||||
*,
|
||||
bus: int | None,
|
||||
device_address: int | None,
|
||||
timeout: float,
|
||||
) -> None:
|
||||
super().__init__(address, ProfileEditorHandler)
|
||||
self.bus = bus
|
||||
self.device_address = device_address
|
||||
self.operation_timeout = timeout
|
||||
self.mutation_token = secrets.token_urlsafe(32)
|
||||
self._device: config_manager.UsbDevice | None = None
|
||||
self.capture: dict[str, Any] | None = None
|
||||
|
||||
def find_device(self) -> config_manager.UsbDevice:
|
||||
if self._device is None:
|
||||
self._device = config_manager.find_pico(
|
||||
self.bus, self.device_address, self.operation_timeout
|
||||
)
|
||||
return self._device
|
||||
|
||||
def invalidate_device(self) -> None:
|
||||
self._device = None
|
||||
|
||||
|
||||
class ProfileEditorHandler(BaseHTTPRequestHandler):
|
||||
@property
|
||||
def profile_server(self) -> ProfileEditorServer:
|
||||
return cast(ProfileEditorServer, self.server)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if not self._local_host_header():
|
||||
self.send_error(HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
|
||||
path = urlsplit(self.path).path
|
||||
if path in _ASSET_TYPES:
|
||||
self._serve_asset(*_ASSET_TYPES[path])
|
||||
return
|
||||
if path == "/api/schema":
|
||||
self._send_json(
|
||||
{
|
||||
"buttons": list(config_manager.LOGICAL_BUTTONS),
|
||||
"controls": list(config_manager.LOGICAL_CONTROLS),
|
||||
"default_switching_chord": [
|
||||
"left_shoulder",
|
||||
"right_shoulder",
|
||||
"select",
|
||||
"start",
|
||||
],
|
||||
"default_motion_toggle_chord": [
|
||||
"dpad_up",
|
||||
"right_shoulder",
|
||||
"start",
|
||||
],
|
||||
"rumble_policies": list(config_manager.RUMBLE_POLICIES),
|
||||
"turbo_modes": list(config_manager.TURBO_MODES),
|
||||
"shift_modes": list(config_manager.SHIFT_MODES),
|
||||
"shortcut_selectors": list(
|
||||
config_manager.SHORTCUT_SELECTOR_BUTTONS
|
||||
),
|
||||
"turbo_settings_bounds": {
|
||||
"rate_hz": {
|
||||
"min": config_manager.PROFILE_TURBO_RATE_MIN,
|
||||
"max": config_manager.PROFILE_TURBO_RATE_MAX,
|
||||
},
|
||||
"duty_percent": {
|
||||
"min": config_manager.PROFILE_TURBO_DUTY_MIN,
|
||||
"max": config_manager.PROFILE_TURBO_DUTY_MAX,
|
||||
},
|
||||
"burst_count": {
|
||||
"min": config_manager.PROFILE_TURBO_BURST_MIN,
|
||||
"max": config_manager.PROFILE_TURBO_BURST_MAX,
|
||||
},
|
||||
},
|
||||
"macro_playback_modes": list(config_manager.MACRO_PLAYBACK_MODES),
|
||||
"macro_repeat_bounds": {
|
||||
"min": config_manager.PROFILE_MACRO_REPEAT_MIN,
|
||||
"max": config_manager.PROFILE_MACRO_REPEAT_MAX,
|
||||
},
|
||||
"macro_overrides": list(config_manager.MACRO_OVERRIDE_NAMES),
|
||||
"macro_count": config_manager.PROFILE_MACRO_COUNT,
|
||||
"profile_capacity": config_manager.PROFILE_CAPACITY,
|
||||
"control_labels": _CONTROL_LABELS,
|
||||
"maximum_macro_state_steps": (
|
||||
config_manager.PROFILE_MACRO_STEPS_PER_MACRO
|
||||
),
|
||||
"maximum_shared_macro_steps": (
|
||||
config_manager.PROFILE_MACRO_STEP_CAPACITY
|
||||
),
|
||||
"maximum_macro_stream_bytes": (
|
||||
config_manager.PROFILE_MACRO_STREAM_SIZE
|
||||
),
|
||||
"default_profile": (
|
||||
config_manager.ControllerProfile.default().to_json_object()
|
||||
),
|
||||
"mutation_token": self.profile_server.mutation_token,
|
||||
}
|
||||
)
|
||||
return
|
||||
if path == "/api/profiles":
|
||||
self._api_call(self._list_profiles)
|
||||
return
|
||||
capture = self._parse_capture_path(path)
|
||||
if capture is not None:
|
||||
identity_index, profile_index, action = capture
|
||||
if action.isdecimal() or action == "current":
|
||||
self._api_call(
|
||||
lambda: self._read_capture(identity_index, profile_index, action)
|
||||
)
|
||||
return
|
||||
|
||||
selection = self._parse_profile_path(path)
|
||||
if selection is not None:
|
||||
identity_index, profile_index, action = selection
|
||||
if action is None:
|
||||
self._api_call(
|
||||
lambda: self._read_profile(identity_index, profile_index)
|
||||
)
|
||||
return
|
||||
if action == "playtest":
|
||||
self._api_call(
|
||||
lambda: self._read_playtest(identity_index, profile_index)
|
||||
)
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
if not self._allow_mutation():
|
||||
return
|
||||
path = urlsplit(self.path).path
|
||||
identity_selection = self._parse_identity_path(path)
|
||||
if identity_selection is not None:
|
||||
identity_index, action = identity_selection
|
||||
if action == "alias":
|
||||
self._api_call(lambda: self._set_alias(identity_index))
|
||||
return
|
||||
selection = self._parse_profile_path(path)
|
||||
if selection is None:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
identity_index, profile_index, action = selection
|
||||
if action is None:
|
||||
self._api_call(lambda: self._write_profile(identity_index, profile_index))
|
||||
return
|
||||
if action == "name":
|
||||
self._api_call(
|
||||
lambda: self._set_profile_name(identity_index, profile_index)
|
||||
)
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if not self._allow_mutation():
|
||||
return
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/api/profiles/validate":
|
||||
self._api_call(
|
||||
lambda: {
|
||||
"profile": config_manager.ControllerProfile.from_json(
|
||||
self._read_json_body()
|
||||
).to_json_object()
|
||||
}
|
||||
)
|
||||
return
|
||||
capture = self._parse_capture_path(path)
|
||||
if capture is not None:
|
||||
identity_index, profile_index, action = capture
|
||||
if action == "start":
|
||||
self._api_call(
|
||||
lambda: self._start_capture(identity_index, profile_index)
|
||||
)
|
||||
return
|
||||
if action == "stop":
|
||||
self._api_call(
|
||||
lambda: self._stop_capture(identity_index, profile_index)
|
||||
)
|
||||
return
|
||||
identity_selection = self._parse_identity_path(path)
|
||||
if identity_selection is not None:
|
||||
identity_index, action = identity_selection
|
||||
if action == "identify":
|
||||
self._api_call(lambda: self._identify(identity_index))
|
||||
return
|
||||
selection = self._parse_profile_path(path)
|
||||
if selection is not None:
|
||||
identity_index, profile_index, action = selection
|
||||
if action == "activate":
|
||||
self._api_call(
|
||||
lambda: self._activate_profile(identity_index, profile_index)
|
||||
)
|
||||
return
|
||||
if action == "copy":
|
||||
self._api_call(
|
||||
lambda: self._copy_profile(identity_index, profile_index)
|
||||
)
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def _local_host_header(self) -> bool:
|
||||
host = self.headers.get("Host", "")
|
||||
return host in {
|
||||
f"127.0.0.1:{self.profile_server.server_port}",
|
||||
f"localhost:{self.profile_server.server_port}",
|
||||
}
|
||||
|
||||
def _allow_mutation(self) -> bool:
|
||||
if not self._local_host_header():
|
||||
self.send_error(HTTPStatus.FORBIDDEN)
|
||||
return False
|
||||
if (
|
||||
self.headers.get("X-Switch-Pico-Token")
|
||||
!= self.profile_server.mutation_token
|
||||
):
|
||||
self._send_json(
|
||||
{"error": "missing or invalid mutation token"},
|
||||
status=HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _parse_profile_path(path: str) -> tuple[int, int, str | None] | None:
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) not in (4, 5) or parts[:2] != ["api", "profiles"]:
|
||||
return None
|
||||
try:
|
||||
identity_index = int(parts[2])
|
||||
profile_number = int(parts[3])
|
||||
except ValueError:
|
||||
return None
|
||||
if (
|
||||
identity_index < 0
|
||||
or not 1 <= profile_number <= config_manager.PROFILE_CAPACITY
|
||||
):
|
||||
return None
|
||||
action = parts[4] if len(parts) == 5 else None
|
||||
return identity_index, profile_number - 1, action
|
||||
|
||||
@classmethod
|
||||
def _parse_capture_path(cls, path: str) -> tuple[int, int, str] | None:
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) != 6 or parts[4] != "capture":
|
||||
return None
|
||||
selection = cls._parse_profile_path("/".join(parts[:4]))
|
||||
if selection is None:
|
||||
return None
|
||||
return selection[0], selection[1], parts[5]
|
||||
|
||||
@staticmethod
|
||||
def _parse_identity_path(path: str) -> tuple[int, str] | None:
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) != 4 or parts[:2] != ["api", "identities"]:
|
||||
return None
|
||||
try:
|
||||
identity_index = int(parts[2])
|
||||
except ValueError:
|
||||
return None
|
||||
return (identity_index, parts[3]) if identity_index >= 0 else None
|
||||
|
||||
def _entries_and_identity(
|
||||
self, device: config_manager.UsbDevice, identity_index: int
|
||||
) -> tuple[
|
||||
tuple[config_manager.ProfileListEntry, ...],
|
||||
config_manager.ControllerIdentity,
|
||||
]:
|
||||
entries = config_manager.list_profiles(device)
|
||||
if not 0 <= identity_index < len(entries):
|
||||
raise config_manager.ConfigManagerError(
|
||||
f"identity index {identity_index} is out of range; "
|
||||
f"refresh the profile library"
|
||||
)
|
||||
return entries, entries[identity_index].identity
|
||||
|
||||
def _list_profiles(self) -> dict[str, Any]:
|
||||
device = self.profile_server.find_device()
|
||||
entries = config_manager.list_profiles(device)
|
||||
return {
|
||||
"identities": [
|
||||
{
|
||||
"index": index,
|
||||
"label": _controller_label(entry.identity, entry.alias),
|
||||
"key": entry.identity.to_bytes().hex(),
|
||||
"active_profile": entry.active_profile_index + 1,
|
||||
"controller": _controller_presentation(entry.identity),
|
||||
"alias": entry.alias,
|
||||
"modifier_controls": list(
|
||||
config_manager.LOGICAL_CONTROLS
|
||||
if _controller_presentation(entry.identity)["style"]
|
||||
in {"xbox", "playstation"}
|
||||
else config_manager.LOGICAL_BUTTONS
|
||||
),
|
||||
}
|
||||
for index, entry in enumerate(entries)
|
||||
]
|
||||
}
|
||||
|
||||
def _read_profile(self, identity_index: int, profile_index: int) -> dict[str, Any]:
|
||||
device = self.profile_server.find_device()
|
||||
entries, identity = self._entries_and_identity(device, identity_index)
|
||||
profile = config_manager.read_profile(device, identity, profile_index)
|
||||
metadata = config_manager.read_selected_profile_metadata(device)
|
||||
return {
|
||||
"profile": profile.to_json_object(),
|
||||
"name": metadata.profile_names[profile_index],
|
||||
"profile_names": list(metadata.profile_names),
|
||||
"alias": metadata.alias,
|
||||
"active": entries[identity_index].active_profile_index == profile_index,
|
||||
}
|
||||
|
||||
def _read_playtest(self, identity_index: int, profile_index: int) -> dict[str, Any]:
|
||||
device = self.profile_server.find_device()
|
||||
_, identity = self._entries_and_identity(device, identity_index)
|
||||
config_manager.select_profile(device, identity, profile_index)
|
||||
playtest = config_manager.read_profile_playtest(device)
|
||||
result = playtest.to_json_object()
|
||||
result["owner_key"] = identity.to_bytes().hex()
|
||||
result["identity_key"] = (
|
||||
playtest.identity.to_bytes().hex()
|
||||
if playtest.identity is not None
|
||||
else None
|
||||
)
|
||||
result["controller"] = (
|
||||
_controller_presentation(playtest.identity)
|
||||
if playtest.identity is not None
|
||||
else None
|
||||
)
|
||||
result["label"] = (
|
||||
_controller_label(playtest.identity)
|
||||
if playtest.identity is not None
|
||||
else None
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _capture_integer(
|
||||
body: dict[str, Any], name: str, minimum: int, maximum: int
|
||||
) -> int:
|
||||
value = body.get(name)
|
||||
if type(value) is not int or not minimum <= value <= maximum:
|
||||
raise config_manager.ConfigManagerError(
|
||||
f"{name} must be an integer from {minimum} to {maximum}"
|
||||
)
|
||||
return value
|
||||
|
||||
def _start_capture(self, identity_index: int, profile_index: int) -> dict[str, Any]:
|
||||
body = self._read_json_object()
|
||||
channels = self._capture_integer(body, "channels", 1, 31)
|
||||
maximum = self._capture_integer(body, "max_events", 1, 8)
|
||||
axis_quantum = self._capture_integer(body, "axis_quantum", 1, 32767)
|
||||
trigger_quantum = self._capture_integer(body, "trigger_quantum", 1, 65535)
|
||||
duration = self._capture_integer(body, "max_duration_ms", 1, 80000)
|
||||
slot = self._capture_integer(
|
||||
body, "slot", 0, config_manager.PROFILE_PLAYTEST_SLOT_COUNT - 1
|
||||
)
|
||||
generation = self._capture_integer(body, "connection_generation", 0, 0xFFFFFFFF)
|
||||
macro_index = self._capture_integer(
|
||||
body, "macro_index", 0, config_manager.PROFILE_MACRO_COUNT - 1
|
||||
)
|
||||
owner_key = body.get("owner_key")
|
||||
if not isinstance(owner_key, str):
|
||||
raise config_manager.ConfigManagerError("capture owner key is required")
|
||||
capture_id = body.get("capture_id")
|
||||
if not isinstance(capture_id, str) or not 1 <= len(capture_id) <= 128:
|
||||
raise config_manager.ConfigManagerError("capture request ID is required")
|
||||
profile = config_manager.ControllerProfile.from_json_object(body.get("profile"))
|
||||
others = [
|
||||
step
|
||||
for index, macro in enumerate(profile.macros)
|
||||
if index != macro_index
|
||||
for step in macro.steps
|
||||
]
|
||||
step_bytes = 3 + sum(
|
||||
size for bit, size in enumerate((2, 4, 4, 2, 2)) if channels & (1 << bit)
|
||||
)
|
||||
available = min(
|
||||
config_manager.PROFILE_MACRO_STEPS_PER_MACRO,
|
||||
config_manager.PROFILE_MACRO_STEP_CAPACITY - len(others),
|
||||
(
|
||||
config_manager.PROFILE_MACRO_STREAM_SIZE
|
||||
- sum(len(step.to_sparse_bytes()) for step in others)
|
||||
)
|
||||
// step_bytes,
|
||||
)
|
||||
if maximum > available:
|
||||
raise config_manager.ConfigManagerError(
|
||||
"recording would exceed the draft's 8/16/136 macro budget"
|
||||
)
|
||||
device = self.profile_server.find_device()
|
||||
_, identity = self._entries_and_identity(device, identity_index)
|
||||
if identity.to_bytes().hex() != owner_key:
|
||||
raise config_manager.ConfigManagerError(
|
||||
"profile owner changed; refresh before recording"
|
||||
)
|
||||
# The routed playtest endpoint selected this owner. Recheck its live
|
||||
# identity and generation without changing USB selection on a stale start.
|
||||
sample = config_manager.read_profile_playtest(device)
|
||||
if (
|
||||
not sample.connected
|
||||
or sample.identity is None
|
||||
or (not identity.is_global_fallback and sample.identity != identity)
|
||||
or sample.slot_index != slot
|
||||
or sample.connection_generation != generation
|
||||
):
|
||||
raise config_manager.ConfigManagerError(
|
||||
"controller connection changed; wait for live input before recording"
|
||||
)
|
||||
previous = config_manager.read_macro_capture(device)
|
||||
if previous.state_name == "recording":
|
||||
raise config_manager.ConfigManagerError(
|
||||
"a recording is already running; stop it before starting another"
|
||||
)
|
||||
self.profile_server.capture = {
|
||||
"identity_index": identity_index,
|
||||
"profile_index": profile_index,
|
||||
"owner_key": owner_key,
|
||||
"macro_index": macro_index,
|
||||
"run_id": None,
|
||||
"previous_run_id": previous.run_id,
|
||||
"slot": slot,
|
||||
"connection_generation": generation,
|
||||
"result": None,
|
||||
"capture_id": capture_id,
|
||||
"channels": channels,
|
||||
"max_events": maximum,
|
||||
"axis_quantum": axis_quantum,
|
||||
"trigger_quantum": trigger_quantum,
|
||||
"max_duration_ms": duration,
|
||||
}
|
||||
page = config_manager.start_macro_capture(
|
||||
device,
|
||||
slot,
|
||||
generation,
|
||||
channels=channels,
|
||||
max_events=maximum,
|
||||
axis_quantum=axis_quantum,
|
||||
trigger_quantum=trigger_quantum,
|
||||
max_duration_ms=duration,
|
||||
)
|
||||
return self._capture_result(device, page)
|
||||
|
||||
def _bound_capture(
|
||||
self,
|
||||
identity_index: int,
|
||||
profile_index: int,
|
||||
run_id: int | None,
|
||||
owner_key: str | None,
|
||||
generation: int,
|
||||
capture_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
capture = self.profile_server.capture
|
||||
if (
|
||||
capture is None
|
||||
or capture["identity_index"] != identity_index
|
||||
or capture["profile_index"] != profile_index
|
||||
or capture["owner_key"] != owner_key
|
||||
or capture["capture_id"] != capture_id
|
||||
or capture["connection_generation"] != generation
|
||||
or (run_id is not None and capture["run_id"] != run_id)
|
||||
):
|
||||
raise config_manager.ConfigManagerError(
|
||||
"stale capture run, connection or profile owner"
|
||||
)
|
||||
return capture
|
||||
|
||||
def _capture_device(self, capture: dict[str, Any]) -> config_manager.UsbDevice:
|
||||
device = self.profile_server.find_device()
|
||||
_, identity = self._entries_and_identity(device, capture["identity_index"])
|
||||
if identity.to_bytes().hex() != capture["owner_key"]:
|
||||
raise config_manager.ConfigManagerError(
|
||||
"capture profile owner moved; refusing a stale library index"
|
||||
)
|
||||
return device
|
||||
|
||||
def _capture_result(
|
||||
self,
|
||||
device: config_manager.UsbDevice,
|
||||
page: config_manager.MacroCapturePage,
|
||||
) -> dict[str, Any]:
|
||||
capture = self.profile_server.capture
|
||||
assert capture is not None
|
||||
run_id = capture["run_id"]
|
||||
if (
|
||||
not page.run_id
|
||||
or page.run_id == capture["previous_run_id"]
|
||||
or (run_id is not None and page.run_id != run_id)
|
||||
or any(
|
||||
getattr(page, field) != capture[field]
|
||||
for field in (
|
||||
"slot",
|
||||
"connection_generation",
|
||||
"channels",
|
||||
"max_events",
|
||||
"axis_quantum",
|
||||
"trigger_quantum",
|
||||
"max_duration_ms",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise config_manager.ConfigManagerError(
|
||||
"capture run, connection or options changed"
|
||||
)
|
||||
capture["run_id"] = page.run_id
|
||||
terminal = page.state_name not in {"idle", "recording"}
|
||||
if terminal:
|
||||
page = config_manager.collect_macro_capture(device, page.run_id)
|
||||
if any(
|
||||
getattr(page, field) != capture[field]
|
||||
for field in (
|
||||
"run_id",
|
||||
"slot",
|
||||
"connection_generation",
|
||||
"channels",
|
||||
"max_events",
|
||||
"axis_quantum",
|
||||
"trigger_quantum",
|
||||
"max_duration_ms",
|
||||
)
|
||||
):
|
||||
raise config_manager.ConfigManagerError(
|
||||
"capture changed during collection"
|
||||
)
|
||||
result = page.to_json_object()
|
||||
result.update(
|
||||
{
|
||||
"owner_key": capture["owner_key"],
|
||||
"capture_id": capture["capture_id"],
|
||||
"profile_number": capture["profile_index"] + 1,
|
||||
"macro_index": capture["macro_index"],
|
||||
}
|
||||
)
|
||||
if terminal:
|
||||
try:
|
||||
result["steps"] = [
|
||||
step.to_json_object()
|
||||
for step in config_manager.capture_macro_steps(page)
|
||||
]
|
||||
except config_manager.ConfigManagerError as exc:
|
||||
# A conversion failure must not hide the retained raw events.
|
||||
result["conversion_error"] = str(exc)
|
||||
capture["result"] = result
|
||||
return result
|
||||
|
||||
def _read_capture(
|
||||
self, identity_index: int, profile_index: int, action: str
|
||||
) -> dict[str, Any]:
|
||||
query = parse_qs(urlsplit(self.path).query)
|
||||
try:
|
||||
generation = int(query.get("connection_generation", [""])[0])
|
||||
except ValueError as exc:
|
||||
raise config_manager.ConfigManagerError(
|
||||
"capture connection generation is required"
|
||||
) from exc
|
||||
capture = self._bound_capture(
|
||||
identity_index,
|
||||
profile_index,
|
||||
None if action == "current" else int(action),
|
||||
query.get("owner_key", [None])[0],
|
||||
generation,
|
||||
query.get("capture_id", [None])[0],
|
||||
)
|
||||
device = self._capture_device(capture)
|
||||
if capture["result"] is not None:
|
||||
return cast(dict[str, Any], capture["result"])
|
||||
page = config_manager.read_macro_capture(device, capture["run_id"] or 0)
|
||||
return self._capture_result(device, page)
|
||||
|
||||
def _stop_capture(self, identity_index: int, profile_index: int) -> dict[str, Any]:
|
||||
body = self._read_json_object()
|
||||
run_id = self._capture_integer(body, "run_id", 1, 0xFFFFFFFF)
|
||||
generation = self._capture_integer(body, "connection_generation", 0, 0xFFFFFFFF)
|
||||
capture = self._bound_capture(
|
||||
identity_index,
|
||||
profile_index,
|
||||
run_id,
|
||||
body.get("owner_key"),
|
||||
generation,
|
||||
body.get("capture_id"),
|
||||
)
|
||||
device = self._capture_device(capture)
|
||||
if capture["result"] is not None:
|
||||
return cast(dict[str, Any], capture["result"])
|
||||
page = config_manager.read_macro_capture(device, run_id)
|
||||
if (
|
||||
page.slot != capture["slot"]
|
||||
or page.connection_generation != generation
|
||||
or page.run_id != run_id
|
||||
):
|
||||
raise config_manager.ConfigManagerError("capture connection or run changed")
|
||||
if page.state_name == "recording":
|
||||
page = config_manager.stop_macro_capture(device, run_id)
|
||||
return self._capture_result(device, page)
|
||||
|
||||
def _read_json_body(self) -> str:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", ""))
|
||||
except ValueError as exc:
|
||||
raise config_manager.ConfigManagerError(
|
||||
"invalid request content length"
|
||||
) from exc
|
||||
if not 1 <= length <= _MAXIMUM_REQUEST_BYTES:
|
||||
raise config_manager.ConfigManagerError(
|
||||
"profile request must contain 1 to 65536 bytes"
|
||||
)
|
||||
return self.rfile.read(length).decode("utf-8")
|
||||
|
||||
def _read_json_object(self) -> dict[str, Any]:
|
||||
value = json.loads(self._read_json_body())
|
||||
if not isinstance(value, dict):
|
||||
raise config_manager.ConfigManagerError(
|
||||
"request body must be a JSON object"
|
||||
)
|
||||
return value
|
||||
|
||||
def _write_profile(self, identity_index: int, profile_index: int) -> dict[str, Any]:
|
||||
profile = config_manager.ControllerProfile.from_json(self._read_json_body())
|
||||
device = self.profile_server.find_device()
|
||||
_, identity = self._entries_and_identity(device, identity_index)
|
||||
status = config_manager.write_profile(
|
||||
device,
|
||||
identity,
|
||||
profile_index,
|
||||
profile,
|
||||
self.profile_server.operation_timeout,
|
||||
)
|
||||
return {
|
||||
"stored_generation": status.stored_generation,
|
||||
"stored_crc": f"{status.stored_crc:08x}",
|
||||
}
|
||||
|
||||
def _set_profile_name(
|
||||
self, identity_index: int, profile_index: int
|
||||
) -> dict[str, Any]:
|
||||
value = self._read_json_object().get("value")
|
||||
if not isinstance(value, str):
|
||||
raise config_manager.ConfigManagerError("profile name must be text")
|
||||
device = self.profile_server.find_device()
|
||||
_, identity = self._entries_and_identity(device, identity_index)
|
||||
status = config_manager.set_profile_metadata(
|
||||
device,
|
||||
identity,
|
||||
profile_index,
|
||||
value,
|
||||
self.profile_server.operation_timeout,
|
||||
)
|
||||
return {"stored_generation": status.stored_generation}
|
||||
|
||||
def _set_alias(self, identity_index: int) -> dict[str, Any]:
|
||||
value = self._read_json_object().get("value")
|
||||
if not isinstance(value, str):
|
||||
raise config_manager.ConfigManagerError("controller alias must be text")
|
||||
device = self.profile_server.find_device()
|
||||
_, identity = self._entries_and_identity(device, identity_index)
|
||||
status = config_manager.set_profile_metadata(
|
||||
device,
|
||||
identity,
|
||||
config_manager.PROFILE_NONE_BUTTON,
|
||||
value,
|
||||
self.profile_server.operation_timeout,
|
||||
)
|
||||
return {
|
||||
"stored_generation": status.stored_generation,
|
||||
"label": _controller_label(identity, value),
|
||||
}
|
||||
|
||||
def _identify(self, identity_index: int) -> dict[str, Any]:
|
||||
device = self.profile_server.find_device()
|
||||
_, identity = self._entries_and_identity(device, identity_index)
|
||||
config_manager.identify_controller(device, identity)
|
||||
return {"identified": True}
|
||||
|
||||
def _copy_profile(self, identity_index: int, profile_index: int) -> dict[str, Any]:
|
||||
destination = self._read_json_object()
|
||||
destination_identity_index = destination.get("identity_index")
|
||||
destination_profile_number = destination.get("profile_number")
|
||||
if (
|
||||
type(destination_identity_index) is not int
|
||||
or type(destination_profile_number) is not int
|
||||
or not 1 <= destination_profile_number <= config_manager.PROFILE_CAPACITY
|
||||
):
|
||||
raise config_manager.ConfigManagerError("copy destination is invalid")
|
||||
profile = config_manager.ControllerProfile.from_json_object(
|
||||
destination.get("profile")
|
||||
)
|
||||
name = destination.get("name")
|
||||
if (
|
||||
type(name) is not str
|
||||
or "\x00" in name
|
||||
or len(name.encode("utf-8")) > config_manager.PROFILE_METADATA_MAX_BYTES
|
||||
):
|
||||
raise config_manager.ConfigManagerError(
|
||||
"profile name must contain at most 31 UTF-8 bytes"
|
||||
)
|
||||
device = self.profile_server.find_device()
|
||||
self._entries_and_identity(device, identity_index)
|
||||
_, destination_identity = self._entries_and_identity(
|
||||
device, destination_identity_index
|
||||
)
|
||||
target_profile = destination_profile_number - 1
|
||||
profile_status = config_manager.write_profile(
|
||||
device,
|
||||
destination_identity,
|
||||
target_profile,
|
||||
profile,
|
||||
self.profile_server.operation_timeout,
|
||||
)
|
||||
name_status = config_manager.set_profile_metadata(
|
||||
device,
|
||||
destination_identity,
|
||||
target_profile,
|
||||
name,
|
||||
self.profile_server.operation_timeout,
|
||||
)
|
||||
return {
|
||||
"stored_generation": name_status.stored_generation,
|
||||
"profile_generation": profile_status.stored_generation,
|
||||
}
|
||||
|
||||
def _activate_profile(
|
||||
self, identity_index: int, profile_index: int
|
||||
) -> dict[str, Any]:
|
||||
device = self.profile_server.find_device()
|
||||
_, identity = self._entries_and_identity(device, identity_index)
|
||||
status = config_manager.activate_profile(
|
||||
device,
|
||||
identity,
|
||||
profile_index,
|
||||
self.profile_server.operation_timeout,
|
||||
)
|
||||
return {"stored_generation": status.stored_generation}
|
||||
|
||||
def _api_call(self, operation: Callable[[], dict[str, Any]]) -> None:
|
||||
try:
|
||||
result = operation()
|
||||
except config_manager.ConfigManagerError as exc:
|
||||
self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
|
||||
except usb.core.USBError as exc:
|
||||
self.profile_server.invalidate_device()
|
||||
self._send_json(
|
||||
{"error": f"USB access failed: {exc}"},
|
||||
status=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
self._send_json(
|
||||
{"error": f"invalid JSON request: {exc}"},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
self._send_json(result)
|
||||
|
||||
def _serve_asset(self, filename: str, content_type: str) -> None:
|
||||
try:
|
||||
payload = (
|
||||
resources.files("switch_pico_bridge")
|
||||
.joinpath("web", filename)
|
||||
.read_bytes()
|
||||
)
|
||||
except (FileNotFoundError, OSError):
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
self._send_bytes(payload, content_type)
|
||||
|
||||
def _send_json(self, value: Any, *, status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||
self._send_bytes(
|
||||
json.dumps(value, separators=(",", ":")).encode("utf-8"),
|
||||
"application/json; charset=utf-8",
|
||||
status=status,
|
||||
)
|
||||
|
||||
def _send_bytes(
|
||||
self,
|
||||
payload: bytes,
|
||||
content_type: str,
|
||||
*,
|
||||
status: HTTPStatus = HTTPStatus.OK,
|
||||
) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; "
|
||||
"connect-src 'self'; img-src 'self'; frame-ancestors 'none'",
|
||||
)
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
return
|
||||
|
||||
|
||||
def run_profile_editor(
|
||||
*,
|
||||
bus: int | None,
|
||||
address: int | None,
|
||||
timeout: float,
|
||||
port: int,
|
||||
open_browser: bool,
|
||||
) -> None:
|
||||
try:
|
||||
server = ProfileEditorServer(
|
||||
("127.0.0.1", port),
|
||||
bus=bus,
|
||||
device_address=address,
|
||||
timeout=timeout,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise config_manager.ConfigManagerError(
|
||||
f"could not start profile editor on port {port}: {exc}"
|
||||
) from exc
|
||||
|
||||
url = f"http://127.0.0.1:{server.server_port}/"
|
||||
print(f"Profile editor: {url}")
|
||||
print("Press Ctrl+C to stop.")
|
||||
if open_browser and not webbrowser.open(url):
|
||||
print(f"Could not open a browser automatically; open {url} manually.")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
35
src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt
Normal file
35
src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
Controller artwork source
|
||||
=========================
|
||||
|
||||
The following files are redistributed from AL2009man/Gamepad-Asset-Pack
|
||||
revision a6d1113b0b3b2438bbbcb08a33f9a25eb2391d7c:
|
||||
|
||||
- controller-dualsense.svg
|
||||
- controller-switch-pro.svg
|
||||
- controller-xbox.svg
|
||||
|
||||
Source: https://github.com/AL2009man/Gamepad-Asset-Pack
|
||||
Copyright (c) 2024 Al. Lopez
|
||||
License: MIT
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Al. Lopez
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
2013
src/switch_pico_bridge/web/assets/controller-dualsense.svg
Normal file
2013
src/switch_pico_bridge/web/assets/controller-dualsense.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 205 KiB |
463
src/switch_pico_bridge/web/assets/controller-switch-pro.svg
Normal file
463
src/switch_pico_bridge/web/assets/controller-switch-pro.svg
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="419.12717mm"
|
||||
height="304.54602mm"
|
||||
viewBox="0 0 419.12717 304.54603"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
|
||||
sodipodi:docname="Switch Pro Controller VSCView.svg"
|
||||
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009man Work Projects\VSCView - Controller Theme Layout\VSCView - Controller Theme (Ver. 1.20)\Nintendo Switch Controller Images\Switch Pro Controller\Templates\Switch Pro Controller Overlay.png"
|
||||
inkscape:export-xdpi="89.994171"
|
||||
inkscape:export-ydpi="89.994171"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.5"
|
||||
inkscape:cx="709"
|
||||
inkscape:cy="694"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="995"
|
||||
inkscape:window-x="-9"
|
||||
inkscape:window-y="-9"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg5" />
|
||||
<defs
|
||||
id="defs2">
|
||||
<inkscape:path-effect
|
||||
effect="bspline"
|
||||
id="path-effect11354"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
weight="33.333333"
|
||||
steps="2"
|
||||
helper_size="0"
|
||||
apply_no_weight="true"
|
||||
apply_with_weight="true"
|
||||
only_selected="false" />
|
||||
<inkscape:path-effect
|
||||
effect="bspline"
|
||||
id="path-effect11187"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
weight="33.333333"
|
||||
steps="2"
|
||||
helper_size="0"
|
||||
apply_no_weight="true"
|
||||
apply_with_weight="true"
|
||||
only_selected="false" />
|
||||
<inkscape:path-effect
|
||||
effect="bspline"
|
||||
id="path-effect41470"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
weight="33.333333"
|
||||
steps="2"
|
||||
helper_size="0"
|
||||
apply_no_weight="true"
|
||||
apply_with_weight="true"
|
||||
only_selected="false" />
|
||||
</defs>
|
||||
<g
|
||||
id="layer2"
|
||||
inkscape:label="Nintendo Switch Pro Controller"
|
||||
style="display:inline"
|
||||
transform="translate(-80.073102,14.620127)">
|
||||
<g
|
||||
id="g10592"
|
||||
inkscape:label="Digital Triggers"
|
||||
style="display:inline">
|
||||
<path
|
||||
id="path1458"
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:8.31496;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:label="ZR Trigger"
|
||||
d="m 1193.8594,3.9244459 c -30.6291,0.049759 -51.9558,10.8927201 -82.25,24.1087571 l -30.9864,15.619141 v 0 c 60.6006,-2.534721 177.2868,6.012391 206.2911,17.507812 17.5526,6.941643 47.521,19.514293 73.8007,43.001954 C 1310.1862,40.291377 1254.6354,3.8257017 1193.8594,3.9244459 Z"
|
||||
sodipodi:nodetypes="scccccs"
|
||||
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009man Work Projects\VSCView - Controller Theme Layout\VSCView - Controller Theme (Ver. 1.20)\Nintendo Switch Controller Images\Switch Pro Controller\Theme SVG\Theme Assets\Active Presses\Button Color\NSwitchPro_ZR.png"
|
||||
inkscape:export-xdpi="89.874435"
|
||||
inkscape:export-ydpi="89.874435"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,80.073102,-14.558417)" />
|
||||
<path
|
||||
id="path8519"
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:8.31496;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:label="ZL Trigger"
|
||||
d="M 388.70312,3.9244459 C 327.84634,3.8255797 272.22886,40.385003 221.64648,104.41406 247.97833,80.800743 278.04726,68.120999 295.64844,61.160156 324.65272,49.664735 441.33889,41.117623 501.93945,43.652344 v 0 L 470.95312,28.033203 C 440.65896,14.817164 419.33216,3.9742049 388.70312,3.9244459 Z"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,80.073102,-14.558417)"
|
||||
sodipodi:nodetypes="scccccs"
|
||||
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009man Work Projects\VSCView - Controller Theme Layout\VSCView - Controller Theme (Ver. 1.20)\Nintendo Switch Controller Images\Switch Pro Controller\Theme SVG\Theme Assets\Active Presses\Button Color\NSwitchPro_ZL.png"
|
||||
inkscape:export-xdpi="90.144424"
|
||||
inkscape:export-ydpi="90.144424" />
|
||||
</g>
|
||||
<g
|
||||
id="g3226"
|
||||
style="display:inline"
|
||||
inkscape:label="Controller Body"
|
||||
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009~1\VSCVIE~1\VSCVIE~1.20)\NINTEN~1\SWITCH~1\TEMPLA~1\SWITCH~4.PNG"
|
||||
inkscape:export-xdpi="89.994171"
|
||||
inkscape:export-ydpi="89.994171">
|
||||
<g
|
||||
id="g20999"
|
||||
inkscape:label="Shoulders"
|
||||
style="display:inline">
|
||||
<path
|
||||
style="display:inline;fill:#3a3b40;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.8;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 278.583,-2.464337 c -28.76946,0 -85.94915,1.52074839 -111.02858,7.9960404 -25.07936,6.4590456 269.26062,6.4590456 244.18129,0 C 386.63998,-0.94358861 329.46023,-2.464337 300.69077,-2.464337 Z m 0,0"
|
||||
id="path1675-3"
|
||||
inkscape:label="Shoulder Top" />
|
||||
<g
|
||||
id="g3215"
|
||||
inkscape:label="Bumpers"
|
||||
style="display:inline">
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.2;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 349.00293,5.0036016 20.78687,-9.8062225 c 16.0339,-0.670645 43.10528,3.3847582 50.77933,6.426255 7.69035,3.041349 24.39363,10.1544219 30.7451,27.7326099"
|
||||
id="path5183"
|
||||
sodipodi:nodetypes="cccc"
|
||||
inkscape:label="R Bumper" />
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.2;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 229.86272,5.0036016 -20.78687,-9.8062225 c -16.0339,-0.670645 -43.10528,3.3847582 -50.77933,6.426255 -7.69035,3.041349 -24.39363,10.1544219 -30.7451,27.7326099"
|
||||
id="path2365"
|
||||
sodipodi:nodetypes="cccc"
|
||||
inkscape:label="L Bumper" />
|
||||
</g>
|
||||
<path
|
||||
style="display:inline;fill:#3a3b40;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.8;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 278.583,3.9619915 c -28.76946,0 -85.94915,1.5206749 -111.02858,7.9795715 -25.07936,6.475292 -47.95454,23.203229 -47.95454,23.203229 -1.22458,2.387321 -2.92887,6.475293 -2.92887,6.475293 27.03871,4.692961 105.59767,6.818626 168.0186,6.93317 54.12641,0.118702 146.98623,3.335721 180.52342,0.01486 0,0 -3.30226,-9.876446 -5.131,-13.441106 0,0 -23.26699,-16.711541 -48.34632,-23.186833 C 386.63998,5.4812083 329.46023,3.9604598 300.69077,3.9604598 Z"
|
||||
id="path1685-5"
|
||||
sodipodi:nodetypes="cccccccccc"
|
||||
inkscape:label="Shoulder Middle" />
|
||||
</g>
|
||||
<path
|
||||
id="path9077"
|
||||
style="color:#000000;display:inline;fill:#303136;fill-opacity:1;stroke-width:6.04724;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||
d="m 1492.3419,363.6276 c 0,0 -120.2829,187.36702 -214.886,314.43124 -47.6216,63.96202 -90.2553,112.22577 -99.0594,118.48022 27.4793,18.41386 45.2809,61.44345 90.4551,155.73242 68.1907,142.33002 119.3493,163.89862 176.1855,159.57232 56.836,-4.3279 127.8652,-70.4537 132.1231,-182.56255 2.9038,-76.49795 -44.0083,-367.63608 -84.8183,-565.65365 z"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
|
||||
inkscape:label="Secondary Color - Right Grip"
|
||||
sodipodi:nodetypes="cscsccc" />
|
||||
<path
|
||||
id="path9067"
|
||||
style="color:#000000;display:inline;fill:#303136;fill-opacity:1;stroke-width:6.04724;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||
d="M 402.42219,795.85127 C 399.38105,793.06819 336.76422,723.08734 320.52137,703.52495 258.47949,628.80361 167.18978,498.80406 115.64482,412.79566 95.419032,379.04672 87.37911,367.31405 87.37911,367.31405 46.574325,565.32969 0.25264151,852.79352 3.15625,929.28125 c 4.2576,112.10885 75.287003,178.23635 132.12305,182.56255 56.836,4.3277 107.99474,-17.2423 176.18554,-159.57232 45.17551,-94.29154 63.47682,-138.00721 90.95735,-156.42021 z"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
|
||||
inkscape:label="Secondary Color - Left Grip"
|
||||
sodipodi:nodetypes="csscccsc" />
|
||||
<path
|
||||
id="path9073"
|
||||
style="color:#000000;display:inline;opacity:1;fill:#3a3b40;fill-opacity:1;stroke-width:6.04724;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||
d="m 747.63867,60.839844 c -110.83321,0 -331.01859,5.748832 -427.6582,30.222656 -96.57774,24.41158 -184.70117,87.69531 -184.70117,87.69531 -11.01001,20.95955 -28.77243,92.5631 -47.282585,181.69038 0.873431,0.0997 0.707604,2.43957 1.16188,3.1922 0.715225,1.38029 27.588795,50.1741 66.836665,108.25343 66.51882,98.43512 163.05602,227.8697 177.48473,243.46334 32.4081,35.02463 70.21689,79.2567 70.90251,79.45725 12.98151,-7.07235 30.71466,-10.76753 51.98078,-10.76753 h 667.58982 c 21.3568,0 38.2517,2.93993 51.2679,10.08307 1.7345,-0.43644 25.6031,-26.33704 59.4122,-64.27553 13.2152,-14.82933 41.5543,-49.99609 41.5543,-49.99609 0,0 59.0253,-77.54759 113.8892,-157.48009 54.4807,-79.37419 104.8128,-161.13504 101.9565,-161.74543 -18.5107,-89.12547 -35.9671,-160.91574 -46.9961,-181.875 0,0 -88.0614,-63.28373 -184.7012,-87.69531 C 1163.7579,66.588676 943.50977,60.839844 832.73828,60.839844 Z"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
|
||||
inkscape:label="Primary Color - Front"
|
||||
sodipodi:nodetypes="sccccsscsscscscccss" />
|
||||
<path
|
||||
id="path44935"
|
||||
style="color:#000000;display:inline;fill:#211e1e;stroke-width:1;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||
d="m 278.38666,9.1209533 c -29.36903,0 -87.49413,1.4566687 -113.4706,8.0351677 -25.88784,6.543567 -49.30656,23.408391 -49.30655,23.408391 -0.16443,0.117573 -0.29839,0.272371 -0.39274,0.451134 a 0.50005001,0.50005001 0 0 0 -0.002,0 c -1.28805,2.452052 -2.7102,6.774486 -4.34548,12.705167 -1.63548,5.931445 -3.44791,13.429921 -5.35367,22.011576 -3.81155,17.163289 -7.995508,38.658521 -11.851476,60.523441 -3.855972,21.86493 -7.382584,44.0963 -9.876917,62.7326 -2.494304,18.63606 -3.96839,33.60292 -3.677812,41.25743 1.146538,30.19005 20.208895,48.33477 36.160065,49.54891 7.71368,0.58736 15.31861,-0.64567 23.09006,-6.83782 7.76932,-6.19044 15.73229,-17.19509 24.79694,-36.11511 9.02811,-18.84372 13.9548,-29.97353 18.73994,-36.1425 2.39403,-3.08636 4.69,-4.92631 7.52564,-6.06474 2.83367,-1.13765 6.27904,-1.56631 10.89804,-1.56631 h 176.63315 c 4.61903,0 8.06488,0.42866 10.89856,1.56631 2.83565,1.13843 5.1311,2.97839 7.52512,6.06474 4.78521,6.16902 9.71394,17.29881 18.74201,36.1425 9.06463,18.92 17.02604,29.92469 24.79538,36.11511 7.77147,6.1921 15.37586,7.42499 23.08955,6.83782 15.95108,-1.21461 35.01344,-19.35886 36.16006,-49.54891 0.29056,-7.6544 -1.18272,-22.62137 -3.67574,-41.25743 -2.49301,-18.63629 -6.01778,-40.86765 -9.87273,-62.7326 -3.85495,-21.86495 -8.03917,-43.362164 -11.85148,-60.525507 -1.90614,-8.581679 -3.7191,-16.077965 -5.35574,-22.00951 -1.6364,-5.93072 -3.05906,-10.252647 -4.34961,-12.705167 a 0.50005001,0.50005001 0 0 0 -0.002,0 c -0.0944,-0.178761 -0.22833,-0.333558 -0.39275,-0.451134 -0.004,-0.0028 -23.40438,-16.865401 -49.30654,-23.408391 -25.9608,-6.578724 -84.10235,-8.0351677 -113.45509,-8.0351677 z m 0,2.5998407 h 22.51593 c 29.26218,0 87.6711,1.584111 112.8143,7.955071 5.3e-4,1.38e-4 0.001,-1.38e-4 0.002,0 a 0.50005001,0.50005001 0 0 0 0,0.0021 c 24.79081,6.262258 47.36709,22.251704 48.14021,22.802741 1.05212,2.078752 2.46999,6.229985 4.04317,11.931573 1.61812,5.864421 3.42424,13.330539 5.32422,21.884452 3.79997,17.107826 7.97854,38.573109 11.82822,60.408199 3.84968,21.83505 7.36883,44.04288 9.85521,62.62925 2.48634,18.58613 3.92719,33.63042 3.65456,40.81249 -1.10646,29.13294 -19.64105,45.98175 -33.75608,47.05656 -7.32887,0.55788 -14.00144,-0.48695 -21.27105,-6.2792 -7.26742,-5.79049 -15.09723,-16.4695 -24.0745,-35.20716 -9.01368,-18.81362 -13.85919,-29.94323 -19.0314,-36.6112 -2.58745,-3.33571 -5.31452,-5.56064 -8.61291,-6.88485 -3.29649,-1.32345 -7.08838,-1.75183 -11.86543,-1.75183 h -176.6325 c -4.77702,0 -8.56894,0.42838 -11.86543,1.75183 -3.29844,1.32423 -6.02399,3.54923 -8.61136,6.88485 -5.17218,6.66792 -10.01976,17.79755 -19.03346,36.6112 -8.97728,18.73766 -16.80657,29.41662 -24.07399,35.20716 -7.2696,5.7923 -13.94274,6.83726 -21.27156,6.2792 -14.11497,-1.07438 -32.649174,-17.92362 -33.755566,-47.05656 -0.27264,-7.18195 1.16849,-22.22637 3.656108,-40.81249 2.487652,-18.58637 6.008639,-40.79417 9.859345,-62.62925 3.850713,-21.83509 8.031053,-43.300284 11.830283,-60.408199 1.8996,-8.553937 3.70513,-16.019931 5.32216,-21.884452 1.5725,-5.70302 2.99039,-9.854922 4.04109,-11.93364 0.78309,-0.557723 23.367,-16.539757 48.1366,-22.800674 a 0.50005001,0.50005001 0 0 0 0,-5.3e-4 0.50005001,0.50005001 0 0 0 0,-5.29e-4 0.50005001,0.50005001 0 0 0 0,-5.29e-4 0.50005001,0.50005001 0 0 0 0,-5.29e-4 c 5.3e-4,-1.38e-4 10e-4,1.38e-4 0.002,0 25.16012,-6.37119 83.55126,-7.955071 112.8298,-7.955071 z"
|
||||
inkscape:label="Controller Outline" />
|
||||
<path
|
||||
id="path15766"
|
||||
style="color:#000000;display:inline;opacity:1;fill:#211e1e;fill-opacity:1;stroke-width:0.2;stroke-linecap:square;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||
d="m 103.54984,89.758219 c -0.46441,0.240657 -0.80478,0.65855 -0.56351,1.122682 8.42782,16.264539 56.93552,89.848909 83.91219,114.452959 0.38624,0.35299 0.64705,0.10389 0.99937,-0.28288 0.35301,-0.3863 0.66878,-0.77094 0.28172,-1.12321 -26.41372,-24.09041 -75.45466,-98.34021 -83.58167,-114.024199 -0.1369,-0.265665 -0.95889,-0.191562 -1.0481,-0.145352 z m 371.93828,-0.254497 -0.10335,0.0041 c -0.33848,0.01543 -0.64484,0.210875 -0.80099,0.511598 -8.10964,15.68127 -57.07626,89.81787 -83.48999,113.90829 -0.38727,0.35208 -0.30372,0.78502 0.0491,1.17149 0.35227,0.38708 0.84561,0.58765 1.23193,0.2346 26.97647,-24.60363 75.48404,-98.17394 83.89668,-114.441079 0.23913,-0.46419 0.0557,-1.03981 -0.40823,-1.279509 -0.11581,-0.05996 -0.2459,-0.07335 -0.37518,-0.08217 z"
|
||||
sodipodi:nodetypes="ccccccccccccccccc"
|
||||
inkscape:label="Outline Color Blend" />
|
||||
<g
|
||||
id="g20546"
|
||||
style="display:inline"
|
||||
inkscape:label="Joystick Outline">
|
||||
<g
|
||||
id="g20740"
|
||||
style="display:inline"
|
||||
inkscape:label="Analog Stick Outline Body - Right">
|
||||
<path
|
||||
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 377.83935,140.67905 c 0,18.82089 -15.0705,34.07712 -33.66779,34.07712 -18.5973,0 -33.66772,-15.25623 -33.66772,-34.07712 0,-18.82088 15.07042,-34.07703 33.66772,-34.07703 18.59729,0 33.66779,15.25615 33.66779,34.07703 z m 0,0"
|
||||
id="path1711-8"
|
||||
inkscape:label="Outline #2" />
|
||||
<path
|
||||
style="display:inline;fill:#2c2c2c;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 370.998,140.67905 c 0,14.99458 -12.00083,27.16027 -26.82644,27.16027 -14.82554,0 -26.82652,-12.16569 -26.82652,-27.16027 0,-14.99457 12.00098,-27.14386 26.82652,-27.14386 14.82561,0 26.82644,12.14929 26.82644,27.14386"
|
||||
id="path1713-1"
|
||||
inkscape:label="Outline #1" />
|
||||
</g>
|
||||
<g
|
||||
id="g20744"
|
||||
style="display:inline"
|
||||
inkscape:label="Analog Stick Outline Body - Left">
|
||||
<path
|
||||
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 207.91654,82.385032 c 0,18.820888 -15.07045,34.077108 -33.66774,34.077108 -18.59729,0 -33.66783,-15.25622 -33.66783,-34.077108 0,-18.82096 15.07054,-34.077037 33.66783,-34.077037 18.59729,0 33.66774,15.256077 33.66774,34.077037 z m 0,0"
|
||||
id="path1695-1"
|
||||
inkscape:label="Outline #2" />
|
||||
<path
|
||||
style="display:inline;fill:#2c2c2c;fill-opacity:1;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 201.07528,82.385032 c 0,14.99457 -12.01729,27.143938 -26.82648,27.143938 -14.82564,0 -26.82648,-12.149368 -26.82648,-27.143938 0,-14.99457 12.00084,-27.160262 26.82648,-27.160262 14.80919,0 26.82648,12.165692 26.82648,27.160262 z m 0,0"
|
||||
id="path1699-1"
|
||||
inkscape:label="Outline #1" />
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
id="path14321"
|
||||
style="display:inline;fill:#c7c7c7;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.297881"
|
||||
d="m 287.64024,16.987688 v 5.61e-4 h -7.18474 c -4.55541,0 -8.25167,3.691333 -8.25167,8.246746 V 40.91088 c 0,4.555458 3.69626,8.246199 8.25167,8.246199 h 7.18474 c 0.1302,0 0.23893,-0.103795 0.23893,-0.233995 V 17.222229 c 0,-0.1302 -0.10873,-0.234541 -0.23893,-0.234541 z m 3.19589,0 c -0.11456,0 -0.20769,0.09424 -0.20769,0.208785 V 48.92307 c 0,0.1302 0.10381,0.233996 0.23399,0.233996 h 5.26348 c 4.5554,0 8.24674,-3.690743 8.24674,-8.246201 l -5.6e-4,-15.676431 c 0,-4.555413 -3.6908,-8.246746 -8.2462,-8.246746 z m -10.38063,2.74928 h 4.67 v 26.670816 h -4.67 c -1.47336,0 -2.85306,-0.572603 -3.88911,-1.613838 -1.04123,-1.036003 -1.61384,-2.415507 -1.61384,-3.883628 V 25.234434 c 0,-1.46812 0.57261,-2.847867 1.61384,-3.889106 1.03605,-1.036003 2.41575,-1.60836 3.88911,-1.60836 z m -0.14084,3.878693 c -1.67116,0 -3.02437,1.353716 -3.02437,3.02492 0,1.665928 1.35321,3.024922 3.02437,3.024922 1.67121,0 3.02492,-1.358994 3.02492,-3.024922 0,-1.671204 -1.35371,-3.02492 -3.02492,-3.02492 z m 16.90998,8.03851 c 1.67114,0 3.02492,1.35895 3.02492,3.024919 0,1.671206 -1.35378,3.024373 -3.02492,3.024373 -1.67121,0 -3.02492,-1.353167 -3.02492,-3.024373 0,-1.665969 1.35371,-3.024919 3.02492,-3.024919 z m 12.75507,22.209604 c -1.21304,0 -2.19689,0.984148 -2.19689,2.202384 0,1.218235 0.98385,2.207317 2.19689,2.207317 1.21824,0 2.2024,-0.989082 2.2024,-2.207317 0,-1.218236 -0.98416,-2.202384 -2.2024,-2.202384 z m -45.34587,0.135355 v 4.138989 h 0.95791 v -2.519672 l 1.87412,2.519672 h 0.90091 V 53.99913 h -0.95789 v 2.478023 l -1.85331,-2.478023 z m 6.62744,0 v 4.138989 h 1.03625 V 53.99913 Z m 3.93075,0 v 4.138989 h 0.95296 v -2.519672 l 1.87907,2.519672 h 0.89541 V 53.99913 h -0.95788 v 2.478023 l -1.85332,-2.478023 z m 6.46084,0 v 0.994606 h 1.28066 v 3.144383 h 1.00501 v -3.144383 h 1.24943 V 53.99913 Z m 6.34631,0 v 4.138989 h 3.25398 v -0.885007 h -2.2961 V 56.46181 h 2.12402 v -0.869667 h -2.12402 v -0.712937 h 2.2961 V 53.99913 Z m 6.45589,0 v 4.138989 h 0.95242 v -2.519672 l 1.87961,2.519672 h 0.89542 V 53.99913 h -0.95788 v 2.478023 l -1.85332,-2.478023 z m 6.99679,0 v 4.138989 h 1.72891 c 1.17657,0 2.12896,-0.926652 2.12896,-2.07196 0,-1.140206 -0.95239,-2.067029 -2.12896,-2.067029 z m 8.52785,0.895421 c 0.64557,0 1.17162,0.526025 1.17162,1.171608 0,0.650864 -0.52609,1.171061 -1.17162,1.171061 -0.64035,0 -1.16613,-0.520331 -1.16613,-1.171061 0,-0.645537 0.52578,-1.171608 1.16613,-1.171608 z m -7.55956,0.08822 h 0.80226 c 0.5987,0 1.08284,0.484688 1.08284,1.083382 0,0.603927 -0.48414,1.087766 -1.08284,1.087766 h -0.80226 z m -2.38431,4.784525 c -2.30114,0 -4.16475,1.864213 -4.16475,4.165296 0,2.295982 1.86361,4.159815 4.16475,4.159815 1.47859,0 2.78567,-0.775887 3.51975,-1.942089 l -1.4478,-1.259831 c -0.36465,0.739317 -1.13484,1.249423 -2.03031,1.249423 -1.24427,0 -2.25389,-0.98908 -2.25389,-2.207314 0,-1.223426 1.00962,-2.212795 2.25389,-2.212795 0.89547,0 1.66585,0.51015 2.03031,1.249422 l 1.4478,-1.259834 C 302.82151,60.543141 301.51443,59.7673 300.03584,59.7673 Z m -32.22638,0.140836 c -1.61911,0 -3.49289,0.78078 -3.49289,2.40514 0,1.327587 0.95255,1.936495 3.11313,2.311434 1.57748,0.270735 2.24558,0.406117 2.22322,0.921724 -0.0156,0.535922 -0.68738,0.83295 -1.86921,0.83295 -1.67115,0 -2.7438,-0.760066 -2.7438,-0.760066 l -0.84828,1.536022 c 0,0 1.45729,0.87953 3.48249,0.87953 2.76447,0 3.80581,-1.093181 3.80581,-2.540496 0,-1.213046 -0.63007,-1.993651 -3.56634,-2.509259 -0.87986,-0.156193 -1.66074,-0.286757 -1.71796,-0.734309 -0.0312,-0.21333 0.11992,-0.697596 1.6922,-0.697596 1.17659,0 2.37938,0.562239 2.37938,0.562239 l 0.64006,-1.561778 c 0,0 -1.43703,-0.645535 -3.09781,-0.645535 z m 4.25901,0.10905 2.21225,7.809447 h 2.02045 l 1.30148,-4.597113 1.29108,4.597113 h 2.02538 l 2.22813,-7.809447 h -1.93167 l -1.35354,5.09195 -1.4889,-5.09195 h -1.54644 l -1.47848,5.071127 -1.34862,-5.071127 z m 12.67178,0 v 7.809447 h 1.9525 v -7.809447 z m 3.55045,0 v 1.874684 h 2.41062 v 5.934763 h 1.89497 v -5.934763 h 2.35308 v -1.874684 z m 16.7379,0 v 7.814926 h 1.83249 v -3.009573 h 3.24905 v 3.009577 h 1.83741 v -7.814926 h -1.83741 v 2.967929 h -3.24905 v -2.967929 z"
|
||||
inkscape:label="Nintendo Switch Logo" />
|
||||
</g>
|
||||
<g
|
||||
id="g10760"
|
||||
inkscape:label="Analog Joysticks"
|
||||
style="display:inline">
|
||||
<g
|
||||
id="g2495"
|
||||
inkscape:label="Right Joystick"
|
||||
style="display:inline;stroke-width:0.901068"
|
||||
transform="matrix(1.1098172,0,0,1.1097717,-37.796036,-15.434424)"
|
||||
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009~1\VSCVIE~1\VSCVIE~1.20)\NINTEN~1\SWITCH~1\THEMES~1\THEMEA~1\ACTIVE~1\BUTTON~1\NSWITC~4.PNG"
|
||||
inkscape:export-xdpi="90.207504"
|
||||
inkscape:export-ydpi="90.207504">
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.44171;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||
d="m 366.06709,140.67905 c 0,12.23121 -9.79667,22.15669 -21.89553,22.15669 -12.08246,0 -21.89553,-9.92548 -21.89553,-22.15669 0,-12.23112 9.81307,-22.1566 21.89553,-22.1566 12.09886,0 21.89553,9.92548 21.89553,22.1566"
|
||||
id="path1717-9"
|
||||
inkscape:label="Color" />
|
||||
<path
|
||||
id="path1719-5"
|
||||
style="color:#000000;fill:#211e1e;stroke-width:3.77953;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||
d="m 998.16797,457.98242 c -52.54376,0 -95.24609,43.2182 -95.24609,96.33985 0,53.12169 42.70233,96.33203 95.24609,96.33203 52.61003,0 95.24803,-43.21336 95.24803,-96.33203 0,-53.11863 -42.638,-96.33985 -95.24803,-96.33985 z m 0,6.80664 c 48.88923,0 88.43943,40.04719 88.43943,89.53321 0,49.48601 -39.5502,89.5332 -88.43943,89.5332 -48.81792,0 -88.4375,-40.05021 -88.4375,-89.5332 0,-49.483 39.61958,-89.53321 88.4375,-89.53321 z m -0.0312,19.52344 c -38.2086,0 -69.17774,31.42482 -69.17774,70.00977 0,38.64299 30.9725,70.00195 69.17774,70.00195 38.20533,0 69.24223,-31.35568 69.24223,-70.00195 0,-38.58827 -31.0336,-70.00977 -69.24223,-70.00977 z m 0,6.04492 c 34.89833,0 63.19733,28.61127 63.19733,63.96485 0,35.4169 -28.2957,63.95703 -63.19733,63.95703 -34.90163,0 -63.13281,-28.53685 -63.13281,-63.95703 0,-35.35685 28.23449,-63.96485 63.13281,-63.96485 z"
|
||||
transform="matrix(0.23840262,0,0,0.2384124,106.2058,8.5222802)"
|
||||
inkscape:label="Outline" />
|
||||
</g>
|
||||
<g
|
||||
id="g2489"
|
||||
inkscape:label="Left Joystick"
|
||||
style="display:inline;stroke-width:0.901069"
|
||||
transform="matrix(1.1098161,0,0,1.1097704,-19.135317,-9.0434377)"
|
||||
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009~1\VSCVIE~1\VSCVIE~1.20)\NINTEN~1\SWITCH~1\THEMES~1\THEMEA~1\ACTIVE~1\BUTTON~1\NSWITC~3.PNG"
|
||||
inkscape:export-xdpi="87.630684"
|
||||
inkscape:export-ydpi="87.630684">
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.44171;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||
d="m 196.14423,82.385032 c 0,12.231124 -9.81296,22.156678 -21.89551,22.156678 -12.09884,0 -21.89543,-9.925554 -21.89543,-22.156678 0,-12.231198 9.79659,-22.156755 21.89543,-22.156755 12.08255,0 21.89551,9.925557 21.89551,22.156755"
|
||||
id="path1701-7"
|
||||
inkscape:label="Color" />
|
||||
<path
|
||||
id="path1707-6"
|
||||
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.44171;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 190.02859,82.385027 c 0,8.814462 -7.05807,15.969873 -15.77265,15.969873 -8.71458,0 -15.78701,-7.155411 -15.78701,-15.969873 0,-8.814527 7.07243,-15.969941 15.78701,-15.969941 8.71458,0 15.77265,7.155414 15.77265,15.969941 z m 0,0 m 6.11564,5e-6 c 0,12.231124 -9.81296,22.156678 -21.89551,22.156678 -12.09884,0 -21.89543,-9.925554 -21.89543,-22.156678 0,-12.231198 9.79659,-22.156755 21.89543,-22.156755 12.08255,0 21.89551,9.925557 21.89551,22.156755 z m 0,0"
|
||||
inkscape:label="Outline" />
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
id="g1427"
|
||||
inkscape:label="Face Buttons"
|
||||
style="display:inline">
|
||||
<g
|
||||
id="g10427"
|
||||
inkscape:label="X Button">
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||
d="m 414.49511,53.671306 c 0,-8.323055 -6.66169,-15.043682 -14.87451,-15.043682 -8.21289,0 -14.85826,6.720627 -14.85826,15.043682 0,8.306659 6.64537,15.043533 14.85826,15.043533 8.21282,0 14.87451,-6.736874 14.87451,-15.043533"
|
||||
id="path1737-4"
|
||||
inkscape:label="Color" />
|
||||
<path
|
||||
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 414.49511,53.671306 c 0,-8.323055 -6.66169,-15.043607 -14.87451,-15.043607 -8.21289,0 -14.85826,6.720552 -14.85826,15.043607 0,8.306659 6.64537,15.043681 14.85826,15.043681 8.21282,0 14.87451,-6.737022 14.87451,-15.043681 z m 0,0"
|
||||
id="path1739-4"
|
||||
inkscape:label="Outline" />
|
||||
<path
|
||||
id="path5694"
|
||||
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
|
||||
d="m 395.50194,48.189305 0.23257,0.379307 3.00548,4.923729 -3.45093,5.644618 h 1.79472 l 2.5528,-4.183724 2.5373,4.183724 h 1.79473 l -3.45094,-5.644618 3.23805,-5.303036 h -1.77715 l -2.34199,3.842145 -2.34145,-3.842145 z"
|
||||
inkscape:label="Text" />
|
||||
</g>
|
||||
<g
|
||||
id="g10432"
|
||||
inkscape:label="Y Button">
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||
d="m 367.25896,67.68476 c -8.21282,0 -14.87451,6.720553 -14.87451,15.043607 0,8.306734 6.66169,15.043682 14.87451,15.043682 8.21289,0 14.87459,-6.736948 14.87459,-15.043682 0,-8.323054 -6.6617,-15.043607 -14.87459,-15.043607"
|
||||
id="path1733-7"
|
||||
inkscape:label="Color" />
|
||||
<path
|
||||
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 367.25904,67.68476 c -8.2129,0 -14.87459,6.720553 -14.87459,15.043607 0,8.306734 6.66169,15.043682 14.87459,15.043682 8.21281,0 14.87451,-6.736948 14.87451,-15.043682 0,-8.323054 -6.6617,-15.043607 -14.87451,-15.043607 z m 0,0"
|
||||
id="path1735-4"
|
||||
inkscape:label="Outline" />
|
||||
<path
|
||||
id="path5704"
|
||||
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
|
||||
d="m 363.00989,77.245929 0.23048,0.379306 3.29488,5.402255 v 5.167643 h 1.51154 V 83.02749 l 3.47472,-5.781561 h -1.77562 l -2.47272,4.086055 -2.50372,-4.086055 z"
|
||||
inkscape:label="Text" />
|
||||
</g>
|
||||
<g
|
||||
id="g10422"
|
||||
inkscape:label="A Button">
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||
d="m 432.60263,97.772049 c 8.21289,0 14.87458,-6.736948 14.87458,-15.043682 0,-8.30666 -6.66169,-15.060003 -14.87458,-15.060003 -8.21282,0 -14.87459,6.753343 -14.87459,15.060003 0,8.306734 6.66177,15.043682 14.87459,15.043682"
|
||||
id="path1725-4"
|
||||
inkscape:label="Color" />
|
||||
<path
|
||||
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 432.60263,97.772049 c 8.21281,0 14.87451,-6.736948 14.87451,-15.043682 0,-8.30666 -6.6617,-15.060003 -14.87451,-15.060003 -8.2129,0 -14.87467,6.753343 -14.87467,15.060003 0,8.306734 6.66177,15.043682 14.87467,15.043682 z m 0,0"
|
||||
id="path1727-2"
|
||||
inkscape:label="Outline" />
|
||||
<path
|
||||
id="path5684"
|
||||
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
|
||||
d="m 432.3457,76.134888 -5.11546,10.947135 h 1.71101 l 1.59991,-3.433384 h 4.14031 l 1.61541,3.433384 h 1.67587 l -5.04672,-10.947135 h -0.1602 z m 0.28731,3.064412 1.35911,2.935221 h -2.74196 z"
|
||||
inkscape:label="Text" />
|
||||
</g>
|
||||
<g
|
||||
id="g10417"
|
||||
inkscape:label="B Button"
|
||||
style="display:inline">
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||
d="m 384.76234,111.19683 c 0,8.30666 6.64537,15.04361 14.85826,15.04361 8.21282,0 14.87451,-6.73695 14.87451,-15.04361 0,-8.32305 -6.66169,-15.059925 -14.87451,-15.059925 -8.21289,0 -14.85826,6.736875 -14.85826,15.059925"
|
||||
id="path1729-5"
|
||||
inkscape:label="Color" />
|
||||
<path
|
||||
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 384.76234,111.19683 c 0,8.30666 6.64544,15.04361 14.85826,15.04361 8.21282,0 14.87451,-6.73695 14.87451,-15.04361 0,-8.32305 -6.66169,-15.060074 -14.87451,-15.060074 -8.21282,0 -14.85826,6.737024 -14.85826,15.060074 z m 0,0"
|
||||
id="path1731-8"
|
||||
inkscape:label="Outline" />
|
||||
<path
|
||||
id="path5714"
|
||||
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
|
||||
d="m 399.47225,105.71448 -2.29081,5.3e-4 v 0.24959 10.69961 h 3.09181 c 1.07363,0 1.95181,-0.30075 2.62154,-0.93586 h 0.003 v -0.002 c 0.64646,-0.62979 0.97462,-1.41132 0.97462,-2.30425 0,-0.44095 -0.0871,-0.86836 -0.26718,-1.24799 l -0.003,-0.002 c -0.17555,-0.38581 -0.41021,-0.71541 -0.72864,-0.99839 l -0.004,-0.002 -0.003,-0.002 c -0.24373,-0.20363 -0.61497,-0.35596 -0.98599,-0.51004 0.29816,-0.22146 0.60555,-0.43885 0.77721,-0.74207 0.25247,-0.41527 0.3788,-0.88916 0.3788,-1.4025 0,-0.53531 -0.14348,-1.04004 -0.41031,-1.48622 l -0.003,-0.004 -0.004,-0.006 c -0.28318,-0.42562 -0.66442,-0.77237 -1.15033,-0.98857 h -0.003 c -0.49593,-0.22406 -1.16128,-0.31626 -1.99625,-0.31626 z m -0.77668,1.52962 h 0.94101 c 0.7048,0 1.16608,0.13228 1.42214,0.34985 l 0.006,0.006 0.006,0.004 c 0.28122,0.21413 0.40618,0.49457 0.40618,0.91209 0,0.31298 -0.0794,0.58368 -0.24804,0.83768 -0.18725,0.25995 -0.40392,0.43401 -0.70126,0.54725 -0.27373,0.10158 -0.79496,0.17932 -1.47846,0.17932 h -0.35346 z m 0.33742,4.39456 c 0.85844,0 1.47833,0.0404 1.76993,0.11111 0.4972,0.12048 0.85116,0.33801 1.10897,0.63872 l 0.003,0.004 0.003,0.002 c 0.27053,0.2999 0.39274,0.6386 0.39274,1.04283 0,0.32553 -0.096,0.61375 -0.28318,0.88884 v 0.002 l -0.003,0.002 c -0.18621,0.28793 -0.41764,0.47979 -0.73432,0.60927 h -0.003 -0.003 c -0.28475,0.12139 -0.8182,0.20877 -1.55083,0.20877 h -1.0392 v -3.5078 z"
|
||||
inkscape:label="Text" />
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
id="g1458"
|
||||
inkscape:label="Home"
|
||||
style="display:inline">
|
||||
<g
|
||||
id="g3754"
|
||||
inkscape:label="LED"
|
||||
style="display:inline">
|
||||
<path
|
||||
id="path1749-3"
|
||||
style="display:none;mix-blend-mode:lighten;fill:#b0b0b0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.04724;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:label="OFF"
|
||||
d="m 902.57812,287.30469 c -25.42484,0 -46.0996,20.88841 -46.0996,46.66015 0,25.77147 20.67476,46.59961 46.0996,46.59961 25.48661,0 46.09766,-20.82814 46.09766,-46.59961 0,-25.77174 -20.61105,-46.66015 -46.09766,-46.66015 z m 0,12.66992 c 18.5751,0 33.57032,15.20215 33.57032,33.99023 0,18.72584 -14.99522,33.99219 -33.57032,33.99219 -18.51337,0 -33.57226,-15.26635 -33.57226,-33.99219 0,-18.78808 15.05889,-33.99023 33.57226,-33.99023 z"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,80.072992,-5.9766386)" />
|
||||
<path
|
||||
id="path4088"
|
||||
style="display:inline;mix-blend-mode:lighten;fill:#2a4fff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.04724;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:label="ON"
|
||||
d="m 902.57812,287.30469 c -25.42484,0 -46.0996,20.88841 -46.0996,46.66015 0,25.77147 20.67476,46.59961 46.0996,46.59961 25.48661,0 46.09766,-20.82814 46.09766,-46.59961 0,-25.77174 -20.61105,-46.66015 -46.09766,-46.66015 z m 0,12.66992 c 18.5751,0 33.57032,15.20215 33.57032,33.99023 0,18.72584 -14.99522,33.99219 -33.57032,33.99219 -18.51337,0 -33.57226,-15.26635 -33.57226,-33.99219 0,-18.78808 15.05889,-33.99023 33.57226,-33.99023 z"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,80.072992,-5.9766386)" />
|
||||
</g>
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||
d="m 318.87991,73.39148 c 4.91466,0 8.88234,4.022539 8.88234,8.993552 0,4.954544 -3.96768,8.993478 -8.88234,8.993478 -4.89833,0 -8.88233,-4.038934 -8.88233,-8.993478 0,-4.971013 3.984,-8.993552 8.88233,-8.993552"
|
||||
id="path4084"
|
||||
inkscape:label="Color" />
|
||||
<path
|
||||
id="path1751-4"
|
||||
style="color:#000000;display:inline;fill:#211e1e;stroke-width:3.77953;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||
d="m 900.68555,283.15039 c -27.08126,0 -49.125,22.29152 -49.125,49.67969 0,27.38843 22.04865,49.62109 49.125,49.62109 27.13383,0 49.11914,-22.23751 49.11914,-49.62109 0,-27.38333 -21.98043,-49.67969 -49.11914,-49.67969 z m 0,6.04492 c 23.83442,0 43.07422,19.47576 43.07422,43.63477 0,24.1593 -19.23489,43.57617 -43.07422,43.57617 -23.77391,0 -43.08008,-19.42172 -43.08008,-43.57617 0,-24.15416 19.31108,-43.63477 43.08008,-43.63477 z m 0,6.6211 c -20.1687,0 -36.59766,16.61035 -36.59766,37.01367 0,20.34695 16.42224,37.01367 36.59766,37.01367 20.23162,0 36.59375,-16.67339 36.59375,-37.01367 0,-20.39667 -16.36882,-37.01367 -36.59375,-37.01367 z m 0,6.04687 c 16.92521,0 30.54687,13.7879 30.54687,30.9668 0,17.11191 -13.62835,30.96679 -30.54687,30.9668 -16.85129,0 -30.55274,-13.86156 -30.55274,-30.9668 0,-17.17225 13.69472,-30.9668 30.55274,-30.9668 z"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
|
||||
inkscape:label="Outline" />
|
||||
<path
|
||||
style="display:inline;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.297882"
|
||||
d="m 320.56433,84.51897 c 0,0.245744 -0.0984,0.344162 -0.33691,0.344162 h -2.69787 c -0.23909,0 -0.33751,-0.09842 -0.33751,-0.344162 v -2.016361 c 0,-0.239102 0.0984,-0.336311 0.33751,-0.336311 h 2.69787 c 0.23185,0 0.32302,0.09117 0.33691,0.323631 z m 3.56907,-3.280941 c -1.67914,-1.426277 -3.35829,-2.852432 -5.03743,-4.278708 -0.15457,-0.140683 -0.28076,-0.140683 -0.43533,0 -1.67214,1.426276 -3.35829,2.852431 -5.03744,4.278708 -0.29525,0.252988 -0.1757,0.589904 0.22462,0.589904 h 0.65336 c 0.23186,0 0.33027,0.09842 0.33027,0.337519 v 4.376944 c 0,0.245743 0.0984,0.343557 0.34416,0.343557 h 7.41215 c 0.2391,0 0.33751,-0.09842 0.33751,-0.343557 v -4.376944 c 0,-0.239101 0.0984,-0.337519 0.33028,-0.337519 h 0.65336 c 0.40032,0 0.52711,-0.336916 0.22462,-0.589904"
|
||||
id="path14283"
|
||||
inkscape:label="Icon" />
|
||||
</g>
|
||||
<g
|
||||
id="g1451"
|
||||
inkscape:label="Capture"
|
||||
style="display:inline">
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.564261"
|
||||
d="m 255.83852,90.838877 c -2.02465,0 -3.67381,-1.667934 -3.67381,-3.711846 v -8.813649 c 0,-2.043912 1.64916,-3.695451 3.67381,-3.695451 h 8.57199 c 2.02473,0 3.65741,1.651539 3.65741,3.695451 v 8.813649 c 0,2.043912 -1.63268,3.711846 -3.65741,3.711846 z m 0,0"
|
||||
id="path1759-9"
|
||||
inkscape:label="Color" />
|
||||
<path
|
||||
style="display:inline;fill:#211e1e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.564261"
|
||||
d="m 264.41051,73.751285 h -8.58839 c -2.46546,0 -4.50643,2.06038 -4.50643,4.562097 v 8.813649 c 0,2.518112 2.04097,4.562172 4.50643,4.562172 h 8.58839 c 2.48186,0 4.50651,-2.04406 4.50651,-4.562172 v -8.813649 c 0,-2.501717 -2.02465,-4.562097 -4.50651,-4.562097 m 0,1.716971 c 1.5512,0 2.80845,1.27534 2.80845,2.845126 v 8.813649 c 0,1.569787 -1.25725,2.861595 -2.80845,2.861595 h -8.58839 c -1.55112,0 -2.8083,-1.291808 -2.8083,-2.861595 v -8.813649 c 0,-1.569786 1.25718,-2.845126 2.8083,-2.845126 h 8.58839"
|
||||
id="path1761-8"
|
||||
inkscape:label="Outline" />
|
||||
<path
|
||||
style="display:inline;fill:#545759;fill-opacity:1;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 265.42287,82.728367 c 0,2.959595 -2.3675,5.363385 -5.30656,5.363385 -2.92265,0 -5.30641,-2.40379 -5.30641,-5.363385 0,-2.97599 2.38376,-5.379707 5.30641,-5.379707 2.93906,0 5.30656,2.403717 5.30656,5.379707 z m 0,0"
|
||||
id="path1765-8"
|
||||
inkscape:label="Symbol" />
|
||||
</g>
|
||||
<g
|
||||
id="g1445"
|
||||
inkscape:label="Minus"
|
||||
style="display:inline">
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||
d="m 247.75631,51.005934 c 0,5.281706 -4.21264,9.549506 -9.43749,9.549506 -5.19221,0 -9.4211,-4.2678 -9.4211,-9.549506 0,-5.281557 4.22889,-9.549357 9.4211,-9.549357 5.22485,0 9.43749,4.2678 9.43749,9.549357"
|
||||
id="path1767-5"
|
||||
inkscape:label="Color" />
|
||||
<path
|
||||
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 247.75624,51.005934 c 0,5.281706 -4.21257,9.549506 -9.43742,9.549506 -5.19221,0 -9.4211,-4.2678 -9.4211,-9.549506 0,-5.281557 4.22889,-9.549357 9.4211,-9.549357 5.22485,0 9.43742,4.2678 9.43742,9.549357 z m 0,0"
|
||||
id="path1769-6"
|
||||
inkscape:label="Outline" />
|
||||
<path
|
||||
id="path6584"
|
||||
style="color:#000000;display:inline;fill:#000000;fill-opacity:1;stroke-width:0.499999;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||
d="m 233.05657,50.134924 v 0.249598 1.492414 h 10.55646 v -1.742012 z"
|
||||
inkscape:label="Symbol" />
|
||||
</g>
|
||||
<g
|
||||
id="g1440"
|
||||
inkscape:label="Plus"
|
||||
style="display:inline">
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||
d="m 350.06585,51.005934 c 0,5.085409 -4.08195,9.222343 -9.11089,9.222343 -5.02887,0 -9.11083,-4.136934 -9.11083,-9.222343 0,-5.085408 4.08196,-9.222343 9.11083,-9.222343 5.02894,0 9.11089,4.136935 9.11089,9.222343"
|
||||
id="path1773-8"
|
||||
inkscape:label="Color" />
|
||||
<path
|
||||
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 350.06585,51.005934 c 0,5.085409 -4.08195,9.222492 -9.11082,9.222492 -5.02894,0 -9.1109,-4.137083 -9.1109,-9.222492 0,-5.085408 4.08196,-9.222343 9.1109,-9.222343 5.02887,0 9.11082,4.136935 9.11082,9.222343 z m 0,0"
|
||||
id="path1775-8"
|
||||
inkscape:label="Outline" />
|
||||
<path
|
||||
id="path6498"
|
||||
style="color:#000000;display:inline;fill:#000000;fill-opacity:1;stroke-width:0.499999;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||
d="m 340.03506,45.866968 v 4.218863 h -4.1951 v 1.840198 h 4.1951 v 4.202843 h 1.82211 v -4.202843 h 4.19766 v -1.840198 h -4.19766 v -4.218863 z"
|
||||
inkscape:label="Symbol" />
|
||||
</g>
|
||||
<g
|
||||
id="g1435"
|
||||
inkscape:label="D-PAD"
|
||||
style="display:inline">
|
||||
<path
|
||||
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.564261"
|
||||
d="m 215.9498,171.45317 c -0.47339,0 -0.84908,-0.37613 -0.84908,-0.85085 v -19.19701 h -18.95656 c -0.47338,0 -0.849,-0.37613 -0.849,-0.85077 v -19.73672 c 0,-0.47482 0.37557,-0.86621 0.849,-0.86621 h 18.95656 V 110.771 c 0,-0.47482 0.37558,-0.86628 0.84908,-0.86628 h 19.49534 c 0.47345,0 0.8491,0.39322 0.8491,0.86628 v 19.18061 h 18.97279 c 0.45713,0 0.84903,0.39248 0.84903,0.86621 v 19.73672 c 0,0.47482 -0.39182,0.85077 -0.84903,0.85077 h -18.97279 v 19.19701 c 0,0.47482 -0.3755,0.85085 -0.8491,0.85085 z m 0,0"
|
||||
id="path1779-7"
|
||||
inkscape:label="Color" />
|
||||
<path
|
||||
style="display:inline;fill:#211e1e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||
d="M 235.44514,109.05477 H 215.9498 c -0.93071,0 -1.69808,0.76895 -1.69808,1.7169 v 18.33036 h -18.10749 c -0.94692,0 -1.69807,0.76887 -1.69807,1.7169 v 19.73665 c 0,0.9481 0.75107,1.71689 1.69807,1.71689 h 18.10749 v 18.33037 c 0,0.9481 0.76745,1.70058 1.69808,1.70058 h 19.49534 c 0.94698,0 1.69805,-0.75226 1.69805,-1.70058 v -18.33037 h 18.12384 c 0.93066,0 1.68173,-0.76887 1.68173,-1.71689 v -19.73665 c 0,-0.9481 -0.75107,-1.7169 -1.68173,-1.7169 h -18.12384 v -18.33036 c 0,-0.9481 -0.75107,-1.7169 -1.69805,-1.7169 m 0,1.7169 v 20.04726 h 19.82189 v 19.73665 h -19.82189 v 20.04726 H 215.9498 v -20.04726 h -19.80557 v -19.73665 h 19.80557 v -20.04726 h 19.49534"
|
||||
id="path1781-2"
|
||||
inkscape:label="Outline" />
|
||||
<path
|
||||
id="path1783-2"
|
||||
style="display:inline;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.13264"
|
||||
d="m 548.21875,463.86523 c -0.77142,0 -1.54272,0.51065 -2.12891,1.53125 l -3.26953,5.68555 c -1.17267,2.03928 -3.0854,5.43727 -4.25781,7.47852 l -3.27148,5.74804 c -1.17238,2.04084 -0.18743,3.70899 2.16015,3.70899 h 21.53711 c 2.3562,0 3.33257,-1.66774 2.16016,-3.70899 l -3.27149,-5.74804 c -1.1721,-2.03955 -3.08544,-5.43727 -4.25781,-7.47852 l -3.27148,-5.68555 c -0.58606,-1.0206 -1.35749,-1.53125 -2.12891,-1.53125 z m -67.85352,77.11524 c -0.48532,0.006 -1.02874,0.16564 -1.60156,0.49609 l -5.61523,3.27539 c -2.03543,1.23364 -5.36999,3.15108 -7.40625,4.32617 l -5.61524,3.33594 c -2.03539,1.17468 -2.03539,3.08881 0,4.26367 l 5.61524,3.33789 c 2.03539,1.17495 5.36999,3.09052 7.40625,4.32813 l 5.61523,3.27344 c 2.03539,1.17494 3.70313,0.16819 3.70313,-2.16211 v -21.81641 c 0,-1.68879 -0.86129,-2.67406 -2.10157,-2.6582 z m 135.65821,0.002 c -1.22227,-0.0113 -2.05078,0.97942 -2.05078,2.6543 v 21.8164 c 0,2.34966 1.60436,3.33507 3.64062,2.16016 l 5.67774,-3.27344 c 2.03625,-1.23647 5.3689,-3.15299 7.34375,-4.32812 l 5.67773,-3.33399 c 2.03626,-1.17498 2.03626,-3.09053 0,-4.26562 l -5.67773,-3.33594 c -1.97458,-1.17468 -5.3075,-3.09048 -7.34375,-4.32812 l -5.67774,-3.27344 c -0.5727,-0.33046 -1.11156,-0.48775 -1.58984,-0.49219 z m -78.57227,80.0918 c -2.35619,0 -3.33256,1.66774 -2.16015,3.70898 l 3.27148,5.74805 c 1.17237,2.03928 3.08544,5.37551 4.25781,7.47852 l 3.26953,5.68554 c 1.17241,2.04125 3.08544,2.04125 4.25782,0 l 3.27148,-5.68554 c 1.17241,-2.10123 3.08544,-5.43727 4.25781,-7.47852 l 3.27149,-5.74805 c 1.17267,-2.04079 0.18742,-3.70898 -2.16016,-3.70898 z"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
|
||||
inkscape:label="Symbols" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 44 KiB |
1383
src/switch_pico_bridge/web/assets/controller-xbox.svg
Normal file
1383
src/switch_pico_bridge/web/assets/controller-xbox.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 101 KiB |
695
src/switch_pico_bridge/web/profile_editor.css
Normal file
695
src/switch_pico_bridge/web/profile_editor.css
Normal file
|
|
@ -0,0 +1,695 @@
|
|||
:root {
|
||||
--canvas: #070913;
|
||||
--canvas-soft: #0a1020;
|
||||
--surface: rgba(14, 20, 37, 0.94);
|
||||
--surface-solid: #0e1425;
|
||||
--surface-2: #141d32;
|
||||
--surface-3: #1b2742;
|
||||
--input: #090f1d;
|
||||
--ink: #f6f7ff;
|
||||
--muted: #abb5cc;
|
||||
--subtle: #7e8aa5;
|
||||
--line: #303d5b;
|
||||
--control-line: #60708f;
|
||||
--line-soft: rgba(129, 147, 190, 0.18);
|
||||
--blue: #6aa7ff;
|
||||
--blue-strong: #3d7eea;
|
||||
--sky: #59c7ff;
|
||||
--mint: #4adea5;
|
||||
--amber: #f6c76d;
|
||||
--rose: #ff7c9f;
|
||||
--cyan: var(--sky);
|
||||
--cyan-strong: #34b5f5;
|
||||
--coral: var(--rose);
|
||||
--lime: var(--mint);
|
||||
--warning: var(--amber);
|
||||
--shadow: 0 28px 80px rgba(1, 3, 12, 0.48);
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
[hidden] { display: none !important; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
button, input, select { font: inherit; }
|
||||
button, select { cursor: pointer; }
|
||||
button, .profile-button, .macro-tab, .checkbox-pill span, .select, .number-input {
|
||||
transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease, color 150ms ease, transform 150ms ease;
|
||||
}
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible {
|
||||
outline: 3px solid rgba(89, 199, 255, 0.95);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.ambient {
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
width: 30rem;
|
||||
height: 30rem;
|
||||
border-radius: 50%;
|
||||
filter: blur(120px);
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ambient-one { top: 10rem; left: -16rem; background: var(--blue); }
|
||||
.ambient-two { right: -14rem; bottom: 3rem; background: var(--sky); }
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 84px;
|
||||
padding: 14px clamp(18px, 4vw, 64px);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: rgba(7, 9, 19, 0.82);
|
||||
box-shadow: 0 10px 36px rgba(1, 3, 12, 0.24);
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 15px; }
|
||||
.brand-mark {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(106, 167, 255, 0.55);
|
||||
border-radius: 15px;
|
||||
background: rgba(106, 167, 255, 0.12);
|
||||
box-shadow: inset 0 1px rgba(255, 255, 255, 0.08), 0 10px 24px rgba(33, 92, 174, 0.26);
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
.brand-mark span { border-radius: 8px 5px 5px 8px; background: var(--blue); }
|
||||
.brand-mark span:last-child { border-radius: 5px 8px 8px 5px; background: var(--sky); }
|
||||
.topbar-meta { display: flex; align-items: center; gap: 20px; }
|
||||
.topbar-meta > p { color: var(--subtle); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
h1, h2, h3, h4, p { margin: 0; }
|
||||
h1 { font-size: 1.18rem; letter-spacing: -0.025em; }
|
||||
h2 { font-size: clamp(1.35rem, 2vw, 1.65rem); letter-spacing: -0.04em; }
|
||||
h3 { font-size: clamp(1.2rem, 1.7vw, 1.42rem); letter-spacing: -0.035em; }
|
||||
h4 { font-size: 1rem; }
|
||||
.eyebrow {
|
||||
margin-bottom: 5px;
|
||||
color: var(--blue);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.17em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 38px;
|
||||
padding: 8px 13px;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(16, 23, 41, 0.76);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.connection-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 4px rgba(246, 199, 109, 0.1), 0 0 14px currentColor;
|
||||
}
|
||||
.connection[data-state="ready"] .connection-dot { background: var(--mint); box-shadow: 0 0 0 4px rgba(74, 222, 165, 0.1), 0 0 14px var(--mint); }
|
||||
.connection[data-state="error"] .connection-dot { background: var(--rose); box-shadow: 0 0 0 4px rgba(255, 124, 159, 0.1), 0 0 14px var(--rose); }
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 292px minmax(0, 1fr);
|
||||
gap: 26px;
|
||||
width: min(1580px, calc(100% - 40px));
|
||||
margin: 28px auto 88px;
|
||||
}
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 112px;
|
||||
align-self: start;
|
||||
max-height: calc(100dvh - 132px);
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow), inset 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.sidebar-heading, .editor-header, .panel-heading, .subpanel-heading, .title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.icon-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
color: var(--sky);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-2);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.icon-button:hover { border-color: var(--sky); background: rgba(89, 199, 255, 0.1); transform: rotate(20deg); }
|
||||
.field-label { display: block; margin: 24px 0 8px; color: var(--muted); font-size: 0.69rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.11em; }
|
||||
.field-help { margin-top: 8px; color: var(--muted); font-size: 0.76rem; line-height: 1.55; }
|
||||
.select, .number-input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 9px 12px;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--control-line);
|
||||
border-radius: 11px;
|
||||
background: var(--input);
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
.select:hover, .number-input:hover { border-color: #7d90b6; }
|
||||
.select:focus, .number-input:focus { border-color: var(--sky); box-shadow: 0 0 0 4px rgba(89, 199, 255, 0.1); }
|
||||
.select:disabled, .number-input:disabled { cursor: not-allowed; opacity: 0.45; border-color: var(--line-soft); box-shadow: none; }
|
||||
.select option:disabled { color: var(--subtle); }
|
||||
.profile-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 22px; }
|
||||
.profile-button {
|
||||
--slot-color: var(--blue);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-height: 66px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 14px;
|
||||
background: rgba(20, 29, 50, 0.72);
|
||||
text-align: left;
|
||||
}
|
||||
.profile-button::before { position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--slot-color); content: ""; opacity: 0.5; }
|
||||
.profile-button:nth-child(1) { --slot-color: #59c7ff; }
|
||||
.profile-button:nth-child(2) { --slot-color: #4adea5; }
|
||||
.profile-button:nth-child(3) { --slot-color: #f6c76d; }
|
||||
.profile-button:nth-child(4) { --slot-color: #a58bfa; }
|
||||
.profile-button:nth-child(5) { --slot-color: #ff7c7c; }
|
||||
.profile-button:nth-child(6) { --slot-color: #45dfe2; }
|
||||
.profile-button:nth-child(7) { --slot-color: #ff7cbd; }
|
||||
.profile-button:nth-child(8) { --slot-color: #c7ed64; }
|
||||
.profile-button:hover { color: var(--ink); border-color: var(--slot-color); background: var(--surface-2); transform: translateY(-1px); }
|
||||
.profile-button.selected { color: var(--ink); border-color: var(--slot-color); background: color-mix(in srgb, var(--slot-color) 12%, var(--surface-2)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--slot-color) 12%, transparent); }
|
||||
.profile-button.selected::before { width: 4px; opacity: 1; }
|
||||
.profile-number { display: flex; align-items: center; gap: 8px; font-size: 0.74rem; font-weight: 780; }
|
||||
.profile-number span { display: grid; place-items: center; width: 25px; height: 25px; color: #07101b; border-radius: 8px; background: var(--slot-color); font-size: 0.73rem; font-weight: 900; }
|
||||
.mini-active { margin: 6px 0 0 33px; color: var(--mint); font-size: 0.59rem; font-weight: 850; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.sidebar-note { display: flex; gap: 11px; margin-top: 22px; padding: 14px 2px 0; color: var(--muted); border-top: 1px solid var(--line-soft); font-size: 0.73rem; line-height: 1.55; }
|
||||
.note-icon { display: grid; flex: 0 0 auto; place-items: center; width: 22px; height: 22px; color: var(--blue); border: 1px solid rgba(106, 167, 255, 0.55); border-radius: 8px; background: rgba(106, 167, 255, 0.08); font-size: 0.7rem; font-weight: 900; }
|
||||
|
||||
.editor-shell { min-width: 0; }
|
||||
.editor-header {
|
||||
min-height: 96px;
|
||||
margin-bottom: 12px;
|
||||
padding: 20px 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.header-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 9px; }
|
||||
.button {
|
||||
min-height: 42px;
|
||||
padding: 9px 16px;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--control-line);
|
||||
border-radius: 11px;
|
||||
background: var(--surface-3);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 780;
|
||||
}
|
||||
.button:hover { border-color: #596b91; transform: translateY(-1px); }
|
||||
.button:active { transform: translateY(0); }
|
||||
.button:disabled { cursor: not-allowed; opacity: 0.42; transform: none; }
|
||||
.button-primary { color: #080a16; border-color: transparent; background: var(--blue); box-shadow: 0 8px 24px rgba(33, 92, 174, 0.24); }
|
||||
.button-secondary { border-color: rgba(106, 167, 255, 0.48); background: rgba(106, 167, 255, 0.1); }
|
||||
.button-ghost { color: var(--muted); background: transparent; }
|
||||
.active-badge, .dirty-badge { padding: 5px 9px; border: 1px solid currentColor; border-radius: 999px; font-size: 0.62rem; font-weight: 850; letter-spacing: 0.09em; text-transform: uppercase; }
|
||||
.active-badge { color: var(--mint); background: rgba(74, 222, 165, 0.08); }
|
||||
.dirty-badge { color: var(--amber); background: rgba(246, 199, 109, 0.08); }
|
||||
.section-nav {
|
||||
position: sticky;
|
||||
top: 96px;
|
||||
z-index: 18;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 14px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(10, 15, 29, 0.9);
|
||||
box-shadow: 0 12px 30px rgba(1, 3, 12, 0.24);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.section-nav a { flex: 1 0 auto; padding: 8px 13px; color: var(--muted); border-radius: 9px; font-size: 0.73rem; font-weight: 780; text-align: center; text-decoration: none; }
|
||||
.section-nav a:hover, .section-nav a:focus-visible { color: var(--ink); background: var(--surface-3); }
|
||||
|
||||
.panel {
|
||||
--section-accent: var(--blue);
|
||||
position: relative;
|
||||
scroll-margin-top: 152px;
|
||||
margin-bottom: 20px;
|
||||
padding: clamp(22px, 3vw, 32px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 18px 52px rgba(1, 3, 12, 0.24), inset 0 1px rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
.panel::before { position: absolute; inset: 0 auto auto 0; width: 64px; height: 3px; background: var(--section-accent); content: ""; }
|
||||
#analog { --section-accent: var(--sky); }
|
||||
#feedback { --section-accent: var(--amber); }
|
||||
#turbo { --section-accent: var(--mint); }
|
||||
#macro { --section-accent: var(--rose); }
|
||||
#shortcuts { --section-accent: var(--sky); }
|
||||
#shift { --section-accent: var(--amber); }
|
||||
.panel .eyebrow { color: var(--section-accent); }
|
||||
.panel-heading { align-items: flex-end; margin-bottom: 26px; }
|
||||
.panel-heading > p { max-width: 440px; color: var(--muted); font-size: 0.78rem; line-height: 1.55; text-align: right; }
|
||||
.controller-mapper { display: grid; grid-template-columns: minmax(0, 1fr) 250px; gap: 18px; align-items: stretch; }
|
||||
.controller-canvas {
|
||||
padding: 18px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(89, 199, 255, 0.24);
|
||||
border-radius: 18px;
|
||||
background: #090f1d;
|
||||
box-shadow: inset 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.controller-caption { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 27px; }
|
||||
.controller-caption strong { color: #dfe8ff; font-size: 0.8rem; }
|
||||
.controller-live { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 0.65rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; }
|
||||
.controller-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--mint); box-shadow: 0 0 0 4px rgba(74, 222, 165, 0.09), 0 0 12px var(--mint); }
|
||||
.controller-photo-wrap { position: relative; width: 100%; margin: 10px auto 4px; aspect-ratio: 1.62; transition: aspect-ratio 180ms ease; }
|
||||
.controller-canvas[data-style="switch"] .controller-photo-wrap { aspect-ratio: 1.38; }
|
||||
.controller-canvas[data-style="playstation"] .controller-photo-wrap { aspect-ratio: 1.8; }
|
||||
.controller-photo { display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; user-select: none; filter: drop-shadow(0 22px 28px rgba(0, 0, 0, 0.46)); }
|
||||
.controller-hotspots { position: absolute; inset: 0; }
|
||||
.controller-hotspots button {
|
||||
--x: 50%;
|
||||
--y: 50%;
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: var(--y);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
color: #eff6ff;
|
||||
border: 2px solid var(--sky);
|
||||
border-radius: 50%;
|
||||
background: rgba(7, 12, 25, 0.9);
|
||||
box-shadow: 0 0 0 4px rgba(89, 199, 255, 0.12), 0 5px 16px rgba(0, 0, 0, 0.42);
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.controller-hotspots button:hover, .controller-hotspots button:focus-visible { z-index: 2; border-color: var(--blue); transform: translate(-50%, -50%) scale(1.13); }
|
||||
.controller-hotspots button.selected { z-index: 3; color: #090a16; border-color: var(--rose); background: var(--rose); box-shadow: 0 0 0 5px rgba(255, 124, 159, 0.18), 0 8px 20px rgba(0, 0, 0, 0.48); transform: translate(-50%, -50%) scale(1.16); }
|
||||
.controller-hotspots button[data-controller-button$="_trigger"] { width: 60px; height: 30px; border-radius: 11px; }
|
||||
.controller-hotspots button.disabled-map { color: var(--subtle); border-color: #56617a; background: rgba(8, 11, 20, 0.9); box-shadow: none; }
|
||||
.controller-hotspots button[data-controller-button$="_shoulder"] { width: 60px; height: 30px; border-radius: 11px; }
|
||||
.controller-hotspots button[data-controller-button^="dpad_"] { width: 27px; height: 27px; font-size: 0.58rem; }
|
||||
.controller-hotspots button[data-controller-button="select"],
|
||||
.controller-hotspots button[data-controller-button="start"],
|
||||
.controller-hotspots button[data-controller-button="capture"],
|
||||
.controller-hotspots button[data-controller-button="system"] { width: 29px; height: 29px; font-size: 0.63rem; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_shoulder"] { --x: 22%; --y: 12%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_trigger"] { --x: 22%; --y: 4%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_trigger"] { --x: 78%; --y: 4%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_shoulder"] { --x: 78%; --y: 12%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_stick"] { --x: 23%; --y: 43%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_stick"] { --x: 63%; --y: 68%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_up"] { --x: 36%; --y: 62%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_right"] { --x: 40%; --y: 68%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_down"] { --x: 36%; --y: 74%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_left"] { --x: 32%; --y: 68%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="north"] { --x: 77%; --y: 33%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="east"] { --x: 84%; --y: 43%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="south"] { --x: 77%; --y: 54%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="west"] { --x: 70%; --y: 43%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="select"] { --x: 42%; --y: 43%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="start"] { --x: 57%; --y: 43%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="capture"] { --x: 50%; --y: 52%; }
|
||||
.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="system"] { --x: 50%; --y: 26%; }
|
||||
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="left_shoulder"] { --x: 27%; --y: 8%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="right_shoulder"] { --x: 73%; --y: 8%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="left_trigger"] { --x: 27%; --y: 2%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="right_trigger"] { --x: 73%; --y: 2%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="left_stick"] { --x: 24%; --y: 30%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="right_stick"] { --x: 63%; --y: 51%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="dpad_up"] { --x: 35%; --y: 46%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="dpad_right"] { --x: 39%; --y: 51%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="dpad_down"] { --x: 35%; --y: 56%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="dpad_left"] { --x: 31%; --y: 51%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="north"] { --x: 77%; --y: 22%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="east"] { --x: 85%; --y: 32%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="south"] { --x: 77%; --y: 43%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="west"] { --x: 69%; --y: 32%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="select"] { --x: 39%; --y: 21%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="start"] { --x: 61%; --y: 21%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="capture"] { --x: 44%; --y: 31%; }
|
||||
.controller-canvas[data-style="switch"] [data-controller-button="system"] { --x: 57%; --y: 32%; }
|
||||
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="left_shoulder"] { --x: 21%; --y: 12%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="right_shoulder"] { --x: 79%; --y: 12%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="left_stick"] { --x: 34%; --y: 69%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="left_trigger"] { --x: 21%; --y: 3%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="right_trigger"] { --x: 79%; --y: 3%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="right_stick"] { --x: 66%; --y: 69%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="dpad_up"] { --x: 18%; --y: 42%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="dpad_right"] { --x: 24%; --y: 50%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="dpad_down"] { --x: 18%; --y: 58%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="dpad_left"] { --x: 12%; --y: 50%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="north"] { --x: 83%; --y: 39%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="east"] { --x: 91%; --y: 50%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="south"] { --x: 83%; --y: 61%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="west"] { --x: 75%; --y: 50%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="select"] { --x: 25%; --y: 30%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="start"] { --x: 75%; --y: 30%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="capture"] { --x: 50%; --y: 36%; }
|
||||
.controller-canvas[data-style="playstation"] [data-controller-button="system"] { --x: 50%; --y: 72%; }
|
||||
|
||||
.controller-hint { margin-top: 4px; color: var(--muted); font-size: 0.72rem; text-align: center; }
|
||||
.controller-credit { margin-top: 8px; color: var(--subtle); font-size: 0.64rem; text-align: center; }
|
||||
.controller-credit a { color: var(--muted); text-decoration-color: rgba(171, 181, 204, 0.45); text-underline-offset: 3px; }
|
||||
.mapping-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 21px;
|
||||
border: 1px solid rgba(106, 167, 255, 0.32);
|
||||
border-radius: 18px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.selected-control-mark { display: grid; place-items: center; width: 58px; height: 58px; margin: 15px 0; color: #090a16; border-radius: 18px; background: var(--rose); box-shadow: 0 10px 28px rgba(255, 124, 159, 0.22); font-size: 1.18rem; font-weight: 950; }
|
||||
.mapping-inspector h4 { font-size: 1.16rem; }
|
||||
.mapping-inspector > p:not(.eyebrow) { min-height: 50px; margin-top: 8px; color: var(--muted); font-size: 0.76rem; line-height: 1.55; }
|
||||
.mapping-inspector .field-label { margin-top: 20px; }
|
||||
.mapping-key { display: flex; flex-wrap: wrap; gap: 13px; margin-top: auto; padding-top: 24px; color: var(--muted); font-size: 0.67rem; }
|
||||
.mapping-key span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.key-dot { width: 9px; height: 9px; border: 2px solid var(--sky); border-radius: 50%; }
|
||||
.key-dot.disabled { border-color: #69758e; }
|
||||
.mapping-grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 12px; }
|
||||
.mapping-grid.compact { grid-template-columns: repeat(4, minmax(140px, 1fr)); }
|
||||
.control-card { padding: 14px; border: 1px solid var(--line-soft); border-radius: 14px; background: var(--surface-2); }
|
||||
.control-card:hover { border-color: #415273; }
|
||||
.control-card label, .number-field label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 0.7rem; font-weight: 760; letter-spacing: 0.035em; }
|
||||
.control-card .select { min-height: 40px; }
|
||||
.modifier-controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); gap: 14px; margin-bottom: 18px; }
|
||||
.turbo-defaults { margin-bottom: 14px; }
|
||||
.turbo-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 14px; }
|
||||
.turbo-setting-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.turbo-setting-grid .number-input { min-width: 0; padding-inline: 9px; }
|
||||
.turbo-setting-grid .number-field label { min-height: 2.6em; line-height: 1.3; }
|
||||
.turbo-override { display: block; margin-block: 14px; }
|
||||
.turbo-card.narrow-pulse { border-color: var(--amber); }
|
||||
.timing-notice { margin-bottom: 18px; padding: 13px 15px; color: var(--muted); border: 1px solid var(--line); border-radius: 12px; background: var(--surface-2); font-size: 0.76rem; line-height: 1.6; }
|
||||
.timing-notice.warning { color: var(--amber); border-color: var(--amber); }
|
||||
.macro-playback-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 20px; }
|
||||
.playtest-panel { margin-bottom: 18px; padding: 19px; border: 1px solid rgba(89, 199, 255, 0.28); border-radius: 17px; background: #0b1324; }
|
||||
.playtest-panel[data-state="waiting"] .playtest-grid { opacity: 0.48; }
|
||||
.playtest-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.playtest-heading h4 { margin-top: 3px; }
|
||||
.playtest-status { padding: 5px 9px; color: var(--amber); border: 1px solid currentColor; border-radius: 999px; font-size: 0.62rem; font-weight: 850; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.playtest-panel[data-state="live"] .playtest-status { color: var(--mint); }
|
||||
.playtest-panel[data-state="error"] .playtest-status { color: var(--rose); }
|
||||
.playtest-help { margin: 7px 0 16px; color: var(--muted); font-size: 0.73rem; }
|
||||
.playtest-grid { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)) minmax(300px, 1.4fr); gap: 12px; transition: opacity 150ms ease; }
|
||||
.playtest-card { min-width: 0; padding: 14px; border: 1px solid var(--line-soft); border-radius: 14px; background: var(--surface-2); }
|
||||
.playtest-card-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 11px; }
|
||||
.playtest-card-heading strong { font-size: 0.78rem; }
|
||||
.playtest-card-heading span { color: var(--muted); font-size: 0.63rem; font-variant-numeric: tabular-nums; }
|
||||
.stick-scope { --raw-x: 50%; --raw-y: 50%; --output-x: 50%; --output-y: 50%; --inner-size: 0%; --outer-size: 100%; position: relative; width: min(100%, 174px); margin: auto; aspect-ratio: 1; overflow: hidden; border: 1px solid var(--control-line); border-radius: 50%; background: #080e1c; }
|
||||
.stick-axis { position: absolute; background: rgba(171, 181, 204, 0.16); }
|
||||
.stick-axis-x { top: 50%; right: 10%; left: 10%; height: 1px; }
|
||||
.stick-axis-y { top: 10%; bottom: 10%; left: 50%; width: 1px; }
|
||||
.stick-ring { position: absolute; top: 50%; left: 50%; border: 1px solid; border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.stick-ring-outer { width: var(--outer-size); height: var(--outer-size); border-color: rgba(89, 199, 255, 0.62); }
|
||||
.stick-ring-inner { width: var(--inner-size); height: var(--inner-size); border-color: rgba(255, 124, 159, 0.8); background: rgba(255, 124, 159, 0.05); }
|
||||
.stick-dot { position: absolute; top: 50%; left: 50%; border-radius: 50%; transform: translate(-50%, -50%); transition: left 45ms linear, top 45ms linear; }
|
||||
.stick-dot-raw { top: var(--raw-y); left: var(--raw-x); z-index: 2; width: 15px; height: 15px; border: 3px solid var(--amber); background: transparent; box-shadow: 0 0 0 2px rgba(246, 199, 109, 0.12); }
|
||||
.stick-dot-output { top: var(--output-y); left: var(--output-x); z-index: 3; width: 8px; height: 8px; border: 2px solid #080e1c; background: var(--blue); box-shadow: 0 0 0 2px rgba(106, 167, 255, 0.3); }
|
||||
.trigger-playtest { display: flex; flex-direction: column; }
|
||||
.trigger-meter { display: grid; grid-template-columns: 28px minmax(120px, 1fr) 88px; align-items: center; gap: 9px; margin-top: 12px; }
|
||||
.trigger-meter > span { color: var(--ink); font-size: 0.7rem; font-weight: 850; }
|
||||
.trigger-meter output { color: var(--muted); font-size: 0.65rem; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.trigger-track { --raw: 0%; --output: 0%; --threshold: 35%; position: relative; height: 28px; overflow: hidden; border: 1px solid var(--control-line); border-radius: 8px; background: #080e1c; }
|
||||
.trigger-bar { position: absolute; left: 0; height: 7px; border-radius: 0 4px 4px 0; transition: width 45ms linear; }
|
||||
.trigger-bar-raw { top: 4px; width: var(--raw); background: var(--amber); }
|
||||
.trigger-bar-output { bottom: 4px; width: var(--output); background: var(--blue); }
|
||||
.trigger-threshold { position: absolute; top: 0; bottom: 0; left: var(--threshold); width: 2px; background: var(--rose); transform: translateX(-1px); }
|
||||
.playtest-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: auto; padding-top: 16px; color: var(--muted); font-size: 0.61rem; }
|
||||
.playtest-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.playtest-legend i { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.legend-raw { background: var(--amber); }
|
||||
.legend-output { background: var(--blue); }
|
||||
.legend-threshold { background: var(--rose); }
|
||||
.controller-hotspots button.pressed { z-index: 4; color: #071018; border-color: var(--mint); background: var(--mint); box-shadow: 0 0 0 5px rgba(74, 222, 165, 0.2), 0 8px 20px rgba(0, 0, 0, 0.42); }
|
||||
.analog-grid, .feedback-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.subpanel { padding: 19px; border: 1px solid var(--line-soft); border-radius: 16px; background: var(--surface-2); }
|
||||
.subpanel-heading { align-items: flex-start; margin-bottom: 16px; }
|
||||
.subpanel-heading span { max-width: 240px; color: var(--muted); font-size: 0.72rem; line-height: 1.45; text-align: right; }
|
||||
.number-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.number-field.wide { grid-column: 1 / -1; }
|
||||
.toggle-row { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; }
|
||||
.checkbox-pill { position: relative; }
|
||||
.checkbox-pill input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.checkbox-pill span { display: block; padding: 8px 11px; color: var(--muted); border: 1px solid var(--control-line); border-radius: 999px; background: rgba(9, 15, 29, 0.45); font-size: 0.7rem; font-weight: 750; cursor: pointer; }
|
||||
.checkbox-pill span:hover { color: var(--ink); border-color: #7d90b6; background: var(--surface-3); }
|
||||
.checkbox-pill input:focus-visible + span { outline: 3px solid rgba(89, 199, 255, 0.95); outline-offset: 3px; }
|
||||
.checkbox-pill input:checked + span { color: #080b15; border-color: var(--blue); background: var(--blue); box-shadow: 0 6px 18px rgba(33, 92, 174, 0.2); }
|
||||
.checkbox-pill input:disabled + span { opacity: 0.4; cursor: not-allowed; }
|
||||
.controller-choices { align-items: stretch; gap: 8px; }
|
||||
.controller-choice span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 60px;
|
||||
min-height: 58px;
|
||||
padding: 8px 9px;
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.controller-choice span[data-button$="_shoulder"],
|
||||
.controller-choice span[data-button$="_trigger"],
|
||||
.controller-choice span[data-button$="_stick"] { min-width: 78px; }
|
||||
.controller-choice b { color: var(--ink); font-size: 1.05rem; font-weight: 930; line-height: 1; }
|
||||
.controller-choice small { max-width: 78px; margin-top: 6px; color: var(--muted); font-size: 0.59rem; font-weight: 720; line-height: 1.1; }
|
||||
.controller-choice input:checked + span b,
|
||||
.controller-choice input:checked + span small { color: #080b15; }
|
||||
.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="north"] b { color: #6fdda9; }
|
||||
.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="east"] b { color: #ff8995; }
|
||||
.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="south"] b { color: #7fbcff; }
|
||||
.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="west"] b { color: #dda1ff; }
|
||||
.check-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.range-row { margin-bottom: 18px; }
|
||||
.range-meta { display: flex; justify-content: space-between; margin-bottom: 9px; color: var(--muted); font-size: 0.74rem; }
|
||||
.range-meta output { color: var(--ink); font-weight: 760; font-variant-numeric: tabular-nums; }
|
||||
input[type="range"] { width: 100%; accent-color: var(--blue); }
|
||||
.builtin-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
|
||||
.action-card { padding: 19px; border: 1px solid var(--line-soft); border-radius: 17px; background: var(--surface-2); }
|
||||
.action-card-heading { min-height: 76px; margin-bottom: 14px; }
|
||||
.action-card-heading h4 { margin-top: 5px; font-size: 1.04rem; }
|
||||
.action-card-heading > span { display: block; margin-top: 8px; color: var(--muted); font-size: 0.73rem; line-height: 1.45; }
|
||||
.action-kind { color: var(--section-accent); font-size: 0.62rem; font-weight: 850; letter-spacing: 0.11em; text-transform: uppercase; }
|
||||
|
||||
.macro-controls { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(280px, 0.9fr); gap: 14px; margin-bottom: 20px; }
|
||||
.macro-picker { display: flex; grid-column: 1 / -1; align-items: stretch; justify-content: space-between; gap: 14px; padding: 13px; border: 1px solid var(--line); border-radius: 16px; background: #090f1d; }
|
||||
.macro-tabs { display: grid; grid-template-columns: repeat(4, minmax(90px, 1fr)); gap: 8px; flex: 1; }
|
||||
.macro-tab { padding: 10px 12px; color: var(--muted); border: 1px solid var(--control-line); border-radius: 11px; background: var(--surface-2); font-weight: 820; }
|
||||
.macro-tab small { display: block; margin-top: 4px; font-size: 0.61rem; font-weight: 650; }
|
||||
.macro-tab.selected { color: #080a15; border-color: transparent; background: var(--rose); box-shadow: 0 7px 20px rgba(255, 124, 159, 0.2); }
|
||||
.macro-budget { display: flex; flex: 0 0 180px; flex-direction: column; justify-content: center; color: var(--muted); font-size: 0.7rem; }
|
||||
.macro-budget strong { color: var(--ink); }
|
||||
.macro-budget progress { width: 100%; height: 6px; margin-top: 9px; overflow: hidden; border: 0; border-radius: 999px; background: var(--surface-3); accent-color: var(--rose); }
|
||||
.macro-budget progress::-webkit-progress-bar { border-radius: inherit; background: var(--surface-3); }
|
||||
.macro-budget progress::-webkit-progress-value { border-radius: inherit; background: var(--rose); }
|
||||
.macro-steps-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: 6px 0 13px; padding: 0 4px; }
|
||||
.macro-steps-heading h4 { margin-top: 4px; font-size: 1.05rem; }
|
||||
.macro-step-action { display: flex; }
|
||||
.macro-steps { display: grid; gap: 13px; }
|
||||
.macro-step { padding: 18px; border: 1px solid var(--line); border-left: 3px solid var(--rose); border-radius: 15px; background: var(--surface-2); }
|
||||
.macro-step.end { min-height: 68px; border: 1px dashed #43506c; border-left: 3px solid var(--subtle); background: rgba(20, 29, 50, 0.46); opacity: 1; }
|
||||
.macro-step.end .field-help { color: var(--muted); }
|
||||
.step-header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 15px; }
|
||||
.step-number { color: var(--rose); font-size: 0.7rem; font-weight: 850; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.remove-step { min-height: 32px; padding: 6px 10px; color: #ff9ab2; border: 1px solid rgba(255, 124, 159, 0.25); border-radius: 8px; background: rgba(255, 124, 159, 0.08); font-size: 0.7rem; font-weight: 720; }
|
||||
.remove-step:hover { border-color: var(--rose); background: rgba(255, 124, 159, 0.14); }
|
||||
.step-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.step-group { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line-soft); }
|
||||
.step-group-title { display: block; margin-bottom: 9px; color: var(--muted); font-size: 0.69rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.step-identity, .step-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
|
||||
.step-timing { color: var(--muted); font-size: 0.7rem; font-variant-numeric: tabular-nums; }
|
||||
.step-tool { min-height: 34px; padding: 6px 9px; color: var(--muted); border: 1px solid var(--control-line); border-radius: 8px; background: var(--input); font-size: 0.7rem; font-weight: 720; }
|
||||
.step-tool:hover:not(:disabled) { color: var(--ink); border-color: var(--sky); }
|
||||
.step-handle { cursor: grab; touch-action: pan-y; }
|
||||
.step-handle:active { cursor: grabbing; }
|
||||
.step-tool:disabled, .remove-step:disabled { cursor: not-allowed; opacity: 0.4; }
|
||||
.macro-step.dragging { opacity: 0.5; }
|
||||
.macro-step.drop-before { box-shadow: 0 -4px 0 var(--sky); }
|
||||
.macro-step.drop-after { box-shadow: 0 4px 0 var(--sky); }
|
||||
.macro-step.preview-active { border-color: var(--mint); box-shadow: 0 0 0 1px var(--mint); }
|
||||
.macro-notice { min-height: 1.4em; margin: 9px 0 14px; color: var(--mint); font-size: 0.76rem; line-height: 1.5; }
|
||||
.macro-notice.error { color: var(--rose); }
|
||||
.macro-preview { margin-bottom: 24px; padding: 19px; border: 1px solid var(--line); border-radius: 17px; background: #0b1324; }
|
||||
.macro-preview[data-state="playing"] { border-color: var(--mint); }
|
||||
.macro-preview-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 14px; }
|
||||
.macro-preview-heading h4 { margin-top: 5px; }
|
||||
.macro-preview-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.macro-preview > .field-help { margin-block: 12px; }
|
||||
.macro-preview-timing { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-block: 10px; color: var(--muted); font-size: 0.73rem; }
|
||||
.macro-preview-timing output { font-variant-numeric: tabular-nums; }
|
||||
.macro-preview progress { width: 100%; height: 8px; border: 0; border-radius: 999px; background: var(--surface-3); accent-color: var(--mint); }
|
||||
.macro-preview progress::-webkit-progress-bar { border-radius: 999px; background: var(--surface-3); }
|
||||
.macro-preview progress::-webkit-progress-value { border-radius: 999px; background: var(--mint); }
|
||||
.macro-preview-buttons { margin-block: 14px; padding: 14px; border: 1px solid var(--line-soft); border-radius: 14px; background: var(--surface-2); }
|
||||
.preview-button { display: inline-flex; flex-direction: column; gap: 3px; min-width: 64px; padding: 7px 9px; color: var(--muted); border: 1px solid var(--line); border-radius: 8px; font-size: 0.7rem; }
|
||||
.preview-button small { font-size: 0.58rem; font-weight: 600; }
|
||||
.preview-button.pressed { color: #080e1c; border-color: var(--mint); background: var(--mint); }
|
||||
.macro-preview [data-mode="override"] { border-color: var(--mint); }
|
||||
.macro-preview [data-mode="override"] [data-preview-mode] { color: var(--mint); }
|
||||
.macro-preview [data-mode="passthrough"] .stick-dot-output { visibility: hidden; }
|
||||
.macro-preview [data-mode="passthrough"] progress { opacity: 0.35; }
|
||||
.macro-preview [data-preview-value] { display: block; margin-top: 9px; color: var(--muted); font-size: 0.7rem; font-variant-numeric: tabular-nums; }
|
||||
.macro-preview-triggers { display: flex; flex-direction: column; justify-content: center; gap: 16px; }
|
||||
.macro-preview-triggers > div { padding: 10px; border: 1px dashed var(--line); border-radius: 9px; }
|
||||
.macro-capture { margin-bottom: 24px; padding: 19px; border: 1px solid rgba(255, 124, 159, 0.35); border-radius: 17px; background: #141323; }
|
||||
.macro-capture[data-state="recording"] { border-color: var(--rose); box-shadow: 0 0 0 1px rgba(255, 124, 159, 0.22); }
|
||||
.macro-capture > .field-help { margin-block: 12px; }
|
||||
.capture-options { min-width: 0; margin-block: 18px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; }
|
||||
.capture-options legend { padding-inline: 6px; color: var(--muted); font-size: 0.73rem; font-weight: 700; }
|
||||
.capture-options:disabled { opacity: 0.65; }
|
||||
.capture-number-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.macro-capture > progress { width: 100%; height: 8px; accent-color: var(--rose); }
|
||||
.capture-notice { min-height: 1.5em; margin-block: 12px; color: var(--muted); font-size: 0.76rem; line-height: 1.55; }
|
||||
.macro-capture:is([data-state="full"], [data-state="disconnected"], [data-state="error"]) .capture-notice { color: var(--amber); }
|
||||
.capture-events { display: grid; gap: 7px; max-height: 260px; margin: 14px 0; padding-left: 28px; overflow: auto; color: var(--muted); font-size: 0.72rem; font-variant-numeric: tabular-nums; }
|
||||
.capture-events:empty { display: none; }
|
||||
.capture-events li { padding: 9px; border: 1px solid var(--line-soft); border-radius: 8px; background: var(--surface-2); overflow-wrap: anywhere; }
|
||||
.capture-events strong { color: var(--ink); }
|
||||
#profileForm [inert] { opacity: 0.55; }
|
||||
.loading-card { display: grid; place-items: center; min-height: 300px; color: var(--muted); border: 1px dashed var(--line); border-radius: 22px; background: var(--surface); }
|
||||
.spinner { width: 30px; height: 30px; margin-bottom: -90px; border: 3px solid var(--surface-3); border-top-color: var(--blue); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 40; max-width: min(430px, calc(100vw - 48px)); padding: 14px 17px; color: var(--ink); border: 1px solid var(--line); border-radius: 13px; background: #172139; box-shadow: var(--shadow); opacity: 0; transform: translateY(12px); pointer-events: none; transition: 180ms ease; }
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.error { border-color: var(--rose); }
|
||||
|
||||
.identity-tools .field-label { margin-top: 18px; }
|
||||
.identity-actions { display: flex; gap: 8px; margin-top: 9px; }
|
||||
.identity-actions .button { flex: 1; padding-inline: 8px; }
|
||||
.controller-details { min-height: 18px; margin-top: 8px; color: var(--subtle); font-size: 0.67rem; }
|
||||
.profile-name-editor { display: grid; grid-template-columns: minmax(140px, 240px) auto; align-items: end; gap: 8px; margin-top: 12px; }
|
||||
.profile-name-editor label { grid-column: 1 / -1; color: var(--muted); font-size: 0.66rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; }
|
||||
.profile-name-editor input { min-height: 40px; padding: 8px 11px; color: var(--ink); border: 1px solid var(--control-line); border-radius: 10px; background: var(--input); }
|
||||
.panel-heading-actions { display: flex; align-items: flex-end; flex-direction: column; gap: 8px; }
|
||||
.panel-heading-actions p { max-width: 440px; color: var(--muted); font-size: 0.78rem; line-height: 1.55; text-align: right; }
|
||||
.section-reset { padding: 0; color: var(--sky); border: 0; background: transparent; font-size: 0.68rem; font-weight: 760; }
|
||||
.section-reset:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.curve-editor { display: grid; grid-template-columns: minmax(120px, 0.8fr) minmax(150px, 1.2fr); gap: 14px; margin-bottom: 16px; padding: 12px; border: 1px solid var(--line-soft); border-radius: 13px; background: #0a1120; }
|
||||
.curve-editor svg { width: 100%; min-height: 110px; overflow: visible; border-left: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.curve-editor path { fill: none; vector-effect: non-scaling-stroke; }
|
||||
.curve-guide { stroke: var(--subtle); stroke-dasharray: 3 3; opacity: 0.45; }
|
||||
.curve-line { stroke: var(--blue); stroke-width: 2.5; }
|
||||
.curve-marker { fill: var(--amber); stroke: #0a1120; stroke-width: 2; opacity: 0; transition: opacity 120ms ease; }
|
||||
.curve-marker.visible { opacity: 1; }
|
||||
.curve-controls { display: flex; flex-direction: column; gap: 10px; }
|
||||
.curve-controls label { color: var(--muted); font-size: 0.67rem; font-weight: 720; }
|
||||
.curve-controls output { float: right; color: var(--ink); }
|
||||
.curve-controls input[type="range"] { display: block; margin-top: 8px; }
|
||||
.copy-side { min-height: 34px; margin-top: auto; padding-block: 6px; }
|
||||
.copy-dialog { width: min(440px, calc(100% - 28px)); padding: 24px; color: var(--ink); border: 1px solid var(--line); border-radius: 18px; background: var(--surface-solid); box-shadow: var(--shadow); }
|
||||
.copy-dialog::backdrop { background: rgba(2, 5, 13, 0.76); }
|
||||
.copy-dialog h3 { margin-bottom: 18px; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 22px; }
|
||||
@media (max-width: 1120px) {
|
||||
.workspace { grid-template-columns: 250px minmax(0, 1fr); }
|
||||
.profile-list { grid-template-columns: 1fr; }
|
||||
.mapping-grid, .mapping-grid.compact { grid-template-columns: repeat(2, minmax(140px, 1fr)); }
|
||||
.controller-mapper { grid-template-columns: 1fr; }
|
||||
.playtest-grid { grid-template-columns: repeat(2, minmax(160px, 1fr)); }
|
||||
.trigger-playtest { grid-column: 1 / -1; }
|
||||
.mapping-inspector { min-height: 270px; }
|
||||
.editor-header { align-items: flex-start; }
|
||||
}
|
||||
@media (max-width: 780px) {
|
||||
.topbar { position: relative; min-height: 76px; }
|
||||
.topbar-meta > p { display: none; }
|
||||
.connection { max-width: 52%; }
|
||||
.workspace { display: block; width: min(100% - 24px, 700px); margin-top: 14px; }
|
||||
.sidebar { position: relative; top: auto; margin-bottom: 18px; max-height: none; overflow: visible; scrollbar-gutter: auto; }
|
||||
.profile-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.profile-button { min-height: 62px; }
|
||||
.profile-number span { font-size: 0.74rem; }
|
||||
.mini-active { margin-left: 0; }
|
||||
.editor-header, .panel-heading { align-items: flex-start; flex-direction: column; }
|
||||
.header-actions { justify-content: flex-start; }
|
||||
.curve-editor { grid-template-columns: 1fr; }
|
||||
.section-nav { top: 8px; }
|
||||
.builtin-actions, .analog-grid, .feedback-grid, .macro-controls { grid-template-columns: 1fr; }
|
||||
.playtest-grid { grid-template-columns: 1fr; }
|
||||
.trigger-playtest { grid-column: auto; }
|
||||
.panel-heading > p, .subpanel-heading span { text-align: left; }
|
||||
.macro-picker { flex-direction: column; }
|
||||
.macro-steps-heading { align-items: stretch; flex-direction: column; }
|
||||
.macro-step-action .button { width: 100%; }
|
||||
.macro-budget { flex-basis: auto; }
|
||||
.step-grid { grid-template-columns: 1fr 1fr; }
|
||||
.macro-preview .playtest-grid { grid-template-columns: 1fr; }
|
||||
.macro-preview-heading, .macro-preview-actions { align-items: stretch; }
|
||||
.macro-preview-actions .button, .step-tool, .remove-step { min-height: 44px; }
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.topbar { padding-inline: 14px; }
|
||||
.brand-mark { width: 38px; height: 38px; }
|
||||
.connection { max-width: 48%; padding: 7px 9px; font-size: 0.68rem; }
|
||||
.workspace { width: min(100% - 16px, 480px); }
|
||||
.sidebar, .panel, .editor-header { border-radius: 17px; }
|
||||
.profile-name-editor { grid-template-columns: 1fr; }
|
||||
.panel-heading-actions { align-items: flex-start; }
|
||||
.panel-heading-actions p { text-align: left; }
|
||||
.sidebar, .panel { padding: 18px; }
|
||||
.trigger-meter { grid-template-columns: 25px minmax(100px, 1fr); }
|
||||
.trigger-meter output { grid-column: 2; text-align: left; }
|
||||
.profile-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.mapping-grid, .mapping-grid.compact, .number-grid, .step-grid { grid-template-columns: 1fr; }
|
||||
.button { flex: 1 1 auto; min-height: 44px; }
|
||||
.header-actions { width: 100%; }
|
||||
.section-nav a { padding-inline: 11px; }
|
||||
.macro-tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.controller-canvas { padding: 12px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
436
src/switch_pico_bridge/web/profile_editor.html
Normal file
436
src/switch_pico_bridge/web/profile_editor.html
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Switch Pico Profile Studio</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient ambient-one"></div>
|
||||
<div class="ambient ambient-two"></div>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark" aria-hidden="true"><span></span><span></span></div>
|
||||
<div>
|
||||
<p class="eyebrow">Switch Pico</p>
|
||||
<h1>Controller Studio</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-meta">
|
||||
<p>Map · tune · automate</p>
|
||||
<div class="connection" id="connectionState" data-state="loading" role="status" aria-live="polite">
|
||||
<span class="connection-dot"></span>
|
||||
<span id="connectionText">Connecting to adapter…</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="workspace">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Profile bank</p>
|
||||
<h2>Your layouts</h2>
|
||||
</div>
|
||||
<button class="icon-button" id="refreshButton" type="button" title="Refresh controllers" aria-label="Refresh controllers">↻</button>
|
||||
</div>
|
||||
|
||||
<label class="field-label" for="identitySelect">Profile owner</label>
|
||||
<select id="identitySelect" class="select"></select>
|
||||
<p class="field-help">The default applies when a controller has no dedicated layout.</p>
|
||||
<div class="identity-tools">
|
||||
<label class="field-label" for="controllerAlias">Controller alias</label>
|
||||
<input class="number-input" id="controllerAlias" maxlength="31" placeholder="Optional nickname">
|
||||
<div class="identity-actions">
|
||||
<button class="button button-secondary" id="saveAliasButton" type="button">Save alias</button>
|
||||
<button class="button button-ghost" id="identifyButton" type="button">Identify</button>
|
||||
</div>
|
||||
<p class="controller-details" id="controllerDetails"></p>
|
||||
</div>
|
||||
|
||||
<div class="profile-list" id="profileList" aria-label="Profiles"></div>
|
||||
|
||||
<div class="sidebar-note">
|
||||
<span class="note-icon">i</span>
|
||||
<p>Each controller has eight independent slots. Tune a draft here, then save it atomically to the Pico.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="editor-shell">
|
||||
<div class="editor-header">
|
||||
<div>
|
||||
<p class="eyebrow" id="profileOwner">Global fallback</p>
|
||||
<div class="title-line">
|
||||
<h2 id="profileTitle">Profile 1</h2>
|
||||
<span class="active-badge" id="activeBadge" hidden>Active</span>
|
||||
<span class="dirty-badge" id="dirtyBadge" hidden>Unsaved</span>
|
||||
</div>
|
||||
<div class="profile-name-editor">
|
||||
<label for="profileName">Profile name</label>
|
||||
<input id="profileName" maxlength="31" placeholder="Optional name">
|
||||
<button class="button button-secondary" id="saveProfileNameButton" type="button">Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="button button-ghost" id="copyProfileButton" type="button">Copy</button>
|
||||
<button class="button button-ghost" id="exportProfileButton" type="button">Export</button>
|
||||
<button class="button button-ghost" id="importProfileButton" type="button">Import</button>
|
||||
<input id="importProfileFile" type="file" accept="application/json,.json" hidden>
|
||||
<button class="button button-ghost" id="resetDraftButton" type="button">Start from defaults</button>
|
||||
<button class="button button-secondary" id="activateButton" type="button">Make active</button>
|
||||
<button class="button button-primary" id="saveButton" type="button">Save to Pico</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="section-nav" aria-label="Editor sections">
|
||||
<a href="#mapping">Mapping</a>
|
||||
<a href="#shortcuts">Shortcuts</a>
|
||||
<a href="#shift">Shift</a>
|
||||
<a href="#analog">Analog</a>
|
||||
<a href="#feedback">Feedback</a>
|
||||
<a href="#turbo">Turbo</a>
|
||||
<a href="#macro">Macro</a>
|
||||
</nav>
|
||||
|
||||
<div class="loading-card" id="loadingCard">
|
||||
<div class="spinner"></div>
|
||||
<p>Reading profile from the adapter…</p>
|
||||
</div>
|
||||
|
||||
<form id="profileForm" hidden>
|
||||
<section class="panel" id="mapping">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">01 · Controls</p>
|
||||
<h3>Button mapping</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions"><p>Choose what each physical button should produce.</p><button class="section-reset" type="button" data-reset-section="mapping">Reset section</button></div>
|
||||
</div>
|
||||
<div class="controller-mapper">
|
||||
<div class="controller-canvas" id="controllerCanvas" data-style="generic">
|
||||
<div class="controller-caption">
|
||||
<span class="controller-live"><i></i> Connected layout</span>
|
||||
<strong id="controllerModel">Generic controller</strong>
|
||||
</div>
|
||||
<div class="controller-photo-wrap">
|
||||
<img
|
||||
class="controller-photo"
|
||||
id="controllerImage"
|
||||
src="/assets/controller-xbox.svg"
|
||||
alt="Connected controller"
|
||||
>
|
||||
<div class="controller-hotspots" id="controllerHotspots">
|
||||
<button type="button" data-controller-button="left_shoulder" aria-label="Left Shoulder">L</button>
|
||||
<button type="button" data-controller-button="right_shoulder" aria-label="Right Shoulder">R</button>
|
||||
<button type="button" data-controller-button="left_trigger" aria-label="Left Trigger">LT</button>
|
||||
<button type="button" data-controller-button="right_trigger" aria-label="Right Trigger">RT</button>
|
||||
<button type="button" data-controller-button="dpad_up" aria-label="D-pad Up">▲</button>
|
||||
<button type="button" data-controller-button="dpad_right" aria-label="D-pad Right">▶</button>
|
||||
<button type="button" data-controller-button="dpad_down" aria-label="D-pad Down">▼</button>
|
||||
<button type="button" data-controller-button="dpad_left" aria-label="D-pad Left">◀</button>
|
||||
<button type="button" data-controller-button="north" aria-label="North button">N</button>
|
||||
<button type="button" data-controller-button="west" aria-label="West button">W</button>
|
||||
<button type="button" data-controller-button="east" aria-label="East button">E</button>
|
||||
<button type="button" data-controller-button="south" aria-label="South button">S</button>
|
||||
<button type="button" data-controller-button="left_stick" aria-label="Left Stick">L3</button>
|
||||
<button type="button" data-controller-button="right_stick" aria-label="Right Stick">R3</button>
|
||||
<button type="button" data-controller-button="select" aria-label="Select">−</button>
|
||||
<button type="button" data-controller-button="start" aria-label="Start">+</button>
|
||||
<button type="button" data-controller-button="capture" aria-label="Capture">▣</button>
|
||||
<button type="button" data-controller-button="system" aria-label="System">⌂</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="controller-hint">Select a highlighted control on the controller to remap it.</p>
|
||||
<p class="controller-credit">
|
||||
Image:
|
||||
<a id="controllerCredit" href="https://github.com/AL2009man/Gamepad-Asset-Pack" target="_blank" rel="noreferrer">Controller artwork by Al. Lopez · MIT</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside class="mapping-inspector">
|
||||
<p class="eyebrow">Selected control</p>
|
||||
<div class="selected-control-mark" id="selectedControlGlyph">S</div>
|
||||
<h4 id="selectedControlName">South button</h4>
|
||||
<p id="selectedControlDescription">Physical South currently produces South.</p>
|
||||
<label class="field-label" for="selectedMapping">Output action</label>
|
||||
<select class="select" id="selectedMapping"></select>
|
||||
<div class="mapping-key">
|
||||
<span><i class="key-dot mapped"></i>Mapped</span>
|
||||
<span><i class="key-dot disabled"></i>Disabled</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="shortcuts" aria-labelledby="shortcutsTitle">
|
||||
<div class="panel-heading">
|
||||
<div><p class="eyebrow">Direct selection</p><h3 id="shortcutsTitle">Profile shortcuts</h3></div>
|
||||
<div class="panel-heading-actions"><p>Hold a modifier and press one face or D-pad button to select a profile directly.</p><button class="section-reset" type="button" data-reset-section="shortcuts">Reset section</button></div>
|
||||
</div>
|
||||
<div class="modifier-controls" id="shortcutModifierFields"></div>
|
||||
<div class="mapping-grid" id="shortcutFields"></div>
|
||||
<p class="field-help">Each selector is unique. Shortcut chords take priority over cycling, motion toggle, Shift and macros. Release before selecting again.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="shift" aria-labelledby="shiftTitle">
|
||||
<div class="panel-heading">
|
||||
<div><p class="eyebrow">Alternate layer</p><h3 id="shiftTitle">Shift button mapping</h3></div>
|
||||
<div class="panel-heading-actions"><p>Hold or toggle one modifier to use these alternate button outputs.</p><button class="section-reset" type="button" data-reset-section="shift">Reset section</button></div>
|
||||
</div>
|
||||
<div class="modifier-controls" id="shiftFields"></div>
|
||||
<div class="mapping-grid" id="shiftMapFields"></div>
|
||||
<p class="field-help">The modifier is consumed while Shift is enabled. Stick and trigger transforms stay on the base profile. Turbo settings and macro triggers remain tied to physical controls.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="analog">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">02 · Response</p>
|
||||
<h3>Sticks & triggers</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions"><p>Values use the controller's normalized integer range.</p><button class="section-reset" type="button" data-reset-section="analog">Reset section</button></div>
|
||||
</div>
|
||||
<div class="playtest-panel" id="playtestPanel" data-state="waiting">
|
||||
<div class="playtest-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Live playtest</p>
|
||||
<h4 id="playtestTitle">Waiting for controller input</h4>
|
||||
</div>
|
||||
<span class="playtest-status" id="playtestStatus">Waiting</span>
|
||||
</div>
|
||||
<p class="playtest-help" id="playtestHelp">
|
||||
Select a connected controller to compare its raw input with this draft.
|
||||
</p>
|
||||
<div class="playtest-grid">
|
||||
<article class="playtest-card">
|
||||
<div class="playtest-card-heading">
|
||||
<strong>Left stick</strong>
|
||||
<span id="playtestLeftValues">0, 0 → 0, 0</span>
|
||||
</div>
|
||||
<div class="stick-scope" data-playtest-stick="left">
|
||||
<span class="stick-ring stick-ring-outer"></span>
|
||||
<span class="stick-ring stick-ring-inner"></span>
|
||||
<span class="stick-axis stick-axis-x"></span>
|
||||
<span class="stick-axis stick-axis-y"></span>
|
||||
<i class="stick-dot stick-dot-raw" title="Raw input"></i>
|
||||
<i class="stick-dot stick-dot-output" title="Draft output"></i>
|
||||
</div>
|
||||
</article>
|
||||
<article class="playtest-card">
|
||||
<div class="playtest-card-heading">
|
||||
<strong>Right stick</strong>
|
||||
<span id="playtestRightValues">0, 0 → 0, 0</span>
|
||||
</div>
|
||||
<div class="stick-scope" data-playtest-stick="right">
|
||||
<span class="stick-ring stick-ring-outer"></span>
|
||||
<span class="stick-ring stick-ring-inner"></span>
|
||||
<span class="stick-axis stick-axis-x"></span>
|
||||
<span class="stick-axis stick-axis-y"></span>
|
||||
<i class="stick-dot stick-dot-raw" title="Raw input"></i>
|
||||
<i class="stick-dot stick-dot-output" title="Draft output"></i>
|
||||
</div>
|
||||
</article>
|
||||
<article class="playtest-card trigger-playtest">
|
||||
<div class="playtest-card-heading">
|
||||
<strong>Triggers</strong>
|
||||
<span>Raw → draft output</span>
|
||||
</div>
|
||||
<div class="trigger-meter" data-playtest-trigger="left">
|
||||
<span id="playtestLeftTriggerLabel">LT</span>
|
||||
<div class="trigger-track">
|
||||
<i class="trigger-bar trigger-bar-raw"></i>
|
||||
<i class="trigger-bar trigger-bar-output"></i>
|
||||
<b class="trigger-threshold" title="Digital threshold"></b>
|
||||
</div>
|
||||
<output>0 → 0</output>
|
||||
</div>
|
||||
<div class="trigger-meter" data-playtest-trigger="right">
|
||||
<span id="playtestRightTriggerLabel">RT</span>
|
||||
<div class="trigger-track">
|
||||
<i class="trigger-bar trigger-bar-raw"></i>
|
||||
<i class="trigger-bar trigger-bar-output"></i>
|
||||
<b class="trigger-threshold" title="Digital threshold"></b>
|
||||
</div>
|
||||
<output>0 → 0</output>
|
||||
</div>
|
||||
<div class="playtest-legend">
|
||||
<span><i class="legend-raw"></i>Raw</span>
|
||||
<span><i class="legend-output"></i>Draft output</span>
|
||||
<span><i class="legend-threshold"></i>Digital threshold</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analog-grid" id="analogFields"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="feedback">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">03 · Identity</p>
|
||||
<h3>Feedback</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions"><p>Scale rumble and choose profile-change confirmation.</p><button class="section-reset" type="button" data-reset-section="feedback">Reset section</button></div>
|
||||
</div>
|
||||
<div class="subpanel" id="rumbleFields"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="turbo">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">04 · Repeat</p>
|
||||
<h3>Turbo behavior</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions"><p>Hold Turbo repeats while pressed; Auto Burst toggles continuous repetition; Burst plays a fixed pulse count per fresh press.</p><button class="section-reset" type="button" data-reset-section="turbo">Reset section</button></div>
|
||||
</div>
|
||||
<div class="subpanel turbo-defaults" id="turboDefaultFields"></div>
|
||||
<p class="timing-notice" id="turboTimingNotice" role="status" aria-live="polite"></p>
|
||||
<div class="turbo-grid" id="turboFields"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="macro">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">05 · Actions</p>
|
||||
<h3>Bindings & custom macro</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions"><button class="section-reset" type="button" data-reset-section="macro">Reset section</button></div>
|
||||
</div>
|
||||
<div class="builtin-actions" id="builtinActions"></div>
|
||||
<div class="macro-controls" id="macroControls"></div>
|
||||
<section class="macro-capture" id="macroCapture" aria-labelledby="macroCaptureTitle" data-state="idle">
|
||||
<div class="macro-preview-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Live input · bounded firmware recorder</p>
|
||||
<h4 id="macroCaptureTitle">Record into macro 1</h4>
|
||||
</div>
|
||||
<div class="macro-preview-actions">
|
||||
<button class="button button-secondary" id="macroCaptureRecord" type="button" disabled>Record input</button>
|
||||
<button class="button button-ghost" id="macroCaptureStop" type="button" disabled>Stop recording</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Records raw input before profile mapping, not controller output. Selected analog channels are explicitly quantized; this is not lossless arbitrary-rate capture. Firmware retains timestamped changes between UI polls, within its input-report precision and the budgets below.</p>
|
||||
<fieldset id="macroCaptureOptions" class="capture-options">
|
||||
<legend>Channels to override in every recorded step</legend>
|
||||
<div class="check-grid">
|
||||
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="1" checked><span>Buttons</span></label>
|
||||
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="2"><span>Left stick</span></label>
|
||||
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="4"><span>Right stick</span></label>
|
||||
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="8"><span>Left trigger</span></label>
|
||||
<label class="checkbox-pill"><input type="checkbox" data-capture-channel="16"><span>Right trigger</span></label>
|
||||
</div>
|
||||
<div class="capture-number-fields">
|
||||
<div class="number-field"><label for="macroCaptureAxis">Stick quantization · raw units</label><input class="number-input" id="macroCaptureAxis" form="macroCaptureSettings" type="number" min="1" max="32767" step="1" value="512" required></div>
|
||||
<div class="number-field"><label for="macroCaptureTrigger">Trigger quantization · raw units</label><input class="number-input" id="macroCaptureTrigger" form="macroCaptureSettings" type="number" min="1" max="65535" step="1" value="1024" required></div>
|
||||
<div class="number-field"><label for="macroCaptureDuration">Time limit · ms</label><input class="number-input" id="macroCaptureDuration" form="macroCaptureSettings" type="number" min="1" max="80000" step="1" value="10000" required></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p class="field-help" id="macroCaptureBudget"></p>
|
||||
<div class="macro-preview-timing">
|
||||
<strong id="macroCaptureState" role="status" aria-live="polite">Ready</strong>
|
||||
<output id="macroCaptureElapsed">0 ms</output>
|
||||
</div>
|
||||
<progress id="macroCaptureProgress" max="1" value="0" aria-label="Recording event capacity"></progress>
|
||||
<p class="capture-notice" id="macroCaptureNotice" role="status" aria-live="polite"></p>
|
||||
<ol id="macroCaptureEvents" class="capture-events" aria-label="Retained recorded states"></ol>
|
||||
<div class="macro-preview-actions">
|
||||
<button class="button button-secondary" id="macroCaptureUse" type="button" disabled>Use recorded steps</button>
|
||||
<button class="button button-ghost" id="macroCaptureDiscard" type="button" disabled>Discard recording</button>
|
||||
<button class="button button-ghost" id="macroCaptureRecover" type="button" hidden>Read retained recording</button>
|
||||
</div>
|
||||
<p class="field-help">Use replaces only this macro’s steps in the unsaved draft. Trigger, cancel and playback settings stay unchanged. Nothing is written to flash until you choose Save to Pico. Stop is requested on hiding or leaving this page; recordings never resume automatically.</p>
|
||||
</section>
|
||||
<section class="macro-preview" id="macroPreview" aria-labelledby="macroPreviewTitle" data-state="stopped">
|
||||
<div class="macro-preview-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Visual only · never sent to the controller</p>
|
||||
<h4 id="macroPreviewTitle">Macro 1 draft preview</h4>
|
||||
</div>
|
||||
<div class="macro-preview-actions">
|
||||
<button class="button button-secondary" id="macroPreviewPlay" type="button">Play preview</button>
|
||||
<button class="button button-ghost" id="macroPreviewStop" type="button" disabled>Stop</button>
|
||||
<button class="button button-ghost" id="macroPreviewRestart" type="button">Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Highlighted fields override input for this step. Passthrough fields keep controller input; their values are not simulated. Zero-duration steps take no preview time.</p>
|
||||
<p class="field-help" id="macroPreviewModeHelp"></p>
|
||||
<div class="macro-preview-timing">
|
||||
<span id="macroPreviewStatus" role="status" aria-live="polite">Ready</span>
|
||||
<output id="macroPreviewTime">0 / 0 ms</output>
|
||||
</div>
|
||||
<progress id="macroPreviewProgress" max="1" value="0" aria-label="Draft preview elapsed time"></progress>
|
||||
<div class="macro-preview-buttons" data-preview-field="buttons" data-mode="passthrough">
|
||||
<div class="playtest-card-heading"><strong>Buttons</strong><span data-preview-mode>Passthrough</span></div>
|
||||
<div class="check-grid" id="macroPreviewButtons"></div>
|
||||
</div>
|
||||
<div class="playtest-grid">
|
||||
<article class="playtest-card" data-preview-field="left_stick" data-mode="passthrough">
|
||||
<div class="playtest-card-heading"><strong>Left stick</strong><span data-preview-mode>Passthrough</span></div>
|
||||
<div class="stick-scope">
|
||||
<span class="stick-axis stick-axis-x"></span><span class="stick-axis stick-axis-y"></span>
|
||||
<i class="stick-dot stick-dot-output"></i>
|
||||
</div>
|
||||
<output data-preview-value>Controller input</output>
|
||||
</article>
|
||||
<article class="playtest-card" data-preview-field="right_stick" data-mode="passthrough">
|
||||
<div class="playtest-card-heading"><strong>Right stick</strong><span data-preview-mode>Passthrough</span></div>
|
||||
<div class="stick-scope">
|
||||
<span class="stick-axis stick-axis-x"></span><span class="stick-axis stick-axis-y"></span>
|
||||
<i class="stick-dot stick-dot-output"></i>
|
||||
</div>
|
||||
<output data-preview-value>Controller input</output>
|
||||
</article>
|
||||
<article class="playtest-card macro-preview-triggers">
|
||||
<div data-preview-field="left_trigger" data-mode="passthrough">
|
||||
<div class="playtest-card-heading"><strong>Left trigger</strong><span data-preview-mode>Passthrough</span></div>
|
||||
<progress max="65535" value="0" aria-label="Preview left trigger"></progress>
|
||||
<output data-preview-value>Controller input</output>
|
||||
</div>
|
||||
<div data-preview-field="right_trigger" data-mode="passthrough">
|
||||
<div class="playtest-card-heading"><strong>Right trigger</strong><span data-preview-mode>Passthrough</span></div>
|
||||
<progress max="65535" value="0" aria-label="Preview right trigger"></progress>
|
||||
<output data-preview-value>Controller input</output>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<div class="macro-steps-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Sequence</p>
|
||||
<h4 id="macroStepsTitle">Macro 1 steps</h4>
|
||||
<p class="field-help" id="macroDuration"></p>
|
||||
</div>
|
||||
<span class="macro-step-action">
|
||||
<button class="button button-secondary" id="addMacroStepButton" type="button">Add state step</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="field-help" id="macroReorderHelp">Insert adds a 100 ms button step before the current step (5 bytes). Drag a step handle, use Move up / Move down, or press Alt + Arrow Up / Down on its handle to reorder.</p>
|
||||
<p class="macro-notice" id="macroNotice" role="status" aria-live="polite"></p>
|
||||
<div class="macro-steps" id="macroSteps" role="list" aria-label="Selected macro steps" aria-describedby="macroReorderHelp"></div>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<form id="macroCaptureSettings" hidden></form>
|
||||
|
||||
<dialog class="copy-dialog" id="copyDialog">
|
||||
<form method="dialog">
|
||||
<p class="eyebrow">Duplicate profile</p>
|
||||
<h3>Choose a destination</h3>
|
||||
<p class="field-help">Copy this current draft, including unsaved settings, to the destination. This replaces its stored profile and name; the source draft is unchanged.</p>
|
||||
<label class="field-label" for="copyIdentity">Controller</label>
|
||||
<select class="select" id="copyIdentity"></select>
|
||||
<label class="field-label" for="copySlot">Profile slot</label>
|
||||
<select class="select" id="copySlot"></select>
|
||||
<div class="dialog-actions">
|
||||
<button class="button button-ghost" value="cancel">Cancel</button>
|
||||
<button class="button button-primary" id="confirmCopyButton" value="default">Copy draft to Pico</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/playtest.js" defer></script>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
2438
src/switch_pico_bridge/web/profile_editor.js
Normal file
2438
src/switch_pico_bridge/web/profile_editor.js
Normal file
File diff suppressed because it is too large
Load diff
112
src/switch_pico_bridge/web/profile_playtest.js
Normal file
112
src/switch_pico_bridge/web/profile_playtest.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"use strict";
|
||||
|
||||
(function publishProfilePlaytestMath(root) {
|
||||
function applyCurveQ16(input, curve) {
|
||||
if (input === 0 || input === 65536 || curve === 256) return input;
|
||||
const denominator = curve * (65536 - input) + 256 * input;
|
||||
return Math.min(
|
||||
65536,
|
||||
Math.floor(
|
||||
(input * 256 * 65536 + Math.floor(denominator / 2)) /
|
||||
denominator
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function transformStickAxis(adjusted, magnitude, response, invert) {
|
||||
if (adjusted === 0 || magnitude === 0 || response === 0) return 0;
|
||||
const negative = (adjusted < 0) !== invert;
|
||||
const limit = negative ? 32768 : 32767;
|
||||
const denominator = magnitude * 65536;
|
||||
const output = Math.min(
|
||||
limit,
|
||||
Math.floor(
|
||||
(
|
||||
Math.abs(adjusted) * limit * response +
|
||||
Math.floor(denominator / 2)
|
||||
) / denominator
|
||||
)
|
||||
);
|
||||
return negative ? -output : output;
|
||||
}
|
||||
|
||||
function transformStick(input, config) {
|
||||
if (
|
||||
config.center_x === 0 && config.center_y === 0 &&
|
||||
config.inner_deadzone === 0 && config.outer_saturation === 32767 &&
|
||||
config.curve_q8_8 === 256 && !config.invert_x && !config.invert_y
|
||||
) {
|
||||
return { x: input.x, y: input.y };
|
||||
}
|
||||
const x = Math.max(-32768, Math.min(32767, input.x - config.center_x));
|
||||
const y = Math.max(-32768, Math.min(32767, input.y - config.center_y));
|
||||
if (config.outer_saturation <= config.inner_deadzone) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
const magnitude = Math.max(Math.abs(x), Math.abs(y));
|
||||
let response = 0;
|
||||
if (magnitude >= config.outer_saturation) {
|
||||
response = 65536;
|
||||
} else if (magnitude > config.inner_deadzone) {
|
||||
const range = config.outer_saturation - config.inner_deadzone;
|
||||
const normalized = Math.floor(
|
||||
(
|
||||
(magnitude - config.inner_deadzone) * 65536 +
|
||||
Math.floor(range / 2)
|
||||
) / range
|
||||
);
|
||||
response = applyCurveQ16(normalized, config.curve_q8_8);
|
||||
}
|
||||
return {
|
||||
x: transformStickAxis(x, magnitude, response, config.invert_x),
|
||||
y: transformStickAxis(y, magnitude, response, config.invert_y),
|
||||
};
|
||||
}
|
||||
|
||||
function transformTrigger(input, config) {
|
||||
if (
|
||||
config.lower_deadzone === 0 &&
|
||||
config.upper_saturation === 65535 &&
|
||||
config.curve_q8_8 === 256
|
||||
) return input;
|
||||
if (config.upper_saturation <= config.lower_deadzone) return 0;
|
||||
if (input <= config.lower_deadzone) return 0;
|
||||
if (input >= config.upper_saturation) return 65535;
|
||||
const range = config.upper_saturation - config.lower_deadzone;
|
||||
const offset = input - config.lower_deadzone;
|
||||
if (config.curve_q8_8 === 256) {
|
||||
return Math.floor(
|
||||
(offset * 65535 + Math.floor(range / 2)) / range
|
||||
);
|
||||
}
|
||||
const normalized = Math.floor(
|
||||
(offset * 65536 + Math.floor(range / 2)) / range
|
||||
);
|
||||
return Math.floor(
|
||||
(applyCurveQ16(normalized, config.curve_q8_8) * 65535 + 32768) /
|
||||
65536
|
||||
);
|
||||
}
|
||||
|
||||
function stickPosition(value) {
|
||||
return Math.max(3, Math.min(97, 50 + value / 32768 * 47));
|
||||
}
|
||||
|
||||
function stickCoordinates(input) {
|
||||
return {
|
||||
left: stickPosition(input.x),
|
||||
top: stickPosition(input.y),
|
||||
};
|
||||
}
|
||||
|
||||
function triggerPercent(value) {
|
||||
return Math.max(0, Math.min(100, value / 65535 * 100));
|
||||
}
|
||||
|
||||
root.ProfilePlaytestMath = Object.freeze({
|
||||
transformStick,
|
||||
transformTrigger,
|
||||
stickCoordinates,
|
||||
triggerPercent,
|
||||
});
|
||||
})(globalThis);
|
||||
|
|
@ -2,9 +2,15 @@
|
|||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#endif
|
||||
|
||||
#include <uni.h>
|
||||
#include "platform/pico/controller_color_config.h"
|
||||
#include "input/switch2_wake.h"
|
||||
|
||||
namespace {
|
||||
|
||||
|
|
@ -44,6 +50,9 @@ bool expect_configuration_timer_prearmed = false;
|
|||
uint32_t expected_configuration_timer_add_count = 0;
|
||||
int cyw43_init_calls = 0;
|
||||
int uni_init_calls = 0;
|
||||
int switch2_wake_initializations = 0;
|
||||
int switch2_wake_requests = 0;
|
||||
bool switch2_connections_ready = true;
|
||||
int device_disconnect_calls = 0;
|
||||
uni_hid_device_t* last_disconnected_device = nullptr;
|
||||
uni_hid_device_t* lookup_devices[8]{};
|
||||
|
|
@ -429,7 +438,9 @@ void cyw43_arch_gpio_put(int, bool enabled) {
|
|||
++observed_status_led_writes;
|
||||
}
|
||||
|
||||
void multicore_launch_core1(void (*)()) {
|
||||
void multicore_launch_core1_with_stack(void (*)(), uint32_t* stack, size_t size) {
|
||||
require(stack != nullptr && size % 8 == 0,
|
||||
"core 1 launch needs an aligned bounded stack");
|
||||
++core1_launch_calls;
|
||||
}
|
||||
|
||||
|
|
@ -441,10 +452,80 @@ uint32_t btstack_run_loop_get_time_ms() {
|
|||
return now_ms;
|
||||
}
|
||||
|
||||
uint32_t time_us_32() {
|
||||
return now_ms * 1000u;
|
||||
}
|
||||
|
||||
|
||||
void switch2_wake_initialize() {
|
||||
++switch2_wake_initializations;
|
||||
}
|
||||
bool switch2_wake_ready_for_connections() {
|
||||
return switch2_connections_ready;
|
||||
}
|
||||
|
||||
|
||||
|
||||
bool switch2_wake_request() {
|
||||
++switch2_wake_requests;
|
||||
return true;
|
||||
}
|
||||
|
||||
void switch2_wake_diagnostics(Switch2WakeDiagnostics*) {
|
||||
}
|
||||
|
||||
#include "core/controller_identity.cpp"
|
||||
#include "input/bluepad32_input_backend.cpp"
|
||||
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
namespace {
|
||||
std::vector<btstack_timer_source_t*> native_timers;
|
||||
std::array<uint8_t, 143> last_native_packet{};
|
||||
uint16_t last_native_cid = 0;
|
||||
}
|
||||
|
||||
void native_test_add_timer(btstack_timer_source_t* timer) {
|
||||
btstack_run_loop_remove_timer(timer);
|
||||
timer->due_ms = uint64_t{now_ms} + timer->timeout_ms + 1;
|
||||
native_timers.push_back(timer);
|
||||
}
|
||||
|
||||
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) {
|
||||
const auto found = std::find(native_timers.begin(), native_timers.end(), timer);
|
||||
if (found == native_timers.end()) return 0;
|
||||
native_timers.erase(found);
|
||||
return 1;
|
||||
}
|
||||
|
||||
uint64_t time_us_64() { return uint64_t{now_ms} * 1000; }
|
||||
uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t) { return 143; }
|
||||
uint8_t l2cap_request_can_send_now_event(uint16_t cid) {
|
||||
for (const auto& slot : g_slots) {
|
||||
if (slot.device != nullptr && slot.device->conn.interrupt_cid == cid) {
|
||||
require(haptics_experiment_on_can_send_now(slot.device, cid),
|
||||
"native send permission was not consumed");
|
||||
return ERROR_CODE_SUCCESS;
|
||||
}
|
||||
}
|
||||
require(false, "native permission targeted a detached connection");
|
||||
return 1;
|
||||
}
|
||||
|
||||
uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) {
|
||||
require(size == last_native_packet.size(), "native report size changed");
|
||||
std::copy(data, data + size, last_native_packet.begin());
|
||||
last_native_cid = cid;
|
||||
return ERROR_CODE_SUCCESS;
|
||||
}
|
||||
|
||||
void haptics_transport_probe_prepare() {}
|
||||
void haptics_transport_probe_begin(uint32_t, uint32_t, uint16_t) {}
|
||||
void haptics_transport_probe_end() {}
|
||||
void haptics_transport_probe_timer(uint32_t) {}
|
||||
void haptics_transport_probe_permission(uint32_t) {}
|
||||
void haptics_transport_probe_send(uint32_t, uint32_t, bool) {}
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
void require_clear_completion_pending() {
|
||||
if (expected_pending_clear_token != 0) {
|
||||
|
|
@ -548,7 +629,8 @@ void start_backend() {
|
|||
kClassicLinkSupervisionTimeout &&
|
||||
!bondable && accepted_stk_methods == 0 &&
|
||||
!ssp_auto_accept && pairing_event_handler != nullptr &&
|
||||
identity_event_handler != nullptr,
|
||||
identity_event_handler != nullptr &&
|
||||
switch2_wake_initializations == 1,
|
||||
"initialization must register Classic and BLE identity policy");
|
||||
}
|
||||
void start_pairing_backend() {
|
||||
|
|
@ -767,6 +849,19 @@ void test_rejections() {
|
|||
"valid device must occupy its indexed slot");
|
||||
require(platform_on_device_ready(&collision) == UNI_ERROR_NO_SLOTS,
|
||||
"different device cannot replace an occupied slot");
|
||||
g_slots[0].identity.stable = true;
|
||||
g_slots[0].identity.transport = ControllerTransport::kClassic;
|
||||
g_slots[0].identity.address[5] = 1;
|
||||
Bluepad32SlotSnapshot identity_snapshot{};
|
||||
bluepad32_input_backend_snapshot(0, &identity_snapshot);
|
||||
require(bluepad32_input_backend_identify(
|
||||
identity_snapshot.identity) &&
|
||||
g_slots[0].pending_profile_feedback_count == 1 &&
|
||||
!bluepad32_input_backend_identify(
|
||||
controller_identity_global()),
|
||||
"Identify did not target only the selected live controller");
|
||||
g_slots[0].pending_profile_feedback_count = 0;
|
||||
g_slots[0].pending_profile_feedback[0] = {};
|
||||
|
||||
uni_controller_t collision_data{};
|
||||
collision_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
|
|
@ -785,6 +880,22 @@ void test_rejections() {
|
|||
require(read_controller_state(0, &snapshot) &&
|
||||
snapshot.motion_sample_count == 3,
|
||||
"valid slot input must remain observable");
|
||||
slot_zero.controller.battery = 201;
|
||||
g_last_snapshot_generation[0] = 0;
|
||||
Bluepad32PlaytestSnapshot playtest{};
|
||||
bluepad32_input_backend_playtest_snapshot(0, &playtest);
|
||||
require(playtest.active && playtest.state_generation != 0 &&
|
||||
playtest.state.motion_sample_count == 3 &&
|
||||
playtest.battery == 201 &&
|
||||
(playtest.capabilities & 0x08u) != 0,
|
||||
"playtest snapshot did not expose input capabilities");
|
||||
bluepad32_input_backend_report_sent(0);
|
||||
require(read_controller_state(0, &snapshot) &&
|
||||
snapshot.motion_sample_count == 3,
|
||||
"playtest snapshot consumed report-path motion");
|
||||
bluepad32_input_backend_playtest_snapshot(4, &playtest);
|
||||
require(!playtest.active && playtest.state_generation == 0,
|
||||
"playtest snapshot accepted slot 4");
|
||||
require(!read_controller_state(4, &snapshot),
|
||||
"public snapshot must reject slot 4");
|
||||
bluepad32_input_backend_report_sent(4);
|
||||
|
|
@ -1970,13 +2081,22 @@ void test_stateful_host_rumble_restore() {
|
|||
#endif
|
||||
}
|
||||
|
||||
void test_motion_hotkey() {
|
||||
void test_motion_toggle_action() {
|
||||
start_pairing_backend();
|
||||
uni_hid_device_t slot_zero = device(0);
|
||||
uni_hid_device_t slot_one = device(1);
|
||||
require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS &&
|
||||
platform_on_device_ready(&slot_one) == UNI_ERROR_SUCCESS,
|
||||
"motion hotkey test controllers did not become ready");
|
||||
"motion action test controllers did not become ready");
|
||||
|
||||
Bluepad32SlotSnapshot backend_snapshot{};
|
||||
bluepad32_input_backend_snapshot(0, &backend_snapshot);
|
||||
require(!bluepad32_input_backend_toggle_motion(
|
||||
0, backend_snapshot.connection_generation + 1u),
|
||||
"stale connection toggled motion");
|
||||
require(bluepad32_input_backend_toggle_motion(
|
||||
0, backend_snapshot.connection_generation),
|
||||
"live connection did not toggle motion");
|
||||
|
||||
uni_controller_t input{};
|
||||
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
|
|
@ -1985,24 +2105,8 @@ void test_motion_hotkey() {
|
|||
ControllerState snapshot{};
|
||||
require(read_controller_state(0, &snapshot) &&
|
||||
snapshot.motion_sample_count ==
|
||||
(kDefaultMotionEnabled ? 3 : 0),
|
||||
"slot 0 did not start with configured motion state");
|
||||
|
||||
input.gamepad.dpad = kMotionHotkeyDpadMask;
|
||||
input.gamepad.buttons = kMotionHotkeyButtonMask;
|
||||
input.gamepad.misc_buttons = kMotionHotkeyMiscMask;
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
Bluepad32SlotSnapshot backend_snapshot{};
|
||||
bluepad32_input_backend_snapshot(0, &backend_snapshot);
|
||||
require(read_controller_state(0, &snapshot) &&
|
||||
backend_snapshot.pre_hotkey_button_mask ==
|
||||
kMotionHotkeyLogicalButtonMask &&
|
||||
snapshot.motion_sample_count ==
|
||||
(kDefaultMotionEnabled ? 0 : 3) &&
|
||||
!snapshot.dpad_up && !snapshot.button_right_shoulder &&
|
||||
!snapshot.button_start,
|
||||
"motion chord was not published pre-hotkey or suppressed "
|
||||
"from normal output");
|
||||
(kDefaultMotionEnabled ? 0 : 3),
|
||||
"motion toggle did not change slot motion publication");
|
||||
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(slot_zero.rumble_calls == 1 &&
|
||||
|
|
@ -2020,12 +2124,6 @@ void test_motion_hotkey() {
|
|||
: kMotionEnabledFeedbackStrongMagnitude),
|
||||
"motion toggle did not send distinct state feedback");
|
||||
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(g_slots[0].motion_enabled == !kDefaultMotionEnabled &&
|
||||
slot_zero.rumble_calls == 1,
|
||||
"held motion chord toggled or rumbled more than once");
|
||||
|
||||
uni_controller_t peer_input{};
|
||||
peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
peer_input.gamepad.accel[0] = 8192;
|
||||
|
|
@ -2033,32 +2131,27 @@ void test_motion_hotkey() {
|
|||
require(read_controller_state(1, &snapshot) &&
|
||||
snapshot.motion_sample_count ==
|
||||
(kDefaultMotionEnabled ? 3 : 0),
|
||||
"slot 0 motion chord changed slot 1 motion state");
|
||||
"slot 0 motion action changed slot 1 motion state");
|
||||
|
||||
input.gamepad = {};
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
input.gamepad.accel[0] = 8192;
|
||||
input.gamepad.dpad = kMotionHotkeyDpadMask;
|
||||
input.gamepad.buttons = kMotionHotkeyButtonMask;
|
||||
input.gamepad.misc_buttons = kMotionHotkeyMiscMask;
|
||||
require(bluepad32_input_backend_toggle_motion(
|
||||
0, backend_snapshot.connection_generation),
|
||||
"second live motion toggle failed");
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
require(read_controller_state(0, &snapshot) &&
|
||||
snapshot.motion_sample_count ==
|
||||
(kDefaultMotionEnabled ? 3 : 0),
|
||||
"released motion chord did not re-arm or restore motion");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(g_slots[0].motion_enabled == kDefaultMotionEnabled &&
|
||||
slot_zero.rumble_calls == 2,
|
||||
"second motion chord did not restore configured state");
|
||||
"second motion toggle did not restore configured state");
|
||||
|
||||
platform_on_device_disconnected(&slot_zero);
|
||||
require(!bluepad32_input_backend_toggle_motion(
|
||||
0, backend_snapshot.connection_generation),
|
||||
"disconnected generation toggled motion");
|
||||
uni_hid_device_t replacement = device(0);
|
||||
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS &&
|
||||
g_slots[0].motion_enabled == kDefaultMotionEnabled &&
|
||||
!g_slots[0].motion_hotkey_latched &&
|
||||
g_slots[0].pre_hotkey_button_mask == 0 &&
|
||||
!g_slots[0].feedback_pending,
|
||||
"disconnect did not reset slot 0 motion hotkey state");
|
||||
"disconnect did not reset slot 0 motion action state");
|
||||
}
|
||||
|
||||
void test_protocol_neutral_analog_state() {
|
||||
|
|
@ -2154,6 +2247,16 @@ void test_protocol_neutral_analog_state() {
|
|||
peer_state.left_trigger == 16399 &&
|
||||
peer_state.right_trigger == 49199,
|
||||
"slot 0 digital trigger fallback changed slot 1");
|
||||
input.gamepad.misc_buttons = MISC_BUTTON_CAPTURE;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(read_controller_state(0, &state) &&
|
||||
state.button_capture,
|
||||
"touchpad/capture input did not reach the logical capture button");
|
||||
input.gamepad.misc_buttons = 0;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(read_controller_state(0, &state) &&
|
||||
!state.button_capture,
|
||||
"released touchpad/capture input remained pressed");
|
||||
}
|
||||
|
||||
void test_host_rumble_mode_duration() {
|
||||
|
|
@ -2352,6 +2455,71 @@ void test_flash_core_start_contract() {
|
|||
"backend start must remain idempotent");
|
||||
}
|
||||
|
||||
|
||||
|
||||
void test_wake_identity_gates_connections() {
|
||||
switch2_connections_ready = false;
|
||||
bluepad32_input_backend_init();
|
||||
platform_on_init_complete();
|
||||
require(switch2_wake_initializations == 1 &&
|
||||
g_connection_policy_state ==
|
||||
ConnectionPolicyState::Uninitialized &&
|
||||
scan_starts == 0 && classic_scan_starts == 0 &&
|
||||
!incoming_connections,
|
||||
"controller discovery started before wake identity was ready");
|
||||
|
||||
switch2_connections_ready = true;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(g_connection_policy_state == ConnectionPolicyState::Open &&
|
||||
scan_starts == 1 && classic_scan_starts == 1 &&
|
||||
incoming_connections,
|
||||
"controller discovery did not start after wake identity setup");
|
||||
}
|
||||
|
||||
|
||||
void test_system_button_wake_trigger() {
|
||||
start_pairing_backend();
|
||||
uni_hid_device_t controller = device(0);
|
||||
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
|
||||
"wake trigger controller did not become ready");
|
||||
|
||||
uni_controller_t input{};
|
||||
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 0,
|
||||
"neutral input requested a wake burst");
|
||||
|
||||
input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 0,
|
||||
"plain system button requested a wake burst");
|
||||
|
||||
input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 1,
|
||||
"held L+R+System chord did not produce exactly one wake request");
|
||||
|
||||
input.gamepad.buttons = 0;
|
||||
input.gamepad.misc_buttons = 0;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 1,
|
||||
"L+R without System requested wake");
|
||||
input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 2,
|
||||
"second L+R+System chord edge did not request wake");
|
||||
|
||||
input.gamepad.buttons = 0;
|
||||
input.gamepad.misc_buttons = MISC_BUTTON_CAPTURE;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 2,
|
||||
"non-system misc button requested wake");
|
||||
}
|
||||
|
||||
|
||||
void test_flash_core_init_fatal() {
|
||||
bluepad32_input_backend_init();
|
||||
flash_core_init_result = false;
|
||||
|
|
@ -2368,11 +2536,220 @@ void test_flash_core_init_fatal() {
|
|||
"flash-safe Core1 init failure must halt before CYW43 init");
|
||||
}
|
||||
|
||||
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) && defined(SWITCH_PICO_USB_OUTPUT_MODES)
|
||||
void advance_native_backend(uint32_t duration_ms) {
|
||||
const uint32_t end_ms = now_ms + duration_ms;
|
||||
while (now_ms < end_ms) {
|
||||
++now_ms;
|
||||
for (;;) {
|
||||
const auto due = std::find_if(
|
||||
native_timers.begin(), native_timers.end(),
|
||||
[](const auto* timer) {
|
||||
return timer != &g_rumble_timer &&
|
||||
timer != &g_configuration_timer &&
|
||||
timer->due_ms <= now_ms;
|
||||
});
|
||||
if (due == native_timers.end()) break;
|
||||
auto* timer = *due;
|
||||
native_timers.erase(due);
|
||||
timer->handler(timer);
|
||||
}
|
||||
if (now_ms % kRumblePollIntervalMs == 0)
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
}
|
||||
}
|
||||
|
||||
void require_native_channels(bool left, bool right) {
|
||||
HapticsExperimentDiagnostics status;
|
||||
haptics_experiment_snapshot(&status);
|
||||
require(status.state == HapticsExperimentState::kRunning &&
|
||||
status.mode == 1,
|
||||
"stateful host rumble lost native gameplay ownership");
|
||||
unsigned active[2]{};
|
||||
for (unsigned frame = 0; frame < status.packet_frames; ++frame) {
|
||||
active[0] += last_native_packet[10 + frame * 2] != 0;
|
||||
active[1] += last_native_packet[11 + frame * 2] != 0;
|
||||
}
|
||||
require((left ? active[0] > status.packet_frames / 2u : active[0] == 0) &&
|
||||
(right ? active[1] > status.packet_frames / 2u : active[1] == 0),
|
||||
"native PCM did not preserve the requested stateful channels");
|
||||
}
|
||||
|
||||
void test_native_stateful_routing() {
|
||||
start_pairing_backend();
|
||||
test_adapter_mode = AdapterUsbMode::kXInput;
|
||||
auto selected = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
selected.vendor_id = 0x054c;
|
||||
selected.product_id = 0x0ce6;
|
||||
selected.conn.connected = true;
|
||||
selected.conn.interrupt_cid = 0x80;
|
||||
selected.outgoing_buffer.queued = 1;
|
||||
require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS,
|
||||
"selected DualSense was rejected");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
HapticsExperimentDiagnostics status;
|
||||
haptics_experiment_snapshot(&status);
|
||||
require(status.mode == 1 && status.state == HapticsExperimentState::kPending,
|
||||
"XInput DualSense did not auto-arm into native Prepare");
|
||||
const uint32_t generation = g_slots[0].connection_generation;
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{180, 0});
|
||||
advance_native_backend(70);
|
||||
const int prepare_calls = selected.rumble_calls;
|
||||
selected.outgoing_buffer.queued = 0;
|
||||
advance_native_backend(350);
|
||||
require_native_channels(true, false);
|
||||
require(selected.rumble_calls == prepare_calls &&
|
||||
last_native_cid == selected.conn.interrupt_cid,
|
||||
"XInput interleaved compatibility rumble into native PCM");
|
||||
|
||||
auto unselected = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
unselected.vendor_id = selected.vendor_id;
|
||||
unselected.product_id = selected.product_id;
|
||||
unselected.conn.connected = true;
|
||||
unselected.conn.interrupt_cid = 0x82;
|
||||
require(platform_on_device_ready(&unselected) == UNI_ERROR_SUCCESS,
|
||||
"unselected DualSense was rejected");
|
||||
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{22, 33});
|
||||
advance_native_backend(10);
|
||||
require(unselected.rumble_calls == 1 && unselected.last_low == 22 &&
|
||||
unselected.last_high == 33,
|
||||
"unselected controller lost compatibility fallback");
|
||||
require(!haptics_experiment_submit_rumble(1, generation, time_us_64(), 255, 255),
|
||||
"native stream accepted an unselected slot");
|
||||
|
||||
bluepad32_input_backend_queue_profile_feedback(
|
||||
0, generation, 1, ControllerProfileConfirmationPolicy::kRumble);
|
||||
advance_native_backend(40);
|
||||
require_native_channels(true, true);
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 170});
|
||||
advance_native_backend(220);
|
||||
require_native_channels(false, true);
|
||||
require(selected.rumble_calls == prepare_calls,
|
||||
"profile feedback escaped the native overlay");
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 0});
|
||||
advance_native_backend(80);
|
||||
require_native_channels(false, false);
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{150, 0});
|
||||
advance_native_backend(80);
|
||||
require(haptics_experiment_request(0, 0), "native stop was rejected");
|
||||
advance_native_backend(20);
|
||||
require(!haptics_experiment_owns(&selected) && selected.last_low == 150 &&
|
||||
selected.last_high == 0 &&
|
||||
selected.last_rumble_duration_ms == kXInputHostRumbleDurationMs,
|
||||
"explicit native stop lost the latest compatibility fallback");
|
||||
require(haptics_experiment_request(2, 0), "manual gameplay rearm failed");
|
||||
advance_native_backend(300);
|
||||
require_native_channels(true, false);
|
||||
const int rearm_calls = selected.rumble_calls;
|
||||
advance_native_backend(300);
|
||||
require(selected.rumble_calls == rearm_calls,
|
||||
"held XInput state required periodic compatibility refresh");
|
||||
|
||||
require(haptics_experiment_request(0, 0), "second native stop failed");
|
||||
advance_native_backend(20);
|
||||
require(selected.last_low == 150 && selected.last_high == 0 &&
|
||||
selected.last_rumble_duration_ms == kXInputHostRumbleDurationMs,
|
||||
"manual rearm discarded held state needed by the next Stop");
|
||||
require(haptics_experiment_request(1, 0), "fixture start failed");
|
||||
advance_native_backend(10);
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 160});
|
||||
advance_native_backend(6200);
|
||||
require(haptics_experiment_request(2, 0), "post-fixture gameplay arm failed");
|
||||
advance_native_backend(300);
|
||||
require_native_channels(false, true);
|
||||
|
||||
// A real HD frame replaces stateful XInput semantics and keeps its watchdog.
|
||||
test_adapter_mode = AdapterUsbMode::kSwitchProbe;
|
||||
ControllerRumbleOutput hd{};
|
||||
hd.hd.actuators[0].sample_count = 1;
|
||||
hd.hd.actuators[1].sample_count = 1;
|
||||
hd.hd.actuators[0].samples[0].low_amplitude_q15 = 20000;
|
||||
hd.hd.actuators[0].samples[0].low_frequency_index = 64;
|
||||
bluepad32_input_backend_queue_rumble(0, hd);
|
||||
advance_native_backend(40);
|
||||
require_native_channels(true, false);
|
||||
advance_native_backend(100);
|
||||
require_native_channels(false, false);
|
||||
|
||||
test_adapter_mode = AdapterUsbMode::kXInput;
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{255, 0});
|
||||
platform_on_device_disconnected(&selected);
|
||||
require(!haptics_experiment_submit_rumble(0, generation, time_us_64(), 255, 255),
|
||||
"disconnected generation remained eligible for native output");
|
||||
require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS,
|
||||
"replacement DualSense was rejected");
|
||||
advance_native_backend(300);
|
||||
require_native_channels(false, false);
|
||||
require(!haptics_experiment_submit_rumble(0, generation, time_us_64(), 255, 255),
|
||||
"old generation reached a replacement native stream");
|
||||
require(haptics_experiment_request(0, 0), "replacement stop failed");
|
||||
advance_native_backend(20);
|
||||
platform_on_device_disconnected(&selected);
|
||||
platform_on_device_disconnected(&unselected);
|
||||
}
|
||||
|
||||
void test_native_second_slot_selection() {
|
||||
start_pairing_backend();
|
||||
test_adapter_mode = AdapterUsbMode::kXInput;
|
||||
auto pro = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
pro.vendor_id = 0x057e;
|
||||
pro.product_id = 0x2009;
|
||||
pro.conn.connected = true;
|
||||
pro.conn.interrupt_cid = 0x80;
|
||||
require(platform_on_device_ready(&pro) == UNI_ERROR_SUCCESS,
|
||||
"first-slot Switch Pro was rejected");
|
||||
auto dualsense = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
dualsense.vendor_id = 0x054c;
|
||||
dualsense.product_id = 0x0ce6;
|
||||
dualsense.conn.connected = true;
|
||||
dualsense.conn.interrupt_cid = 0x82;
|
||||
require(platform_on_device_ready(&dualsense) == UNI_ERROR_SUCCESS,
|
||||
"second-slot DualSense was rejected");
|
||||
advance_native_backend(30);
|
||||
HapticsExperimentDiagnostics status;
|
||||
haptics_experiment_snapshot(&status);
|
||||
require(status.state == HapticsExperimentState::kRunning && status.slot == 1,
|
||||
"native auto-arm ignored the first eligible DualSense outside slot zero");
|
||||
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{90, 0});
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{20, 0});
|
||||
advance_native_backend(200);
|
||||
require_native_channels(true, false);
|
||||
require(pro.rumble_calls == 1 && last_native_cid == 0x82,
|
||||
"mixed controller slots lost their independent rumble paths");
|
||||
auto later = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
later.vendor_id = dualsense.vendor_id;
|
||||
later.product_id = dualsense.product_id;
|
||||
later.conn.connected = true;
|
||||
later.conn.interrupt_cid = 0x84;
|
||||
require(platform_on_device_ready(&later) == UNI_ERROR_SUCCESS,
|
||||
"later DualSense was rejected");
|
||||
advance_native_backend(30);
|
||||
haptics_experiment_snapshot(&status);
|
||||
require(status.slot == 1 && last_native_cid == 0x82,
|
||||
"a later DualSense stole the selected native stream");
|
||||
require(haptics_experiment_request(0, 1), "selected stream did not stop");
|
||||
advance_native_backend(20);
|
||||
platform_on_device_disconnected(&dualsense);
|
||||
platform_on_device_disconnected(&pro);
|
||||
platform_on_device_disconnected(&later);
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
require(argc == 2, "scenario argument required");
|
||||
const std::string scenario = argv[1];
|
||||
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) && defined(SWITCH_PICO_USB_OUTPUT_MODES)
|
||||
if (scenario == "native-stateful") {
|
||||
test_native_stateful_routing();
|
||||
return 0;
|
||||
}
|
||||
if (scenario == "native-second-slot") {
|
||||
test_native_second_slot_selection();
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
if (scenario == "ready-forward") {
|
||||
test_ready_order(false);
|
||||
} else if (scenario == "ready-reverse") {
|
||||
|
|
@ -2392,7 +2769,7 @@ int main(int argc, char** argv) {
|
|||
} else if (scenario == "profile-feedback") {
|
||||
test_profile_feedback_scheduler();
|
||||
} else if (scenario == "motion-hotkey") {
|
||||
test_motion_hotkey();
|
||||
test_motion_toggle_action();
|
||||
} else if (scenario == "analog-state") {
|
||||
test_protocol_neutral_analog_state();
|
||||
} else if (scenario == "rumble-mode") {
|
||||
|
|
@ -2403,6 +2780,10 @@ int main(int argc, char** argv) {
|
|||
test_configuration_timer_rearms_before_storage_work();
|
||||
} else if (scenario == "flash-core-start") {
|
||||
test_flash_core_start_contract();
|
||||
} else if (scenario == "wake-identity-gate") {
|
||||
test_wake_identity_gates_connections();
|
||||
} else if (scenario == "system-wake") {
|
||||
test_system_button_wake_trigger();
|
||||
} else if (scenario == "flash-core-failure") {
|
||||
test_flash_core_init_fatal();
|
||||
} else {
|
||||
|
|
|
|||
8
tests/bluepad32_native_stubs/btstack.h
Normal file
8
tests/bluepad32_native_stubs/btstack.h
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#pragma once
|
||||
|
||||
#include "btstack_run_loop.h"
|
||||
#include "uni.h"
|
||||
|
||||
uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid);
|
||||
uint8_t l2cap_request_can_send_now_event(uint16_t cid);
|
||||
uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size);
|
||||
|
|
@ -6,8 +6,14 @@ struct btstack_timer_source_t {
|
|||
void (*handler)(btstack_timer_source_t*);
|
||||
uint32_t timeout_ms;
|
||||
uint32_t add_count;
|
||||
uint64_t due_ms;
|
||||
};
|
||||
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
void native_test_add_timer(btstack_timer_source_t* timer);
|
||||
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer);
|
||||
#endif
|
||||
|
||||
inline void btstack_run_loop_set_timer_handler(
|
||||
btstack_timer_source_t* timer,
|
||||
void (*handler)(btstack_timer_source_t*)) {
|
||||
|
|
@ -21,6 +27,9 @@ inline void btstack_run_loop_set_timer(btstack_timer_source_t* timer,
|
|||
|
||||
inline void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
|
||||
++timer->add_count;
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
native_test_add_timer(timer);
|
||||
#endif
|
||||
}
|
||||
uint32_t btstack_run_loop_get_time_ms();
|
||||
inline void btstack_run_loop_execute() {}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
#pragma once
|
||||
|
||||
void multicore_launch_core1(void (*entry)());
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
void multicore_launch_core1_with_stack(void (*entry)(), uint32_t* stack, size_t size);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
#pragma once
|
||||
#include <stdint.h>
|
||||
uint32_t time_us_32();
|
||||
uint64_t time_us_64();
|
||||
|
||||
void tight_loop_contents();
|
||||
|
|
|
|||
|
|
@ -98,9 +98,11 @@ struct uni_gamepad_t {
|
|||
struct uni_controller_t {
|
||||
int klass;
|
||||
uni_gamepad_t gamepad;
|
||||
uint8_t battery;
|
||||
};
|
||||
|
||||
struct uni_hid_device_t;
|
||||
struct uni_hid_device_s;
|
||||
typedef struct uni_hid_device_s uni_hid_device_t;
|
||||
typedef void (*btstack_packet_handler_t)(uint8_t, uint16_t, uint8_t*,
|
||||
uint16_t);
|
||||
struct btstack_packet_callback_registration_t {
|
||||
|
|
@ -130,14 +132,25 @@ struct uni_bt_conn_t {
|
|||
bd_addr_t btaddr;
|
||||
hci_con_handle_t handle;
|
||||
uni_bt_conn_protocol_t protocol;
|
||||
bool connected;
|
||||
uint16_t interrupt_cid;
|
||||
uint16_t control_cid;
|
||||
};
|
||||
|
||||
struct uni_hid_device_t {
|
||||
struct uni_circular_buffer_t {
|
||||
unsigned queued;
|
||||
};
|
||||
inline uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer) {
|
||||
return buffer->queued == 0;
|
||||
}
|
||||
|
||||
struct uni_hid_device_s {
|
||||
uint16_t vendor_id;
|
||||
uint16_t product_id;
|
||||
uni_bt_conn_t conn;
|
||||
int idx;
|
||||
bool gamepad;
|
||||
uni_controller_t controller;
|
||||
uni_report_parser_t report_parser;
|
||||
int rumble_calls;
|
||||
uint8_t last_high;
|
||||
|
|
@ -149,6 +162,7 @@ struct uni_hid_device_t {
|
|||
uint8_t lightbar_blue;
|
||||
int player_led_calls;
|
||||
uint8_t player_leds;
|
||||
uni_circular_buffer_t outgoing_buffer;
|
||||
};
|
||||
|
||||
struct uni_platform {
|
||||
|
|
|
|||
14
tests/btstack_credit_batch_native_stubs/btstack_config.h
Normal file
14
tests/btstack_credit_batch_native_stubs/btstack_config.h
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#ifndef SWITCH_PICO_CREDIT_BATCH_TEST_CONFIG_H
|
||||
#define SWITCH_PICO_CREDIT_BATCH_TEST_CONFIG_H
|
||||
|
||||
#define ENABLE_CLASSIC
|
||||
#define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL
|
||||
#define ENABLE_BTSTACK_ASSERT
|
||||
#define HAVE_MALLOC
|
||||
#define HCI_ACL_PAYLOAD_SIZE 1021
|
||||
#define HCI_HOST_ACL_PACKET_LEN 1021
|
||||
#define HCI_HOST_ACL_PACKET_NUM 3
|
||||
#define HCI_HOST_SCO_PACKET_LEN 64
|
||||
#define HCI_HOST_SCO_PACKET_NUM 3
|
||||
|
||||
#endif
|
||||
338
tests/btstack_credit_batch_test.c
Normal file
338
tests/btstack_credit_batch_test.c
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
// Compile the real patched SDK implementation, not a model of its credit logic.
|
||||
#include "hci.c"
|
||||
|
||||
static uint32_t clock_ms;
|
||||
static void (*receive_packet)(uint8_t, uint8_t *, uint16_t);
|
||||
static bool transport_ready;
|
||||
static unsigned acl_delivered;
|
||||
static unsigned sco_delivered;
|
||||
static unsigned wire_count;
|
||||
static uint8_t wire[32][64];
|
||||
static int wire_size[32];
|
||||
static void (*during_send)(void);
|
||||
|
||||
noreturn void btstack_assert_failed(const char *file, uint16_t line){
|
||||
fprintf(stderr, "BTstack assertion: %s:%u\n", file, line);
|
||||
abort();
|
||||
}
|
||||
|
||||
static void set_timer(btstack_timer_source_t *timer, uint32_t timeout_ms){
|
||||
// Match pico_btstack's millisecond quantization, including its extra tick.
|
||||
timer->timeout = clock_ms + timeout_ms + 1;
|
||||
}
|
||||
|
||||
static uint32_t get_time_ms(void){
|
||||
return clock_ms;
|
||||
}
|
||||
|
||||
static const btstack_run_loop_t run_loop = {
|
||||
.init = btstack_run_loop_base_init,
|
||||
.set_timer = set_timer,
|
||||
.add_timer = btstack_run_loop_base_add_timer,
|
||||
.remove_timer = btstack_run_loop_base_remove_timer,
|
||||
.get_time_ms = get_time_ms,
|
||||
};
|
||||
|
||||
static void advance(uint32_t ms){
|
||||
clock_ms += ms;
|
||||
btstack_run_loop_base_process_timers(clock_ms);
|
||||
}
|
||||
|
||||
static void register_receiver(void (*handler)(uint8_t, uint8_t *, uint16_t)){
|
||||
receive_packet = handler;
|
||||
}
|
||||
|
||||
static int transport_open(void){
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int transport_close(void){
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int can_send(uint8_t packet_type){
|
||||
(void) packet_type;
|
||||
return transport_ready;
|
||||
}
|
||||
|
||||
static int send_packet(uint8_t type, uint8_t *packet, int size){
|
||||
assert(type == HCI_COMMAND_DATA_PACKET);
|
||||
assert(wire_count < 32 && size <= 64);
|
||||
memcpy(wire[wire_count], packet, (size_t) size);
|
||||
wire_size[wire_count++] = size;
|
||||
if (during_send != NULL){
|
||||
void (*callback)(void) = during_send;
|
||||
during_send = NULL;
|
||||
callback();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static hci_transport_t transport = {
|
||||
.name = "credit-regression",
|
||||
.open = transport_open,
|
||||
.close = transport_close,
|
||||
.register_packet_handler = register_receiver,
|
||||
.send_packet = send_packet,
|
||||
};
|
||||
|
||||
static void on_acl(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size){
|
||||
(void) channel;
|
||||
assert(type == HCI_ACL_DATA_PACKET && size == 9 && packet[8] == 0x5a);
|
||||
acl_delivered++;
|
||||
}
|
||||
|
||||
static void on_sco(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size){
|
||||
(void) channel;
|
||||
assert(type == HCI_SCO_DATA_PACKET && size == 4 && packet[3] == 0x5a);
|
||||
sco_delivered++;
|
||||
}
|
||||
|
||||
static void working(void){
|
||||
// Fixture bypasses controller initialization, leaving the production receive/run paths intact.
|
||||
hci_stack->state = HCI_STATE_WORKING;
|
||||
hci_stack->gap_tasks_classic = 0;
|
||||
hci_stack->num_cmd_packets = 1;
|
||||
hci_register_acl_packet_handler(on_acl);
|
||||
hci_register_sco_packet_handler(on_sco);
|
||||
}
|
||||
|
||||
static void begin(uint32_t now, bool asynchronous){
|
||||
clock_ms = now;
|
||||
transport_ready = true;
|
||||
transport.can_send_packet_now = asynchronous ? can_send : NULL;
|
||||
during_send = NULL;
|
||||
wire_count = acl_delivered = sco_delivered = 0;
|
||||
btstack_run_loop_init(&run_loop);
|
||||
btstack_memory_init();
|
||||
hci_init(&transport, NULL);
|
||||
working();
|
||||
}
|
||||
|
||||
static void finish(void){
|
||||
if (hci_stack != NULL){
|
||||
// Isolate close's timer cancellation from the unrelated asynchronous shutdown FSM.
|
||||
hci_stack->state = HCI_STATE_OFF;
|
||||
hci_close();
|
||||
}
|
||||
advance(10);
|
||||
btstack_memory_deinit();
|
||||
btstack_run_loop_deinit();
|
||||
}
|
||||
|
||||
static void add_connection(uint16_t handle, bd_addr_type_t type){
|
||||
hci_connection_t *connection = btstack_memory_hci_connection_get();
|
||||
assert(connection != NULL);
|
||||
connection->con_handle = handle;
|
||||
connection->address_type = type;
|
||||
hci_connection_init(connection);
|
||||
connection->state = OPEN;
|
||||
btstack_linked_list_add_tail(&hci_stack->connections, (btstack_linked_item_t *) connection);
|
||||
}
|
||||
|
||||
static void acl(uint16_t handle){
|
||||
uint8_t packet[] = {0, 0x20, 5, 0, 1, 0, 0x40, 0, 0x5a};
|
||||
little_endian_store_16(packet, 0, handle | 0x2000);
|
||||
receive_packet(HCI_ACL_DATA_PACKET, packet, sizeof(packet));
|
||||
}
|
||||
|
||||
static void disconnected(uint16_t handle){
|
||||
uint8_t packet[] = {HCI_EVENT_DISCONNECTION_COMPLETE, 4, 0, 0, 0, 0x13};
|
||||
little_endian_store_16(packet, 3, handle);
|
||||
receive_packet(HCI_EVENT_PACKET, packet, sizeof(packet));
|
||||
}
|
||||
|
||||
static void expect_credits(unsigned index, uint16_t handle, uint16_t count){
|
||||
const uint8_t *packet = wire[index];
|
||||
assert(wire_size[index] == 8);
|
||||
assert(packet[0] == 0x35 && packet[1] == 0x0c && packet[2] == 5 && packet[3] == 1);
|
||||
assert(little_endian_read_16(packet, 4) == handle);
|
||||
assert(little_endian_read_16(packet, 6) == count);
|
||||
}
|
||||
|
||||
#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
static void test_batch_and_ordinary_command(void){
|
||||
begin(100, false);
|
||||
add_connection(0x41, BD_ADDR_TYPE_ACL);
|
||||
acl(0x41);
|
||||
assert(acl_delivered == 1 && wire_count == 0);
|
||||
gap_set_class_of_device(0x010203);
|
||||
assert(wire_count == 1 && little_endian_read_16(wire[0], 0) == HCI_OPCODE_HCI_WRITE_CLASS_OF_DEVICE);
|
||||
// Completed packets remain sendable with zero command credits.
|
||||
assert(!hci_can_send_command_packet_now());
|
||||
acl(0x41);
|
||||
assert(acl_delivered == 2 && wire_count == 2);
|
||||
expect_credits(1, 0x41, 2);
|
||||
advance(2);
|
||||
assert(wire_count == 2);
|
||||
finish();
|
||||
}
|
||||
|
||||
static void test_deadline_and_retry(void){
|
||||
begin(UINT32_MAX - 1, true);
|
||||
add_connection(0x41, BD_ADDR_TYPE_ACL);
|
||||
acl(0x41);
|
||||
advance(1);
|
||||
assert(wire_count == 0);
|
||||
// Repeated POWER_ON must leave the original deadline intact.
|
||||
hci_power_control(HCI_POWER_ON);
|
||||
transport_ready = false;
|
||||
advance(1);
|
||||
assert(wire_count == 0);
|
||||
transport_ready = true;
|
||||
// No incoming event is needed to retry after a busy transport.
|
||||
advance(2);
|
||||
assert(wire_count == 1);
|
||||
expect_credits(0, 0x41, 1);
|
||||
uint8_t sent[] = {HCI_EVENT_TRANSPORT_PACKET_SENT, 0};
|
||||
receive_packet(HCI_EVENT_PACKET, sent, sizeof(sent));
|
||||
advance(2);
|
||||
assert(wire_count == 1);
|
||||
finish();
|
||||
}
|
||||
|
||||
static void test_multiple_handles_and_disconnect(void){
|
||||
begin(100, false);
|
||||
add_connection(0x41, BD_ADDR_TYPE_ACL);
|
||||
add_connection(0x42, BD_ADDR_TYPE_ACL);
|
||||
acl(0x41);
|
||||
acl(0x42);
|
||||
assert(wire_count == 1 && wire_size[0] == 12);
|
||||
const uint8_t expected[] = {0x35, 0x0c, 9, 2, 0x41, 0, 1, 0, 0x42, 0, 1, 0};
|
||||
assert(memcmp(wire[0], expected, sizeof(expected)) == 0);
|
||||
acl(0x41);
|
||||
advance(1);
|
||||
disconnected(0x41);
|
||||
add_connection(0x41, BD_ADDR_TYPE_ACL);
|
||||
acl(0x41);
|
||||
advance(1);
|
||||
assert(wire_count == 1); // Removed handle's deadline cannot flush its replacement early.
|
||||
advance(1);
|
||||
assert(wire_count == 2);
|
||||
expect_credits(1, 0x41, 1);
|
||||
acl(0x42);
|
||||
disconnected(0x42);
|
||||
advance(2);
|
||||
assert(wire_count == 2); // No empty completion command after the last pending handle disappears.
|
||||
finish();
|
||||
}
|
||||
|
||||
static void test_sco_and_malformed_acl(void){
|
||||
begin(100, false);
|
||||
add_connection(0x41, BD_ADDR_TYPE_ACL);
|
||||
add_connection(0x42, BD_ADDR_TYPE_SCO);
|
||||
uint8_t sco[] = {0x42, 0, 1, 0x5a};
|
||||
receive_packet(HCI_SCO_DATA_PACKET, sco, sizeof(sco));
|
||||
assert(sco_delivered == 1 && wire_count == 1);
|
||||
expect_credits(0, 0x42, 1);
|
||||
// An orphan continuation is counted by HCI before parsing rejects it.
|
||||
uint8_t malformed[] = {0x41, 0x10, 1, 0, 0x5a};
|
||||
receive_packet(HCI_ACL_DATA_PACKET, malformed, sizeof(malformed));
|
||||
receive_packet(HCI_ACL_DATA_PACKET, malformed, sizeof(malformed));
|
||||
assert(acl_delivered == 0 && wire_count == 2);
|
||||
expect_credits(1, 0x41, 2);
|
||||
finish();
|
||||
}
|
||||
|
||||
static void receive_during_send(void){
|
||||
acl(0x41);
|
||||
}
|
||||
|
||||
static void replace_stack_during_send(void){
|
||||
hci_stack->state = HCI_STATE_OFF;
|
||||
hci_close();
|
||||
hci_init(&transport, NULL);
|
||||
working();
|
||||
hci_reserve_packet_buffer();
|
||||
}
|
||||
|
||||
static void test_synchronous_callbacks(void){
|
||||
begin(100, false);
|
||||
add_connection(0x41, BD_ADDR_TYPE_ACL);
|
||||
acl(0x41);
|
||||
during_send = receive_during_send;
|
||||
advance(2);
|
||||
assert(acl_delivered == 2 && wire_count == 1);
|
||||
expect_credits(0, 0x41, 1);
|
||||
advance(2);
|
||||
assert(wire_count == 2);
|
||||
expect_credits(1, 0x41, 1); // Incoming callback's new count/timer survived the previous send.
|
||||
acl(0x41);
|
||||
during_send = replace_stack_during_send;
|
||||
advance(2);
|
||||
assert(wire_count == 3 && !hci_can_send_command_packet_now());
|
||||
// The returning old send must not release the new stack's reserved buffer.
|
||||
hci_release_packet_buffer();
|
||||
advance(2);
|
||||
assert(wire_count == 3);
|
||||
finish();
|
||||
}
|
||||
|
||||
static void test_lifecycle(void){
|
||||
begin(100, false);
|
||||
add_connection(0x41, BD_ADDR_TYPE_ACL);
|
||||
acl(0x41);
|
||||
hci_stack->state = HCI_STATE_OFF;
|
||||
hci_close();
|
||||
advance(2);
|
||||
assert(wire_count == 0);
|
||||
hci_init(&transport, NULL);
|
||||
working();
|
||||
add_connection(0x42, BD_ADDR_TYPE_ACL);
|
||||
acl(0x42);
|
||||
// Re-init cancels before memset; preserve connection allocation for explicit cleanup.
|
||||
hci_connection_t *old_connection = hci_connection_for_handle(0x42);
|
||||
hci_init(&transport, NULL);
|
||||
btstack_memory_hci_connection_free(old_connection);
|
||||
working();
|
||||
advance(2);
|
||||
assert(wire_count == 0);
|
||||
add_connection(0x43, BD_ADDR_TYPE_ACL);
|
||||
acl(0x43);
|
||||
old_connection = hci_connection_for_handle(0x43);
|
||||
hci_deinit();
|
||||
btstack_memory_hci_connection_free(old_connection);
|
||||
advance(2);
|
||||
assert(wire_count == 0);
|
||||
btstack_memory_deinit();
|
||||
btstack_run_loop_deinit();
|
||||
|
||||
begin(100, false);
|
||||
add_connection(0x44, BD_ADDR_TYPE_ACL);
|
||||
acl(0x44);
|
||||
hci_power_control(HCI_POWER_SLEEP);
|
||||
unsigned after_power_transition = wire_count;
|
||||
advance(2);
|
||||
assert(wire_count == after_power_transition);
|
||||
finish();
|
||||
}
|
||||
#endif
|
||||
|
||||
int main(void){
|
||||
#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
|
||||
test_batch_and_ordinary_command();
|
||||
test_deadline_and_retry();
|
||||
test_multiple_handles_and_disconnect();
|
||||
test_sco_and_malformed_acl();
|
||||
test_synchronous_callbacks();
|
||||
test_lifecycle();
|
||||
#else
|
||||
begin(100, false);
|
||||
add_connection(0x41, BD_ADDR_TYPE_ACL);
|
||||
acl(0x41);
|
||||
assert(acl_delivered == 1 && wire_count == 1);
|
||||
expect_credits(0, 0x41, 1);
|
||||
acl(0x41);
|
||||
assert(acl_delivered == 2 && wire_count == 2);
|
||||
expect_credits(1, 0x41, 1);
|
||||
advance(2);
|
||||
assert(wire_count == 2);
|
||||
finish();
|
||||
#endif
|
||||
puts("BTstack credit behavior passed");
|
||||
return 0;
|
||||
}
|
||||
522
tests/controller_macro_capture_test.cpp
Normal file
522
tests/controller_macro_capture_test.cpp
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
#include "input/controller_macro_capture.h"
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
|
||||
namespace {
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
std::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
CaptureEvent captured_event(const ControllerMacroCapture& capture,
|
||||
uint16_t index) {
|
||||
CaptureEvent event{};
|
||||
require(capture.event(index, &event), "recorded event was inaccessible");
|
||||
return event;
|
||||
}
|
||||
|
||||
void require_terminal_frozen(ControllerMacroCapture& capture,
|
||||
CaptureState expected, uint32_t elapsed) {
|
||||
const uint16_t count = capture.event_count();
|
||||
capture.stop(90000000);
|
||||
capture.disconnect(capture.slot(), capture.generation(), 90000001);
|
||||
capture.observe(capture.slot(), capture.generation(), 90000002,
|
||||
controller_neutral_state());
|
||||
capture.tick(90000003);
|
||||
require(capture.state() == expected &&
|
||||
capture.elapsed_us(90000004) == elapsed &&
|
||||
capture.event_count() == count,
|
||||
"a terminal capture changed state, end time or event count");
|
||||
}
|
||||
|
||||
void test_initial_snapshot_and_start_validation() {
|
||||
ControllerMacroCapture capture;
|
||||
CaptureEvent output{};
|
||||
require(capture.state() == CaptureState::kIdle &&
|
||||
capture.elapsed_us(1234) == 0 &&
|
||||
!capture.event(0, &output),
|
||||
"idle capture exposed an event or running clock");
|
||||
capture.stop(10);
|
||||
capture.disconnect(0, 0, 11);
|
||||
capture.tick(12);
|
||||
require(capture.state() == CaptureState::kIdle,
|
||||
"an operation without a run changed idle state");
|
||||
|
||||
CaptureOptions options;
|
||||
options.channels = 31;
|
||||
ControllerState initial = controller_neutral_state();
|
||||
initial.button_south = true;
|
||||
initial.button_left_stick = true;
|
||||
initial.dpad_up = true;
|
||||
initial.left_stick_x = -768;
|
||||
initial.right_stick_y = 768;
|
||||
initial.left_trigger = 1536;
|
||||
initial.right_trigger = UINT16_MAX;
|
||||
require(capture.start(3, 77, options, 100, initial),
|
||||
"valid capture did not start");
|
||||
const uint32_t run = capture.run_id();
|
||||
const CaptureEvent first = captured_event(capture, 0);
|
||||
require(run != 0 && capture.slot() == 3 && capture.generation() == 77 &&
|
||||
capture.event_count() == 1 && first.at_us == 0 &&
|
||||
first.buttons == ((1u << 0) | (1u << 10) | (1u << 12)) &&
|
||||
first.left_x == -1024 && first.right_y == 1024 &&
|
||||
first.left_trigger == 2048 && first.right_trigger == UINT16_MAX,
|
||||
"capture did not retain the quantized initial state at time zero");
|
||||
require(!capture.start(0, 1, options, 200, controller_neutral_state()) &&
|
||||
capture.run_id() == run && capture.elapsed_us(180) == 80 &&
|
||||
captured_event(capture, 0).buttons == first.buttons,
|
||||
"start replaced an active capture");
|
||||
require(!capture.event(1, &output) && !capture.event(0, nullptr),
|
||||
"event accessor accepted an absent event or null output");
|
||||
capture.stop(180);
|
||||
|
||||
const CaptureOptions invalid[] = {
|
||||
{0, 512, 1024, 8, 10000},
|
||||
{32, 512, 1024, 8, 10000},
|
||||
{1, 0, 1024, 8, 10000},
|
||||
{1, 32768, 1024, 8, 10000},
|
||||
{1, 512, 0, 8, 10000},
|
||||
{1, 512, 1024, 0, 10000},
|
||||
{1, 512, 1024, 129, 10000},
|
||||
{1, 512, 1024, 8, 0},
|
||||
{1, 512, 1024, 8, 80001},
|
||||
};
|
||||
for (const CaptureOptions& bad : invalid) {
|
||||
require(!capture.start(0, 1, bad, 200, initial),
|
||||
"invalid capture options were accepted");
|
||||
}
|
||||
require(!capture.start(4, 1, options, 200, initial) &&
|
||||
capture.run_id() == run &&
|
||||
capture.state() == CaptureState::kStopped &&
|
||||
capture.elapsed_us(200) == 80 &&
|
||||
captured_event(capture, 0).buttons == first.buttons,
|
||||
"rejected starts damaged the stopped recording");
|
||||
|
||||
options.axis_quantum = INT16_MAX;
|
||||
options.trigger_quantum = UINT16_MAX;
|
||||
options.max_events = 128;
|
||||
options.max_duration_ms = 80000;
|
||||
require(capture.start(0, 1, options, 300, controller_neutral_state()) &&
|
||||
capture.run_id() == run + 1 && capture.event_count() == 1 &&
|
||||
captured_event(capture, 0).buttons == 0 &&
|
||||
capture.elapsed_us(300) == 0,
|
||||
"a successful restart did not replace the old run at valid bounds");
|
||||
}
|
||||
|
||||
void test_positional_logical_buttons() {
|
||||
constexpr bool ControllerState::* buttons[] = {
|
||||
&ControllerState::button_south,
|
||||
&ControllerState::button_east,
|
||||
&ControllerState::button_west,
|
||||
&ControllerState::button_north,
|
||||
&ControllerState::button_left_shoulder,
|
||||
&ControllerState::button_right_shoulder,
|
||||
&ControllerState::button_select,
|
||||
&ControllerState::button_start,
|
||||
&ControllerState::button_system,
|
||||
&ControllerState::button_capture,
|
||||
&ControllerState::button_left_stick,
|
||||
&ControllerState::button_right_stick,
|
||||
&ControllerState::dpad_up,
|
||||
&ControllerState::dpad_down,
|
||||
&ControllerState::dpad_left,
|
||||
&ControllerState::dpad_right,
|
||||
};
|
||||
ControllerMacroCapture capture;
|
||||
CaptureOptions options;
|
||||
options.max_events = 17;
|
||||
require(capture.start(0, 1, options, 0, controller_neutral_state()),
|
||||
"button capture did not start");
|
||||
for (uint16_t i = 0; i < 16; ++i) {
|
||||
ControllerState state = controller_neutral_state();
|
||||
state.*buttons[i] = true;
|
||||
capture.observe(0, 1, i + 1, state);
|
||||
const CaptureEvent event = captured_event(capture, i + 1);
|
||||
require(event.buttons == (1u << i) && event.at_us == i + 1u,
|
||||
"a positional button mapped to the wrong logical bit");
|
||||
}
|
||||
}
|
||||
|
||||
ControllerState channel_state(uint8_t channels) {
|
||||
ControllerState state = controller_neutral_state();
|
||||
state.button_south = (channels & 1u) != 0;
|
||||
if ((channels & 2u) != 0) {
|
||||
state.left_stick_x = 1279;
|
||||
state.left_stick_y = -1279;
|
||||
}
|
||||
if ((channels & 4u) != 0) {
|
||||
state.right_stick_x = -1791;
|
||||
state.right_stick_y = 1791;
|
||||
}
|
||||
if ((channels & 8u) != 0) {
|
||||
state.left_trigger = 2047;
|
||||
}
|
||||
if ((channels & 16u) != 0) {
|
||||
state.right_trigger = 3071;
|
||||
}
|
||||
state.motion_sample_count = 1;
|
||||
state.motion_samples[0].gyro_x = 1000;
|
||||
return state;
|
||||
}
|
||||
|
||||
void test_selected_channels_ignore_unrelated_changes() {
|
||||
for (uint8_t channel = 1; channel <= 16; channel <<= 1) {
|
||||
ControllerMacroCapture capture;
|
||||
CaptureOptions options;
|
||||
options.channels = channel;
|
||||
require(capture.start(0, 1, options, 10, controller_neutral_state()),
|
||||
"single-channel capture did not start");
|
||||
capture.observe(0, 1, 20, channel_state(31u ^ channel));
|
||||
require(capture.event_count() == 1,
|
||||
"unselected channels or motion produced a capture edge");
|
||||
capture.observe(0, 1, 30, channel_state(31));
|
||||
require(capture.event_count() == 2,
|
||||
"selected channel did not produce exactly one edge");
|
||||
const CaptureEvent event = captured_event(capture, 1);
|
||||
require(event.at_us == 20 &&
|
||||
event.buttons == (channel == 1 ? 1 : 0) &&
|
||||
event.left_x == (channel == 2 ? 1024 : 0) &&
|
||||
event.left_y == (channel == 2 ? -1024 : 0) &&
|
||||
event.right_x == (channel == 4 ? -1536 : 0) &&
|
||||
event.right_y == (channel == 4 ? 1536 : 0) &&
|
||||
event.left_trigger == (channel == 8 ? 2048 : 0) &&
|
||||
event.right_trigger == (channel == 16 ? 3072 : 0),
|
||||
"selected output leaked or omitted a channel");
|
||||
}
|
||||
}
|
||||
|
||||
void test_analog_quantization_symmetry_and_endpoints() {
|
||||
ControllerMacroCapture capture;
|
||||
CaptureOptions options;
|
||||
options.channels = 30;
|
||||
ControllerState state = controller_neutral_state();
|
||||
state.left_stick_x = 255;
|
||||
state.left_stick_y = -255;
|
||||
state.left_trigger = 511;
|
||||
state.right_trigger = 511;
|
||||
require(capture.start(0, 1, options, 100, state),
|
||||
"analog capture did not start");
|
||||
CaptureEvent event = captured_event(capture, 0);
|
||||
require(event.left_x == 0 && event.left_y == 0 &&
|
||||
event.left_trigger == 0 && event.right_trigger == 0,
|
||||
"near-zero inputs did not quantize to rest");
|
||||
|
||||
state.left_stick_x = 256;
|
||||
state.left_stick_y = -256;
|
||||
state.right_stick_x = -768;
|
||||
state.right_stick_y = 768;
|
||||
state.left_trigger = 512;
|
||||
state.right_trigger = 1536;
|
||||
capture.observe(0, 1, 101, state);
|
||||
event = captured_event(capture, 1);
|
||||
require(event.left_x == 512 && event.left_y == -512 &&
|
||||
event.right_x == -1024 && event.right_y == 1024 &&
|
||||
event.left_trigger == 1024 && event.right_trigger == 2048,
|
||||
"half-quantum ties did not round symmetrically away from zero");
|
||||
state.left_stick_x = 767;
|
||||
state.left_stick_y = -767;
|
||||
state.right_stick_x = -1279;
|
||||
state.right_stick_y = 1279;
|
||||
state.left_trigger = 1535;
|
||||
state.right_trigger = 2559;
|
||||
capture.observe(0, 1, 102, state);
|
||||
require(capture.event_count() == 2,
|
||||
"jitter within a quantized bucket produced another edge");
|
||||
|
||||
state.left_stick_x = INT16_MIN;
|
||||
state.left_stick_y = INT16_MAX;
|
||||
state.right_stick_x = -INT16_MAX;
|
||||
state.right_stick_y = INT16_MAX - 1;
|
||||
state.left_trigger = 0;
|
||||
state.right_trigger = UINT16_MAX;
|
||||
capture.observe(0, 1, 103, state);
|
||||
event = captured_event(capture, 2);
|
||||
require(event.left_x == INT16_MIN && event.left_y == INT16_MAX &&
|
||||
event.right_x == -INT16_MAX && event.right_y == INT16_MAX &&
|
||||
event.left_trigger == 0 && event.right_trigger == UINT16_MAX,
|
||||
"quantization wrapped, lost full scale or broke signed symmetry");
|
||||
capture.observe(0, 1, 104, controller_neutral_state());
|
||||
event = captured_event(capture, 3);
|
||||
require(event.left_x == 0 && event.left_y == 0 && event.right_x == 0 &&
|
||||
event.right_y == 0 && event.left_trigger == 0 &&
|
||||
event.right_trigger == 0,
|
||||
"returning to exact rest retained a quantized analog value");
|
||||
capture.stop(105);
|
||||
|
||||
options.axis_quantum = 3;
|
||||
options.trigger_quantum = 3;
|
||||
state.left_stick_x = 1;
|
||||
state.left_stick_y = -1;
|
||||
state.right_stick_x = 2;
|
||||
state.right_stick_y = -2;
|
||||
state.left_trigger = 1;
|
||||
state.right_trigger = 2;
|
||||
require(capture.start(0, 2, options, 200, state),
|
||||
"odd-quantum capture did not start");
|
||||
event = captured_event(capture, 0);
|
||||
require(event.left_x == 0 && event.left_y == 0 && event.right_x == 3 &&
|
||||
event.right_y == -3 && event.left_trigger == 0 &&
|
||||
event.right_trigger == 3,
|
||||
"odd quanta shifted the signed or unsigned rounding threshold");
|
||||
}
|
||||
|
||||
void test_same_timestamp_coalesces_before_budget_check() {
|
||||
ControllerMacroCapture capture;
|
||||
CaptureOptions options;
|
||||
options.max_events = 2;
|
||||
ControllerState state = controller_neutral_state();
|
||||
require(capture.start(0, 1, options, 10, state),
|
||||
"coalescing capture did not start");
|
||||
state.button_south = true;
|
||||
capture.observe(0, 1, 10, state);
|
||||
state.button_south = false;
|
||||
capture.observe(0, 1, 10, state);
|
||||
require(capture.event_count() == 1 &&
|
||||
captured_event(capture, 0).buttons == 0,
|
||||
"same-time initial changes made zero-length intermediate events");
|
||||
state.button_south = true;
|
||||
capture.observe(0, 1, 20, state);
|
||||
state.button_south = false;
|
||||
state.button_west = true;
|
||||
capture.observe(0, 1, 20, state);
|
||||
capture.observe(0, 1, 21, state);
|
||||
require(capture.state() == CaptureState::kRecording &&
|
||||
capture.event_count() == 2 &&
|
||||
captured_event(capture, 1).buttons == (1u << 2),
|
||||
"a full event budget rejected coalescing or an unchanged sample");
|
||||
state.button_west = false;
|
||||
state.button_north = true;
|
||||
capture.observe(0, 1, 22, state);
|
||||
require(capture.state() == CaptureState::kFull &&
|
||||
capture.elapsed_us(30) == 12 && capture.event_count() == 2 &&
|
||||
captured_event(capture, 1).at_us == 10 &&
|
||||
captured_event(capture, 1).buttons == (1u << 2),
|
||||
"overflow did not retain the last complete recorded state");
|
||||
require_terminal_frozen(capture, CaptureState::kFull, 12);
|
||||
}
|
||||
|
||||
void test_full_static_capacity() {
|
||||
ControllerMacroCapture capture;
|
||||
CaptureOptions options;
|
||||
options.max_events = 128;
|
||||
ControllerState state = controller_neutral_state();
|
||||
require(capture.start(0, 1, options, 0, state),
|
||||
"maximum-capacity capture did not start");
|
||||
for (uint16_t i = 1; i < 128; ++i) {
|
||||
state.button_south = (i & 1u) != 0;
|
||||
capture.observe(0, 1, i, state);
|
||||
}
|
||||
require(capture.event_count() == 128 &&
|
||||
capture.state() == CaptureState::kRecording,
|
||||
"capture stopped before an edge exceeded its full capacity");
|
||||
state.button_south = false;
|
||||
capture.observe(0, 1, 128, state);
|
||||
require(capture.state() == CaptureState::kFull &&
|
||||
capture.event_count() == 128 && capture.elapsed_us(129) == 128 &&
|
||||
captured_event(capture, 127).at_us == 127 &&
|
||||
captured_event(capture, 127).buttons == 1,
|
||||
"capacity overflow overwrote retained events or lost its end time");
|
||||
}
|
||||
|
||||
void test_clock_wrap_and_stopped_end() {
|
||||
ControllerMacroCapture capture;
|
||||
CaptureOptions options;
|
||||
ControllerState state = controller_neutral_state();
|
||||
constexpr uint32_t started = UINT32_MAX - 100u;
|
||||
require(capture.start(0, 1, options, started, state),
|
||||
"wrap capture did not start");
|
||||
state.button_east = true;
|
||||
capture.observe(0, 1, 49, state);
|
||||
require(captured_event(capture, 1).at_us == 150 &&
|
||||
capture.elapsed_us(49) == 150,
|
||||
"microsecond clock wrap changed elapsed event timing");
|
||||
capture.stop(149);
|
||||
require_terminal_frozen(capture, CaptureState::kStopped, 250);
|
||||
}
|
||||
|
||||
void test_long_holds_and_exact_timeout() {
|
||||
ControllerMacroCapture capture;
|
||||
CaptureOptions options;
|
||||
options.max_duration_ms = 80000;
|
||||
ControllerState held = controller_neutral_state();
|
||||
held.button_south = true;
|
||||
constexpr uint32_t start = 123;
|
||||
require(capture.start(0, 1, options, start, held),
|
||||
"long-hold capture did not start");
|
||||
capture.tick(start + 10000000);
|
||||
require(capture.event_count() == 2 &&
|
||||
captured_event(capture, 1).at_us == 10000000,
|
||||
"a live ten-second hold did not split at its boundary");
|
||||
capture.observe(0, 1, start + 15000000, held);
|
||||
require(capture.event_count() == 2,
|
||||
"unchanged observation added a spurious hold edge");
|
||||
capture.tick(start + 35000000);
|
||||
require(capture.event_count() == 4,
|
||||
"late tick omitted required ten-second hold boundaries");
|
||||
capture.tick(start + 90000000);
|
||||
require(capture.state() == CaptureState::kTimedOut &&
|
||||
capture.event_count() == 8 &&
|
||||
capture.elapsed_us(start + 90000000) == 80000000,
|
||||
"long hold did not clip at the exact duration and fit eight steps");
|
||||
for (uint16_t i = 0; i < 8; ++i) {
|
||||
const CaptureEvent event = captured_event(capture, i);
|
||||
require(event.at_us == i * 10000000u && event.buttons == 1,
|
||||
"hold split changed the captured state or interval length");
|
||||
}
|
||||
require_terminal_frozen(capture, CaptureState::kTimedOut, 80000000);
|
||||
|
||||
options.max_duration_ms = 25000;
|
||||
require(capture.start(0, 1, options, 0, held),
|
||||
"partial-hold timeout capture did not start");
|
||||
capture.tick(40000000);
|
||||
require(capture.state() == CaptureState::kTimedOut &&
|
||||
capture.event_count() == 3 &&
|
||||
captured_event(capture, 2).at_us == 20000000 &&
|
||||
capture.elapsed_us(40000000) == 25000000,
|
||||
"timeout beyond a hold boundary lost the partial final interval");
|
||||
|
||||
options.max_duration_ms = 10000;
|
||||
options.max_events = 1;
|
||||
require(capture.start(0, 1, options, 0, held),
|
||||
"single-step timeout capture did not start");
|
||||
capture.tick(10000000);
|
||||
require(capture.state() == CaptureState::kTimedOut &&
|
||||
capture.event_count() == 1 &&
|
||||
capture.elapsed_us(20000000) == 10000000,
|
||||
"an exact-duration hold created a zero-length edge or false full");
|
||||
}
|
||||
|
||||
void test_hold_splits_follow_edges_and_coalesce() {
|
||||
ControllerMacroCapture capture;
|
||||
CaptureOptions options;
|
||||
options.max_events = 3;
|
||||
options.max_duration_ms = 80000;
|
||||
ControllerState state = controller_neutral_state();
|
||||
state.button_south = true;
|
||||
require(capture.start(0, 1, options, 0, state),
|
||||
"edge-relative hold capture did not start");
|
||||
state.button_south = false;
|
||||
state.button_east = true;
|
||||
capture.observe(0, 1, 5000000, state);
|
||||
capture.tick(15000000);
|
||||
state.button_east = false;
|
||||
state.button_west = true;
|
||||
capture.observe(0, 1, 15000000, state);
|
||||
require(capture.state() == CaptureState::kRecording &&
|
||||
capture.event_count() == 3 &&
|
||||
captured_event(capture, 1).at_us == 5000000 &&
|
||||
captured_event(capture, 2).at_us == 15000000 &&
|
||||
captured_event(capture, 2).buttons == (1u << 2),
|
||||
"hold boundary did not follow or coalesce with a real edge");
|
||||
capture.stop(25000000);
|
||||
require(capture.state() == CaptureState::kStopped &&
|
||||
capture.event_count() == 3 &&
|
||||
capture.elapsed_us(30000000) == 25000000,
|
||||
"stop at the final hold boundary added an unnecessary event");
|
||||
}
|
||||
|
||||
void test_hold_budget_exhaustion_and_stop_catchup() {
|
||||
ControllerMacroCapture capture;
|
||||
CaptureOptions options;
|
||||
options.max_events = 2;
|
||||
options.max_duration_ms = 80000;
|
||||
ControllerState state = controller_neutral_state();
|
||||
state.button_south = true;
|
||||
require(capture.start(0, 1, options, 0, state),
|
||||
"bounded hold capture did not start");
|
||||
capture.tick(UINT32_MAX);
|
||||
require(capture.state() == CaptureState::kFull &&
|
||||
capture.elapsed_us(UINT32_MAX) == 20000000 &&
|
||||
capture.event_count() == 2 &&
|
||||
captured_event(capture, 1).at_us == 10000000 &&
|
||||
captured_event(capture, 1).buttons == 1,
|
||||
"large tick did not stop full at the first missing hold boundary");
|
||||
require_terminal_frozen(capture, CaptureState::kFull, 20000000);
|
||||
|
||||
options.max_events = 4;
|
||||
require(capture.start(0, 1, options, 0, state),
|
||||
"stop-catchup capture did not start");
|
||||
capture.stop(35000000);
|
||||
require(capture.state() == CaptureState::kStopped &&
|
||||
capture.event_count() == 4 &&
|
||||
captured_event(capture, 3).at_us == 30000000 &&
|
||||
capture.elapsed_us(50000000) == 35000000,
|
||||
"stop without intervening ticks omitted long-hold splits");
|
||||
}
|
||||
|
||||
void test_generation_replacement_disconnect_and_slot_isolation() {
|
||||
ControllerMacroCapture capture;
|
||||
CaptureOptions options;
|
||||
ControllerState initial = controller_neutral_state();
|
||||
initial.button_south = true;
|
||||
ControllerState replacement = controller_neutral_state();
|
||||
replacement.button_east = true;
|
||||
require(capture.start(2, 7, options, 100, initial),
|
||||
"generation capture did not start");
|
||||
capture.observe(3, 99, 150, replacement);
|
||||
capture.disconnect(3, 7, 170);
|
||||
capture.disconnect(2, 6, 180);
|
||||
require(capture.state() == CaptureState::kRecording &&
|
||||
capture.event_count() == 1,
|
||||
"another slot or stale disconnect interrupted capture");
|
||||
capture.observe(2, 8, 200, replacement);
|
||||
require(capture.state() == CaptureState::kDisconnected &&
|
||||
capture.generation() == 7 && capture.event_count() == 1 &&
|
||||
captured_event(capture, 0).buttons == 1 &&
|
||||
capture.elapsed_us(250) == 100,
|
||||
"generation replacement recorded the new device or lost the end");
|
||||
require_terminal_frozen(capture, CaptureState::kDisconnected, 100);
|
||||
|
||||
require(capture.start(2, 8, options, 300, replacement),
|
||||
"replacement generation could not start a new capture");
|
||||
capture.disconnect(2, 7, 400);
|
||||
require(capture.state() == CaptureState::kRecording,
|
||||
"old-generation disconnect ended a replacement run");
|
||||
capture.disconnect(2, 8, 450);
|
||||
require_terminal_frozen(capture, CaptureState::kDisconnected, 150);
|
||||
}
|
||||
|
||||
void test_deadline_precedes_late_edges_and_terminal_requests() {
|
||||
ControllerMacroCapture capture;
|
||||
CaptureOptions options;
|
||||
options.max_duration_ms = 1;
|
||||
ControllerState state = controller_neutral_state();
|
||||
require(capture.start(0, 1, options, 0, state),
|
||||
"deadline capture did not start");
|
||||
state.button_south = true;
|
||||
capture.observe(0, 1, 1000, state);
|
||||
require(capture.state() == CaptureState::kTimedOut &&
|
||||
capture.event_count() == 1 &&
|
||||
captured_event(capture, 0).buttons == 0 &&
|
||||
capture.elapsed_us(2000) == 1000,
|
||||
"an edge at the deadline escaped timeout clipping");
|
||||
require(capture.start(0, 1, options, 0, state),
|
||||
"late-stop capture did not start");
|
||||
capture.stop(2000);
|
||||
require_terminal_frozen(capture, CaptureState::kTimedOut, 1000);
|
||||
require(capture.start(0, 1, options, 0, state),
|
||||
"late-disconnect capture did not start");
|
||||
capture.disconnect(0, 1, 2000);
|
||||
require_terminal_frozen(capture, CaptureState::kTimedOut, 1000);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_initial_snapshot_and_start_validation();
|
||||
test_positional_logical_buttons();
|
||||
test_selected_channels_ignore_unrelated_changes();
|
||||
test_analog_quantization_symmetry_and_endpoints();
|
||||
test_same_timestamp_coalesces_before_budget_check();
|
||||
test_full_static_capacity();
|
||||
test_clock_wrap_and_stopped_end();
|
||||
test_long_holds_and_exact_timeout();
|
||||
test_hold_splits_follow_edges_and_coalesce();
|
||||
test_hold_budget_exhaustion_and_stop_catchup();
|
||||
test_generation_replacement_disconnect_and_slot_isolation();
|
||||
test_deadline_precedes_late_edges_and_terminal_requests();
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
#include "profile/controller_profile.h"
|
||||
|
||||
constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = {
|
||||
constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE] = {
|
||||
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
|
||||
0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
|
||||
|
|
@ -24,7 +24,7 @@ constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = {
|
|||
};
|
||||
|
||||
constexpr uint8_t
|
||||
kLegacyNarrowRawRangeProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = {
|
||||
kLegacyNarrowRawRangeProfile[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE] = {
|
||||
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
|
||||
0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
|
||||
|
|
@ -43,7 +43,7 @@ constexpr uint8_t
|
|||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
|
||||
constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = {
|
||||
constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE] = {
|
||||
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
|
||||
0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ unsigned active_snapshot_count = 0;
|
|||
std::array<ActivationAttempt, 32> activation_attempts{};
|
||||
size_t activation_attempt_count = 0;
|
||||
unsigned activation_busy_attempts = 0;
|
||||
ConfigurationTransactionStatus activation_result =
|
||||
ConfigurationTransactionStatus::kPending;
|
||||
uint8_t last_motion_toggle_slot = 0;
|
||||
uint32_t last_motion_toggle_connection_generation = 0;
|
||||
unsigned motion_toggle_count = 0;
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
|
|
@ -54,6 +59,10 @@ void prepare_profiles() {
|
|||
activation_attempts = {};
|
||||
activation_attempt_count = 0;
|
||||
activation_busy_attempts = 0;
|
||||
activation_result = ConfigurationTransactionStatus::kPending;
|
||||
last_motion_toggle_slot = 0;
|
||||
last_motion_toggle_connection_generation = 0;
|
||||
motion_toggle_count = 0;
|
||||
for (uint8_t slot = 0;
|
||||
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
|
||||
FakeProfileRow& row = rows[slot];
|
||||
|
|
@ -390,18 +399,17 @@ void test_initial_profile_indication_once_per_connection() {
|
|||
void test_default_switching_retry_commit_and_feedback() {
|
||||
prepare_profiles();
|
||||
ControllerProfile& initial_profile = rows[0].profiles[0];
|
||||
initial_profile.macro_trigger = static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kLeftShoulder);
|
||||
initial_profile.macro_step_count = 2;
|
||||
initial_profile.macro_steps[0].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
initial_profile.macros[0].trigger_mask =
|
||||
logical_button_bit(
|
||||
ControllerProfileLogicalButton::kLeftShoulder);
|
||||
initial_profile.macros[0].first_step = 0;
|
||||
initial_profile.macros[0].step_count = 1;
|
||||
initial_profile.macro_step_count = 1;
|
||||
initial_profile.macro_steps[0].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
initial_profile.macro_steps[0].duration_ms = 1000;
|
||||
initial_profile.macro_steps[0].output_button_mask =
|
||||
logical_button_bit(ControllerProfileLogicalButton::kNorth);
|
||||
initial_profile.macro_steps[1].type =
|
||||
ControllerProfileMacroStepType::kEnd;
|
||||
|
||||
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
|
|
@ -526,18 +534,17 @@ void test_identity_promotion_preserves_held_switching() {
|
|||
}
|
||||
|
||||
void configure_motion_suppression_probe(ControllerProfile* profile) {
|
||||
profile->macro_trigger = static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kDpadUp);
|
||||
profile->macro_step_count = 2;
|
||||
profile->macro_steps[0].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
profile->macros[0].trigger_mask =
|
||||
logical_button_bit(
|
||||
ControllerProfileLogicalButton::kDpadUp);
|
||||
profile->macros[0].first_step = 0;
|
||||
profile->macros[0].step_count = 1;
|
||||
profile->macro_step_count = 1;
|
||||
profile->macro_steps[0].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile->macro_steps[0].duration_ms = 1000;
|
||||
profile->macro_steps[0].output_button_mask =
|
||||
logical_button_bit(ControllerProfileLogicalButton::kNorth);
|
||||
profile->macro_steps[1].type =
|
||||
ControllerProfileMacroStepType::kEnd;
|
||||
}
|
||||
|
||||
void test_switching_uses_pre_hotkey_buttons_only() {
|
||||
|
|
@ -623,19 +630,19 @@ void test_custom_switching_chord_and_wrap() {
|
|||
"custom switching chord was not consumed or activated");
|
||||
|
||||
prepare_profiles();
|
||||
rows[0].active_profile = 3;
|
||||
rows[0].profiles[3].switching_chord = kCustomChord;
|
||||
rows[0].active_profile = 7;
|
||||
rows[0].profiles[7].switching_chord = kCustomChord;
|
||||
snapshot = make_snapshot(0);
|
||||
(void)runtime_transform(0, snapshot, 10);
|
||||
bool event_available = true;
|
||||
(void)take_profile_change(0, &event_available);
|
||||
require(!event_available,
|
||||
"initial profile 4 load published a change event");
|
||||
"initial profile 8 load published a change event");
|
||||
apply_button_mask(kCustomChord, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 11);
|
||||
require(activation_attempt_count == 1 &&
|
||||
activation_attempts[0].profile_index == 0,
|
||||
"profile switching did not wrap profile 4 to profile 1");
|
||||
"profile switching did not wrap profile 8 to profile 1");
|
||||
}
|
||||
void test_switching_slot_isolation() {
|
||||
prepare_profiles();
|
||||
|
|
@ -673,25 +680,61 @@ void test_switching_slot_isolation() {
|
|||
}
|
||||
|
||||
|
||||
void test_profile_motion_toggle_supports_trigger_chords() {
|
||||
prepare_profiles();
|
||||
const uint32_t custom_chord =
|
||||
logical_button_bit(ControllerProfileLogicalButton::kSouth) |
|
||||
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
|
||||
rows[0].profiles[0].motion_toggle_chord = custom_chord;
|
||||
Bluepad32SlotSnapshot snapshot = make_snapshot(0, 9);
|
||||
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
require(motion_toggle_count == 0,
|
||||
"profile refresh toggled motion without a chord");
|
||||
|
||||
apply_button_mask(
|
||||
logical_button_bit(ControllerProfileLogicalButton::kSouth),
|
||||
&snapshot);
|
||||
snapshot.state.left_trigger = UINT16_MAX;
|
||||
ControllerProfileTransformResult output =
|
||||
runtime_transform(0, snapshot, 1);
|
||||
require(motion_toggle_count == 1 &&
|
||||
last_motion_toggle_slot == 0 &&
|
||||
last_motion_toggle_connection_generation == 9 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0 &&
|
||||
output.state.left_trigger == 0,
|
||||
"trigger-backed motion chord did not toggle and consume inputs");
|
||||
|
||||
(void)runtime_transform(0, snapshot, 2);
|
||||
require(motion_toggle_count == 1,
|
||||
"held trigger-backed motion chord toggled twice");
|
||||
snapshot = make_snapshot(0, 9);
|
||||
(void)runtime_transform(0, snapshot, 3);
|
||||
apply_button_mask(
|
||||
logical_button_bit(ControllerProfileLogicalButton::kSouth),
|
||||
&snapshot);
|
||||
snapshot.state.left_trigger = UINT16_MAX;
|
||||
(void)runtime_transform(0, snapshot, 4);
|
||||
require(motion_toggle_count == 2,
|
||||
"released trigger-backed motion chord did not re-arm");
|
||||
}
|
||||
|
||||
void configure_synthetic_profile(uint8_t slot) {
|
||||
ControllerProfile& profile = rows[slot].profiles[0];
|
||||
profile = controller_profile_default(rows[slot].identity, 0);
|
||||
profile.macro_trigger =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth);
|
||||
profile.macro_cancel =
|
||||
profile.macros[0].trigger_mask =
|
||||
logical_button_bit(ControllerProfileLogicalButton::kSouth);
|
||||
profile.macros[0].cancel_control =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kCapture);
|
||||
profile.macro_step_count = 2;
|
||||
profile.macro_steps[0].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
profile.macros[0].first_step = 0;
|
||||
profile.macros[0].step_count = 1;
|
||||
profile.macro_step_count = 1;
|
||||
profile.macro_steps[0].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[0].duration_ms = 1000;
|
||||
profile.macro_steps[0].output_button_mask = static_cast<uint16_t>(
|
||||
1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kNorth));
|
||||
profile.macro_steps[1] = {};
|
||||
profile.macro_steps[1].type =
|
||||
ControllerProfileMacroStepType::kEnd;
|
||||
profile.turbo_modes[static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kEast)] =
|
||||
ControllerProfileTurboMode::kAutoBurst;
|
||||
|
|
@ -835,8 +878,298 @@ void test_runtime_slot_synthetic_isolation() {
|
|||
"slot-local configured cancel affected another slot");
|
||||
}
|
||||
|
||||
void test_shortcut_arbitration_latching_and_commit_feedback() {
|
||||
prepare_profiles();
|
||||
ControllerProfile& profile = rows[0].profiles[0];
|
||||
constexpr uint16_t modifier = 1u << 9;
|
||||
constexpr uint16_t chord = modifier | 1u;
|
||||
profile.shortcuts.modifier = 9;
|
||||
profile.shortcuts.selectors[2] = 0;
|
||||
profile.shortcuts.selectors[3] = 1;
|
||||
profile.switching_chord = chord;
|
||||
profile.motion_toggle_chord = chord;
|
||||
profile.shift.mode = ControllerProfileShiftMode::kToggle;
|
||||
profile.shift.modifier = 9;
|
||||
profile.shift.button_map[2] = 3;
|
||||
profile.macros[0] = {chord, CONTROLLER_PROFILE_NO_BUTTON, 0, 1};
|
||||
profile.macro_step_count = 1;
|
||||
profile.macro_steps[0] = {kControllerProfileOverrideButtons, 100, 8};
|
||||
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
|
||||
apply_button_mask(chord | 2u, &snapshot);
|
||||
auto output = runtime_transform(0, snapshot, 1);
|
||||
require(activation_attempt_count == 0 && motion_toggle_count == 0 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"ambiguous direct selectors triggered a lower-priority action");
|
||||
apply_button_mask(chord, &snapshot);
|
||||
output = runtime_transform(0, snapshot, 2);
|
||||
require(activation_attempt_count == 0 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"ambiguous chord chose a target when one selector was released");
|
||||
apply_button_mask(modifier, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 3);
|
||||
apply_button_mask(chord | 4u, &snapshot);
|
||||
output = runtime_transform(0, snapshot, 4);
|
||||
require(activation_attempt_count == 1 &&
|
||||
activation_attempts[0].profile_index == 2 &&
|
||||
motion_toggle_count == 0 && output.state.button_west &&
|
||||
!output.state.button_north && !output.state.button_south &&
|
||||
!output.state.button_capture,
|
||||
"direct shortcut did not outrank cycle, motion, Shift and macro");
|
||||
bool available = true;
|
||||
(void)take_profile_change(0, &available);
|
||||
require(!available, "accepted but uncommitted shortcut produced feedback");
|
||||
|
||||
ControllerProfile& committed = rows[0].profiles[2];
|
||||
committed.shortcuts.modifier = 9;
|
||||
committed.shortcuts.selectors[4] = 0;
|
||||
committed.button_map[2] = 3;
|
||||
committed.confirmation_policy = ControllerProfileConfirmationPolicy::kLed;
|
||||
rows[0].active_profile = 2;
|
||||
++database_generation;
|
||||
output = runtime_transform(0, snapshot, 5);
|
||||
const auto event = take_profile_change(0, &available);
|
||||
require(available && event.active_profile_number == 3 &&
|
||||
event.policy == ControllerProfileConfirmationPolicy::kLed &&
|
||||
activation_attempt_count == 1 && output.state.button_north &&
|
||||
!output.state.button_capture && !output.state.button_south,
|
||||
"committed shortcut redirected a held chord or lost commit feedback");
|
||||
(void)take_profile_change(0, &available);
|
||||
require(!available, "shortcut published duplicate commit feedback");
|
||||
apply_button_mask(modifier, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 6);
|
||||
activation_result = ConfigurationTransactionStatus::kStorageError;
|
||||
apply_button_mask(chord, &snapshot);
|
||||
output = runtime_transform(0, snapshot, 7);
|
||||
(void)runtime_transform(0, snapshot, 8);
|
||||
(void)take_profile_change(0, &available);
|
||||
require(activation_attempt_count == 2 &&
|
||||
activation_attempts[1].profile_index == 4 && !available &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"failed activation retried, leaked its chord, or produced feedback");
|
||||
|
||||
prepare_profiles();
|
||||
rows[0].profiles[0].shortcuts.modifier = 9;
|
||||
rows[0].profiles[0].shortcuts.selectors[0] = 0;
|
||||
snapshot = make_snapshot(0);
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
apply_button_mask(chord, &snapshot);
|
||||
output = runtime_transform(0, snapshot, 1);
|
||||
(void)take_profile_change(0, &available);
|
||||
require(activation_attempt_count == 0 && !available &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"already-active direct target was not a consumed no-op");
|
||||
}
|
||||
|
||||
void test_busy_shortcut_target_and_generation_isolation() {
|
||||
prepare_profiles();
|
||||
constexpr uint16_t chord = (1u << 9) | 1u;
|
||||
rows[0].profiles[0].shortcuts.modifier = 9;
|
||||
rows[0].profiles[0].shortcuts.selectors[3] = 0;
|
||||
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
activation_busy_attempts = 3;
|
||||
apply_button_mask(chord, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 1);
|
||||
rows[0].active_profile = 1;
|
||||
rows[0].profiles[1].shortcuts.modifier = 9;
|
||||
rows[0].profiles[1].shortcuts.selectors[6] = 0;
|
||||
++database_generation;
|
||||
(void)runtime_transform(0, snapshot, 2);
|
||||
require(activation_attempt_count == 2 &&
|
||||
activation_attempts[1].profile_index == 3 &&
|
||||
activation_attempts[0].transaction_id ==
|
||||
activation_attempts[1].transaction_id,
|
||||
"profile refresh changed a busy shortcut's latched target");
|
||||
++snapshot.connection_generation;
|
||||
auto output = runtime_transform(0, snapshot, 3);
|
||||
require(activation_attempt_count == 2 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"new connection generation inherited or retriggered held activation");
|
||||
apply_button_mask(0, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 4);
|
||||
apply_button_mask(chord, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 5);
|
||||
require(activation_attempt_count == 3 &&
|
||||
activation_attempts[2].profile_index == 6 &&
|
||||
activation_attempts[2].transaction_id !=
|
||||
activation_attempts[0].transaction_id,
|
||||
"new generation could not rearm with its own target");
|
||||
output = runtime_transform(0, snapshot, 6, AdapterUsbMode::kXInput);
|
||||
(void)runtime_transform(0, snapshot, 7, AdapterUsbMode::kXInput);
|
||||
require(activation_attempt_count == 3 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"output-mode reset leaked a pending activation retry");
|
||||
}
|
||||
|
||||
void test_cycle_motion_arbitration_and_held_refresh() {
|
||||
prepare_profiles();
|
||||
constexpr uint16_t chord = (1u << 9) | 1u;
|
||||
rows[0].profiles[0].switching_chord = chord;
|
||||
rows[0].profiles[0].motion_toggle_chord = chord;
|
||||
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
apply_button_mask(chord, &snapshot);
|
||||
auto output = runtime_transform(0, snapshot, 1);
|
||||
require(activation_attempt_count == 1 && motion_toggle_count == 0 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"one cycle chord also toggled motion");
|
||||
apply_button_mask(1u, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 2);
|
||||
apply_button_mask(chord, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 2);
|
||||
require(activation_attempt_count == 2 && motion_toggle_count == 0,
|
||||
"re-completing a released cycle chord did not rearm");
|
||||
apply_button_mask(1u, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 2);
|
||||
rows[0].profiles[0].motion_toggle_chord = 1u;
|
||||
rows[0].profiles[0].switching_chord = 2u;
|
||||
++database_generation;
|
||||
output = runtime_transform(0, snapshot, 3);
|
||||
require(motion_toggle_count == 0 && !output.state.button_south,
|
||||
"partial held chord became a new action after profile refresh");
|
||||
apply_button_mask(0, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 4);
|
||||
apply_button_mask(1u, &snapshot);
|
||||
output = runtime_transform(0, snapshot, 5);
|
||||
require(motion_toggle_count == 1 && !output.state.button_south,
|
||||
"motion did not rearm after the higher-priority chord released");
|
||||
++database_generation;
|
||||
output = runtime_transform(0, snapshot, 6);
|
||||
require(motion_toggle_count == 1 && !output.state.button_south,
|
||||
"profile refresh phantom-toggled held motion chord");
|
||||
}
|
||||
|
||||
void test_shift_slot_and_context_resets() {
|
||||
prepare_profiles();
|
||||
std::array<Bluepad32SlotSnapshot, 4> snapshots{};
|
||||
constexpr uint8_t outputs[4] = {1, 2, 3, 12};
|
||||
for (uint8_t slot = 0; slot < 4; ++slot) {
|
||||
auto& profile = rows[slot].profiles[0];
|
||||
profile.shift.mode = ControllerProfileShiftMode::kToggle;
|
||||
profile.shift.modifier = 9;
|
||||
profile.shift.button_map[0] = outputs[slot];
|
||||
snapshots[slot] = make_snapshot(slot);
|
||||
(void)runtime_transform(slot, snapshots[slot], 0);
|
||||
apply_button_mask((1u << 9) | 1u, &snapshots[slot]);
|
||||
const auto output = runtime_transform(slot, snapshots[slot], 1);
|
||||
require(controller_profile_extract_button_mask(output.state) ==
|
||||
(1u << outputs[slot]),
|
||||
"Shift activation was not physical, consumed and slot-local");
|
||||
}
|
||||
++snapshots[0].connection_generation;
|
||||
auto output = runtime_transform(0, snapshots[0], 2);
|
||||
require(output.state.button_south && !output.state.button_capture,
|
||||
"connection reset phantom-toggled held Shift");
|
||||
output = runtime_transform(1, snapshots[1], 2);
|
||||
require(output.state.button_west && !output.state.button_south,
|
||||
"one slot's Shift reset disturbed another slot");
|
||||
output = runtime_transform(1, snapshots[1], 3, AdapterUsbMode::kXInput);
|
||||
require(output.state.button_south && !output.state.button_west,
|
||||
"mode reset retained or retriggered toggle Shift");
|
||||
rows[2].profiles[1].shift = rows[2].profiles[0].shift;
|
||||
rows[2].active_profile = 1;
|
||||
++database_generation;
|
||||
output = runtime_transform(2, snapshots[2], 4);
|
||||
require(output.state.button_south && !output.state.button_north &&
|
||||
!output.state.button_capture,
|
||||
"profile activation retained or phantom-toggled Shift");
|
||||
output = runtime_transform(3, snapshots[3], 4);
|
||||
require(output.state.button_south && !output.state.dpad_up,
|
||||
"database refresh did not reset the remaining Shift slot");
|
||||
apply_button_mask(1u, &snapshots[3]);
|
||||
(void)runtime_transform(3, snapshots[3], 5);
|
||||
apply_button_mask((1u << 9) | 1u, &snapshots[3]);
|
||||
output = runtime_transform(3, snapshots[3], 6);
|
||||
require(output.state.dpad_up && !output.state.button_south,
|
||||
"Shift could not rearm after reset and physical release");
|
||||
++configuration_reset_generation;
|
||||
output = runtime_transform(3, snapshots[3], 7);
|
||||
require(output.state.button_south && !output.state.dpad_up,
|
||||
"configuration reset retained Shift toggle state");
|
||||
}
|
||||
|
||||
void test_held_synthetic_sources_and_disconnect_rearming() {
|
||||
prepare_profiles();
|
||||
auto& profile = rows[0].profiles[0];
|
||||
profile.macros[0] = {1u, 9, 0, 1};
|
||||
profile.macros[0].mode = ControllerProfileMacroMode::kToggle;
|
||||
profile.macro_step_count = 1;
|
||||
profile.macro_steps[0].override_flags = kControllerProfileOverrideLeftStick;
|
||||
profile.macro_steps[0].duration_ms = 10;
|
||||
profile.macro_steps[0].left_stick_x = 12345;
|
||||
profile.turbo_modes[1] = ControllerProfileTurboMode::kBurst;
|
||||
profile.turbo_modes[2] = ControllerProfileTurboMode::kAutoBurst;
|
||||
profile.shortcuts.modifier = 9;
|
||||
profile.shortcuts.selectors[2] = 3;
|
||||
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
apply_button_mask(7u, &snapshot);
|
||||
auto output = runtime_transform(0, snapshot, 1);
|
||||
require(output.state.left_stick_x == 12345 &&
|
||||
output.state.button_east && output.state.button_west &&
|
||||
!output.state.button_south,
|
||||
"independent looping macro and Burst sources did not start");
|
||||
++database_generation;
|
||||
output = runtime_transform(0, snapshot, 2);
|
||||
require(output.state.left_stick_x == 0 && output.state.button_south &&
|
||||
!output.state.button_east && !output.state.button_west,
|
||||
"refresh phantom-restarted a held edge-triggered synthetic source");
|
||||
apply_button_mask(0, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 3);
|
||||
apply_button_mask(7u, &snapshot);
|
||||
output = runtime_transform(0, snapshot, 4);
|
||||
require(output.state.left_stick_x == 12345 &&
|
||||
output.state.button_east && output.state.button_west,
|
||||
"synthetic sources could not rearm after refresh and release");
|
||||
snapshot.active = false;
|
||||
(void)runtime_transform(0, snapshot, 5);
|
||||
snapshot.active = true;
|
||||
apply_button_mask((1u << 9) | 8u, &snapshot);
|
||||
output = runtime_transform(0, snapshot, 6);
|
||||
require(activation_attempt_count == 0 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0 &&
|
||||
output.state.left_stick_x == 0,
|
||||
"reconnect treated an already-held shortcut as a new command");
|
||||
apply_button_mask(0, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 7);
|
||||
apply_button_mask((1u << 9) | 8u, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 8);
|
||||
require(activation_attempt_count == 1 &&
|
||||
activation_attempts[0].profile_index == 2,
|
||||
"reconnected shortcut did not rearm on physical release");
|
||||
}
|
||||
|
||||
void test_shortcut_selector_rollover_without_modifier_release() {
|
||||
prepare_profiles();
|
||||
auto& profile = rows[0].profiles[0];
|
||||
profile.shortcuts.modifier = 9;
|
||||
profile.shortcuts.selectors[1] = 0;
|
||||
profile.shortcuts.selectors[2] = 1;
|
||||
auto snapshot = make_snapshot(0);
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
apply_button_mask((1u << 9) | 1u, &snapshot);
|
||||
(void)runtime_transform(0, snapshot, 1);
|
||||
apply_button_mask((1u << 9) | 2u, &snapshot);
|
||||
const auto output = runtime_transform(0, snapshot, 2);
|
||||
require(activation_attempt_count == 2 &&
|
||||
activation_attempts[0].profile_index == 1 &&
|
||||
activation_attempts[1].profile_index == 2 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"swapping shortcut selectors while holding the modifier lost the new action");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool bluepad32_input_backend_toggle_motion(
|
||||
uint8_t slot, uint32_t connection_generation) {
|
||||
last_motion_toggle_slot = slot;
|
||||
last_motion_toggle_connection_generation = connection_generation;
|
||||
++motion_toggle_count;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t profile_service_database_generation() {
|
||||
return database_generation;
|
||||
}
|
||||
|
|
@ -855,7 +1188,7 @@ ConfigurationTransactionStatus profile_service_activate_internal(
|
|||
--activation_busy_attempts;
|
||||
return ConfigurationTransactionStatus::kBusy;
|
||||
}
|
||||
return ConfigurationTransactionStatus::kPending;
|
||||
return activation_result;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -889,8 +1222,15 @@ int main() {
|
|||
test_identity_promotion_preserves_held_switching();
|
||||
test_switching_uses_pre_hotkey_buttons_only();
|
||||
test_custom_switching_chord_and_wrap();
|
||||
test_profile_motion_toggle_supports_trigger_chords();
|
||||
test_switching_slot_isolation();
|
||||
test_all_runtime_cancellation_causes();
|
||||
test_runtime_slot_synthetic_isolation();
|
||||
test_shortcut_arbitration_latching_and_commit_feedback();
|
||||
test_busy_shortcut_target_and_generation_isolation();
|
||||
test_cycle_motion_arbitration_and_held_refresh();
|
||||
test_shift_slot_and_context_resets();
|
||||
test_held_synthetic_sources_and_disconnect_rearming();
|
||||
test_shortcut_selector_rollover_without_modifier_release();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ void test_profile_wire_schema() {
|
|||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
|
||||
"default profile did not encode");
|
||||
require(encoded[0] == 2 && encoded[1] == 0 &&
|
||||
encoded[2] == 0 && encoded[3] == 1,
|
||||
"profile header is not little-endian v2/256");
|
||||
require(encoded[0] == 6 && encoded[1] == 0 &&
|
||||
encoded[2] == 0x80 && encoded[3] == 1,
|
||||
"profile header is not little-endian v6/384");
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||
require(encoded[4 + index] == index,
|
||||
|
|
@ -64,25 +64,78 @@ void test_profile_wire_schema() {
|
|||
static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8),
|
||||
"default trigger encoding changed");
|
||||
require(encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
encoded[61] == 0 &&
|
||||
encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
|
||||
encoded[71] == 0,
|
||||
"default trigger mappings are not identity");
|
||||
require(encoded[72] == 0xff && encoded[73] == 0xff &&
|
||||
encoded[74] == 3 && encoded[78] == 0xff &&
|
||||
encoded[79] == 0xff && encoded[80] == 1,
|
||||
"default rumble or macro encoding changed");
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
|
||||
require(encoded[100 + index * 19] == 1,
|
||||
"unused macro step is not canonical end");
|
||||
}
|
||||
encoded[74] == 3 && encoded[75] == 0 &&
|
||||
encoded[78] == 0 && encoded[79] == 0,
|
||||
"default rumble or action encoding changed");
|
||||
require(encoded[96] == 0 && encoded[97] == 0 &&
|
||||
encoded[98] == 0x7c && encoded[99] == 0 &&
|
||||
encoded[100] == 0 && encoded[101] == 0,
|
||||
"default sparse macro descriptor changed");
|
||||
|
||||
ControllerProfile decoded{};
|
||||
require(controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"default profile did not decode");
|
||||
ControllerProfile action_profile = profile;
|
||||
action_profile.switching_chord =
|
||||
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) |
|
||||
(1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kSouth));
|
||||
action_profile.motion_toggle_chord =
|
||||
(1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL);
|
||||
action_profile.macros[0].trigger_mask =
|
||||
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) |
|
||||
(1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL);
|
||||
action_profile.macros[0].cancel_control =
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
action_profile.macros[0].first_step = 0;
|
||||
action_profile.macros[0].step_count = 1;
|
||||
action_profile.macro_step_count = 1;
|
||||
action_profile.macro_steps[0].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
for (uint8_t macro_index = 1;
|
||||
macro_index < CONTROLLER_PROFILE_MACRO_COUNT; ++macro_index) {
|
||||
action_profile.macros[macro_index].first_step = 1;
|
||||
}
|
||||
action_profile.macro_steps[0].duration_ms = 25;
|
||||
action_profile.macro_steps[0].output_button_mask = 1;
|
||||
uint8_t action_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(
|
||||
action_profile, action_encoded, sizeof(action_encoded)),
|
||||
"sparse action profile did not encode");
|
||||
require(action_encoded[75] == 0x21,
|
||||
"sparse action extension encoding changed");
|
||||
require(action_encoded[96] == 0 &&
|
||||
action_encoded[97] == 0 &&
|
||||
action_encoded[98] == 0x43,
|
||||
"sparse macro binding encoding changed");
|
||||
require(action_encoded[100] == 1 &&
|
||||
action_encoded[101] == 5,
|
||||
"sparse macro step bounds changed");
|
||||
require(controller_profile_decode(
|
||||
action_encoded, sizeof(action_encoded), &decoded),
|
||||
"sparse action profile did not decode");
|
||||
require(decoded.switching_chord ==
|
||||
action_profile.switching_chord &&
|
||||
decoded.motion_toggle_chord ==
|
||||
action_profile.motion_toggle_chord &&
|
||||
decoded.macros[0].trigger_mask ==
|
||||
action_profile.macros[0].trigger_mask &&
|
||||
decoded.macros[0].cancel_control ==
|
||||
action_profile.macros[0].cancel_control &&
|
||||
decoded.macros[0].step_count == 1,
|
||||
"sparse trigger-backed macro did not round-trip");
|
||||
encoded[252] = 1;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"nonzero reserved profile byte was accepted");
|
||||
|
||||
ControllerProfile invalid = profile;
|
||||
invalid.button_map[0] = 16;
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"invalid direct output was accepted");
|
||||
invalid = profile;
|
||||
|
|
@ -121,23 +174,22 @@ void test_profile_wire_schema() {
|
|||
"reversed raw trigger range was accepted");
|
||||
invalid = profile;
|
||||
invalid.turbo_modes[0] =
|
||||
static_cast<ControllerProfileTurboMode>(3);
|
||||
static_cast<ControllerProfileTurboMode>(4);
|
||||
require(!controller_profile_validate(invalid),
|
||||
"invalid Turbo mode was accepted");
|
||||
invalid = profile;
|
||||
invalid.macro_step_count = 2;
|
||||
invalid.macro_steps[0].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
invalid.macros[0].first_step = 0;
|
||||
invalid.macros[0].step_count = 1;
|
||||
invalid.macro_step_count = 1;
|
||||
invalid.macro_steps[0].duration_ms =
|
||||
CONTROLLER_PROFILE_MAX_WAIT_MS + 1;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"unbounded macro wait was accepted");
|
||||
invalid.macro_steps[0].duration_ms =
|
||||
CONTROLLER_PROFILE_MAX_WAIT_MS;
|
||||
invalid.macro_steps[1].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
invalid.macros[1].first_step = 0;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"macro without a final end was accepted");
|
||||
"noncanonical shared macro pool was accepted");
|
||||
}
|
||||
|
||||
void test_legacy_profile_migration() {
|
||||
|
|
@ -155,16 +207,9 @@ void test_legacy_profile_migration() {
|
|||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
|
||||
"migrated default profile did not encode");
|
||||
for (size_t index = 0; index < sizeof(encoded); ++index) {
|
||||
const bool schema_byte = index == 0;
|
||||
const bool threshold_byte =
|
||||
(index >= 58 && index < 60) ||
|
||||
(index >= 68 && index < 70);
|
||||
if (!schema_byte && !threshold_byte) {
|
||||
require(encoded[index] == kLegacyDefaultProfile[index],
|
||||
"legacy default profile changed an unrelated byte");
|
||||
}
|
||||
}
|
||||
require(migrated.macros[0].step_count == 0 &&
|
||||
migrated.macro_step_count == 0,
|
||||
"legacy end marker was not removed during migration");
|
||||
require(encoded[0] == CONTROLLER_PROFILE_SCHEMA_VERSION &&
|
||||
encoded[58] ==
|
||||
static_cast<uint8_t>(
|
||||
|
|
@ -177,8 +222,10 @@ void test_legacy_profile_migration() {
|
|||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) &&
|
||||
encoded[69] ==
|
||||
static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8),
|
||||
"migrated default profile did not encode as v2");
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) &&
|
||||
encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL,
|
||||
"migrated default profile did not encode as v6");
|
||||
|
||||
require(controller_profile_decode(
|
||||
kLegacyNarrowRawRangeProfile,
|
||||
|
|
@ -195,17 +242,6 @@ void test_legacy_profile_migration() {
|
|||
"legacy narrow raw range or inherited threshold was not migrated");
|
||||
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
|
||||
"migrated narrow-raw-range profile did not encode");
|
||||
for (size_t index = 0; index < sizeof(encoded); ++index) {
|
||||
const bool schema_byte = index == 0;
|
||||
const bool threshold_byte =
|
||||
(index >= 58 && index < 60) ||
|
||||
(index >= 68 && index < 70);
|
||||
if (!schema_byte && !threshold_byte) {
|
||||
require(
|
||||
encoded[index] == kLegacyNarrowRawRangeProfile[index],
|
||||
"narrow-raw-range migration changed unrelated profile data");
|
||||
}
|
||||
}
|
||||
|
||||
require(controller_profile_decode(
|
||||
kLegacyCustomThresholdProfile,
|
||||
|
|
@ -214,12 +250,67 @@ void test_legacy_profile_migration() {
|
|||
require(migrated.triggers[0].digital_threshold == 0x1234 &&
|
||||
migrated.triggers[1].digital_threshold == 0xabcd,
|
||||
"legacy custom thresholds were not preserved");
|
||||
require(migrated.triggers[0].output ==
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
migrated.triggers[1].output ==
|
||||
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL,
|
||||
"legacy trigger identity mappings were not restored");
|
||||
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
|
||||
"legacy custom-threshold profile did not re-encode");
|
||||
for (size_t index = 1; index < sizeof(encoded); ++index) {
|
||||
require(encoded[index] == kLegacyCustomThresholdProfile[index],
|
||||
"legacy custom-threshold profile changed data");
|
||||
}
|
||||
const uint8_t legacy_macro_trigger =
|
||||
kLegacyCustomThresholdProfile[78];
|
||||
const uint32_t expected_macro_trigger =
|
||||
legacy_macro_trigger < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT
|
||||
? static_cast<uint32_t>(1u << legacy_macro_trigger)
|
||||
: 0;
|
||||
require(migrated.macros[0].trigger_mask ==
|
||||
expected_macro_trigger &&
|
||||
migrated.macros[0].cancel_control ==
|
||||
kLegacyCustomThresholdProfile[79],
|
||||
"legacy macro trigger was not migrated to descriptor zero");
|
||||
|
||||
uint8_t previous_encoded[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE]{};
|
||||
memcpy(previous_encoded, kLegacyDefaultProfile,
|
||||
sizeof(previous_encoded));
|
||||
previous_encoded[0] = static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION);
|
||||
previous_encoded[58] = static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD);
|
||||
previous_encoded[59] = static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8);
|
||||
previous_encoded[68] = static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD);
|
||||
previous_encoded[69] = static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8);
|
||||
previous_encoded[78] = static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kSouth);
|
||||
previous_encoded[79] = static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kCapture);
|
||||
require(controller_profile_decode(
|
||||
previous_encoded, sizeof(previous_encoded), &migrated) &&
|
||||
migrated.triggers[0].output ==
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
migrated.triggers[1].output ==
|
||||
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
|
||||
migrated.macros[0].trigger_mask ==
|
||||
static_cast<uint32_t>(
|
||||
1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kSouth)) &&
|
||||
migrated.macros[0].cancel_control ==
|
||||
static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kCapture) &&
|
||||
migrated.motion_toggle_chord == 0,
|
||||
"v2 profile controls did not migrate to v6");
|
||||
previous_encoded[80] = 2;
|
||||
previous_encoded[100] = 0;
|
||||
previous_encoded[101] = kControllerProfileOverrideButtons;
|
||||
previous_encoded[102] = 25;
|
||||
previous_encoded[104] = 1;
|
||||
require(controller_profile_decode(
|
||||
previous_encoded, sizeof(previous_encoded), &migrated) &&
|
||||
migrated.macros[0].step_count == 1 &&
|
||||
migrated.macro_steps[0].output_button_mask == 1,
|
||||
"legacy nonempty macro did not migrate into shared pool");
|
||||
|
||||
ControllerProfile current =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
|
|
@ -265,7 +356,7 @@ void test_database_round_trip_and_capacity() {
|
|||
require(encoded_database[4] ==
|
||||
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION &&
|
||||
encoded_database[5] == 0,
|
||||
"database encoder did not emit v2");
|
||||
"database encoder did not emit v3");
|
||||
require(controller_profile_database_decode(
|
||||
read_encoded_database, nullptr, &decoded_database),
|
||||
"database did not decode");
|
||||
|
|
@ -279,10 +370,150 @@ void test_database_round_trip_and_capacity() {
|
|||
"nonzero database header reservation was accepted");
|
||||
}
|
||||
|
||||
void test_set_b_sparse_extension_and_migration() {
|
||||
ControllerProfile profile =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
profile.shortcuts.modifier = 16;
|
||||
profile.shortcuts.selectors[0] = 0;
|
||||
profile.shortcuts.selectors[7] = 15;
|
||||
profile.shift.mode = ControllerProfileShiftMode::kToggle;
|
||||
profile.shift.modifier = 17;
|
||||
profile.shift.button_map[0] = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
profile.turbo_modes[15] = ControllerProfileTurboMode::kBurst;
|
||||
profile.turbo_defaults = {30, 99, 255};
|
||||
profile.turbo_override_mask = (1u << 2) | (1u << 15);
|
||||
profile.turbo_overrides[2] = {1, 1, 1};
|
||||
profile.turbo_overrides[15] = {23, 37, 17};
|
||||
profile.turbo_overrides[0] = {0, 0, 0};
|
||||
profile.macros[0] = {1, 0xff, 0, 8, ControllerProfileMacroMode::kRepeat, 255};
|
||||
profile.macro_step_count = 8;
|
||||
for (uint8_t macro = 1; macro < CONTROLLER_PROFILE_MACRO_COUNT; ++macro) {
|
||||
profile.macros[macro].first_step = 8;
|
||||
profile.macros[macro].mode = static_cast<ControllerProfileMacroMode>(macro - 1);
|
||||
}
|
||||
for (uint8_t step = 0; step < 8; ++step) {
|
||||
profile.macro_steps[step] = {31, 25, 1, -2, 3, -4, 5, 0x1234, 0xabcd};
|
||||
}
|
||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
ControllerProfile decoded{};
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
|
||||
controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"full sparse stream and Set B extension did not round trip");
|
||||
require(encoded[254] == 0xcd && encoded[255] == 0xab &&
|
||||
encoded[256] == 16 && encoded[264] == 15 &&
|
||||
encoded[265] == 2 && encoded[266] == 17 &&
|
||||
encoded[267] == 0xff && encoded[283] == 30 &&
|
||||
encoded[286] == 4 && encoded[287] == 0x80 &&
|
||||
encoded[288] == 1 && encoded[291] == 23 &&
|
||||
encoded[336] == 3 && encoded[337] == 255,
|
||||
"Set B fields overlap macro data or use wrong sparse ordering");
|
||||
require(decoded.turbo_overrides[15].duty_percent == 37 &&
|
||||
decoded.macros[0].repeat_count == 255 &&
|
||||
decoded.shortcuts.selectors[7] == 15,
|
||||
"Set B extension settings were not decoded");
|
||||
for (size_t offset = 294; offset < 336; ++offset) {
|
||||
require(encoded[offset] == 0, "absent override was not canonical zero");
|
||||
}
|
||||
encoded[294] = 1;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"nonzero sparse Turbo padding was accepted");
|
||||
encoded[294] = 0;
|
||||
encoded[383] = 1;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"nonzero extension reservation was accepted");
|
||||
encoded[383] = 0;
|
||||
encoded[337] = 0;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"zero macro repeat count was accepted");
|
||||
encoded[337] = 255;
|
||||
uint8_t legacy[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE]{};
|
||||
memcpy(legacy, encoded, sizeof(legacy));
|
||||
legacy[0] = 5;
|
||||
legacy[2] = 0;
|
||||
legacy[95] = 2;
|
||||
require(controller_profile_decode(legacy, sizeof(legacy), &decoded) &&
|
||||
decoded.macros[0].step_count == 8 &&
|
||||
decoded.macro_steps[7].right_trigger == 0xabcd &&
|
||||
decoded.macros[0].mode == ControllerProfileMacroMode::kOnce &&
|
||||
decoded.shortcuts.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
decoded.turbo_override_mask == 0,
|
||||
"schema5 full136-byte macro stream did not migrate");
|
||||
require(controller_profile_encode(decoded, encoded, sizeof(encoded)) &&
|
||||
memcmp(&legacy[4], &encoded[4], sizeof(legacy) - 4) == 0,
|
||||
"schema5 migration changed existing profile data");
|
||||
legacy[0] = 6;
|
||||
require(!controller_profile_decode(legacy, sizeof(legacy), &decoded),
|
||||
"schema6 accepted a legacy-sized payload");
|
||||
profile.shortcuts.selectors[7] = 0;
|
||||
require(!controller_profile_validate(profile), "duplicate shortcut was accepted");
|
||||
profile.shortcuts.selectors[7] = 15;
|
||||
profile.shift.button_map[0] = 16;
|
||||
require(!controller_profile_validate(profile), "analog Shift output was accepted");
|
||||
profile.shift.button_map[0] = 0;
|
||||
profile.turbo_overrides[2].rate_hz = 31;
|
||||
require(!controller_profile_validate(profile), "out-of-range Turbo rate was accepted");
|
||||
profile.turbo_overrides[2].rate_hz = 1;
|
||||
for (uint8_t step = 0; step < 8; ++step) {
|
||||
profile.macro_steps[step].duration_ms = 0;
|
||||
}
|
||||
require(!controller_profile_validate(profile), "zero-duration looping macro was accepted");
|
||||
profile.macros[0].mode = ControllerProfileMacroMode::kOnce;
|
||||
require(controller_profile_validate(profile), "zero-duration once macro was rejected");
|
||||
}
|
||||
|
||||
void test_legacy_database_strides() {
|
||||
for (uint8_t version = 1; version <= 2; ++version) {
|
||||
const uint8_t count = version == 1 ? 4 : 8;
|
||||
const size_t entry_size = 16 + count * 256;
|
||||
const size_t entries_offset = 32 + count * 256;
|
||||
const size_t total_size = entries_offset + 16 * entry_size;
|
||||
memset(encoded_database, 0, sizeof(encoded_database));
|
||||
memcpy(encoded_database, "SPDB", 4);
|
||||
encoded_database[4] = version;
|
||||
encoded_database[6] = static_cast<uint8_t>(total_size);
|
||||
encoded_database[7] = static_cast<uint8_t>(total_size >> 8);
|
||||
encoded_database[8] = 16;
|
||||
encoded_database[9] = count;
|
||||
encoded_database[10] = count - 1;
|
||||
encoded_database[11] = 1;
|
||||
for (uint8_t index = 0; index < count; ++index) {
|
||||
memcpy(&encoded_database[32 + index * 256], kLegacyDefaultProfile, 256);
|
||||
}
|
||||
const size_t last_entry = entries_offset + 15 * entry_size;
|
||||
require(controller_identity_encode(identity(16), &encoded_database[last_entry], 14),
|
||||
"legacy database identity did not encode");
|
||||
encoded_database[last_entry + 14] = count - 1;
|
||||
encoded_database[last_entry + 15] = 1;
|
||||
for (uint8_t index = 0; index < count; ++index) {
|
||||
memcpy(&encoded_database[last_entry + 16 + index * 256],
|
||||
kLegacyCustomThresholdProfile, 256);
|
||||
}
|
||||
require(controller_profile_database_decode(
|
||||
read_encoded_database, nullptr, &decoded_database),
|
||||
"legacy database strides were not preserved");
|
||||
const ControllerProfileDatabaseEntry* entry =
|
||||
controller_profile_database_find(decoded_database, identity(16));
|
||||
require(entry != nullptr && entry->active_profile == count - 1 &&
|
||||
entry->profiles[count - 1].triggers[1].digital_threshold == 0xabcd,
|
||||
"last legacy bank profile was read from the wrong offset");
|
||||
if (count == 4) {
|
||||
require(entry->profiles[7].triggers[0].digital_threshold ==
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD,
|
||||
"new profile slots were not defaulted during migration");
|
||||
}
|
||||
encoded_database[4] = 3;
|
||||
require(!controller_profile_database_decode(
|
||||
read_encoded_database, nullptr, &decoded_database),
|
||||
"current database version accepted legacy strides");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
int main() {
|
||||
test_profile_wire_schema();
|
||||
test_legacy_profile_migration();
|
||||
test_database_round_trip_and_capacity();
|
||||
test_set_b_sparse_extension_and_migration();
|
||||
test_legacy_database_strides();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ uint16_t transform_left_trigger(
|
|||
uint16_t value) {
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.triggers[0] = configuration;
|
||||
profile.triggers[0].output =
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
ControllerState state{};
|
||||
state.left_trigger = value;
|
||||
return controller_profile_transform(state, profile).state.left_trigger;
|
||||
|
|
@ -107,9 +109,9 @@ void test_button_masks_and_direct_mapping() {
|
|||
"disabled button mapping still produced output");
|
||||
|
||||
ControllerProfile invalid = default_profile();
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT;
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"logical output 16 was accepted");
|
||||
"logical output 18 was accepted");
|
||||
invalid.button_map[0] = 0xfe;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"logical output 0xfe was accepted");
|
||||
|
|
@ -271,6 +273,8 @@ void test_trigger_boundaries_curves_and_thresholds() {
|
|||
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.triggers[0] = slow;
|
||||
profile.triggers[0].output =
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
profile.triggers[0].digital_threshold = 12345;
|
||||
profile.triggers[1].digital_threshold = 54321;
|
||||
ControllerState state{};
|
||||
|
|
@ -282,6 +286,41 @@ void test_trigger_boundaries_curves_and_thresholds() {
|
|||
"profile-owned digital thresholds were not returned");
|
||||
}
|
||||
|
||||
void test_trigger_and_button_cross_mapping() {
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.triggers[0].output =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth);
|
||||
profile.triggers[0].digital_threshold = 30000;
|
||||
profile.triggers[1].output =
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
profile.triggers[1].digital_threshold = 40000;
|
||||
profile.button_map[
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kEast)] =
|
||||
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
|
||||
|
||||
ControllerState input{};
|
||||
input.left_trigger = 29999;
|
||||
input.right_trigger = 45000;
|
||||
input.button_east = true;
|
||||
ControllerProfileTransformResult output =
|
||||
controller_profile_transform(input, profile);
|
||||
require(!output.state.button_south &&
|
||||
output.state.left_trigger == 45000 &&
|
||||
output.state.right_trigger == UINT16_MAX &&
|
||||
output.left_trigger_digital_threshold == 40000,
|
||||
"cross-mapped triggers lost analog or button output");
|
||||
|
||||
input.left_trigger = 30000;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.button_south,
|
||||
"trigger threshold did not produce its mapped button");
|
||||
|
||||
profile.triggers[0].output =
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
require(!controller_profile_validate(profile),
|
||||
"two analog triggers targeting one output were accepted");
|
||||
}
|
||||
|
||||
void test_default_whole_state_equivalence() {
|
||||
ControllerState input{};
|
||||
input.dpad_up = true;
|
||||
|
|
@ -365,6 +404,28 @@ void test_rumble_scaling_and_confirmation_policy() {
|
|||
UINT8_MAX,
|
||||
"full rumble scaling did not saturate at uint8 maximum");
|
||||
|
||||
ControllerRumbleOutput hd_input{};
|
||||
hd_input.hd.actuators[0].sample_count = 2;
|
||||
hd_input.hd.actuators[0].samples[0] = {48, 96, 18000, 10000};
|
||||
hd_input.hd.actuators[0].samples[1] = {49, 97, 31000, 0};
|
||||
hd_input.hd.actuators[1].sample_count = 1;
|
||||
hd_input.hd.actuators[1].samples[0] = {20, 70, 12000, 10000};
|
||||
ControllerProfile hd_profile = profile;
|
||||
hd_profile.strong_rumble_scale = 0;
|
||||
hd_profile.weak_rumble_scale = 128;
|
||||
const auto hd_output = controller_profile_scale_host_rumble(hd_input, hd_profile);
|
||||
require(hd_output.hd.actuators[0].sample_count == 2 &&
|
||||
hd_output.hd.actuators[1].sample_count == 1 &&
|
||||
hd_output.hd.actuators[0].samples[1].low_frequency_index == 49 &&
|
||||
hd_output.hd.actuators[1].samples[0].high_frequency_index == 70,
|
||||
"profile gain lost HD substeps or side-specific frequencies");
|
||||
require(hd_output.hd.actuators[0].samples[0].low_amplitude_q15 == 0 &&
|
||||
hd_output.hd.actuators[1].samples[0].low_amplitude_q15 == 0 &&
|
||||
hd_output.hd.actuators[0].samples[0].high_amplitude_q15 == 5020 &&
|
||||
hd_output.hd.actuators[1].samples[0].high_amplitude_q15 == 5020 &&
|
||||
hd_output.hd.actuators[0].samples[1].high_amplitude_q15 == 0,
|
||||
"profile band gains were not applied to linear HD amplitudes");
|
||||
|
||||
profile.confirmation_policy = ControllerProfileConfirmationPolicy::kLed;
|
||||
require(controller_profile_confirmation_policy(profile) ==
|
||||
ControllerProfileConfirmationPolicy::kLed,
|
||||
|
|
@ -378,6 +439,7 @@ int main() {
|
|||
test_stick_center_boundaries_and_inversion();
|
||||
test_stick_curves_and_monotonicity();
|
||||
test_trigger_boundaries_curves_and_thresholds();
|
||||
test_trigger_and_button_cross_mapping();
|
||||
test_default_whole_state_equivalence();
|
||||
test_rumble_scaling_and_confirmation_policy();
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -39,36 +39,28 @@ ControllerProfile profile_with_macro(
|
|||
ControllerProfileLogicalButton::kCapture) {
|
||||
ControllerProfile profile =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
profile.macro_trigger = button_index(trigger);
|
||||
profile.macro_cancel = button_index(cancel);
|
||||
profile.macros[0].trigger_mask = button_bit(trigger);
|
||||
profile.macros[0].cancel_control = button_index(cancel);
|
||||
profile.macros[0].first_step = 0;
|
||||
return profile;
|
||||
}
|
||||
|
||||
void set_end(ControllerProfile* profile, uint8_t index) {
|
||||
profile->macro_steps[index] = {};
|
||||
profile->macro_steps[index].type =
|
||||
ControllerProfileMacroStepType::kEnd;
|
||||
}
|
||||
|
||||
void test_immediate_press_release_dpad_and_explicit_end() {
|
||||
ControllerProfile profile =
|
||||
profile_with_macro(ControllerProfileLogicalButton::kSouth);
|
||||
profile.macro_step_count = 3;
|
||||
profile.macro_steps[0].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
profile.macro_step_count = 2;
|
||||
profile.macros[0].step_count = 2;
|
||||
profile.macro_steps[0].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[0].duration_ms = 10;
|
||||
profile.macro_steps[0].output_button_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kNorth) |
|
||||
button_bit(ControllerProfileLogicalButton::kDpadUp);
|
||||
profile.macro_steps[1].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
profile.macro_steps[1].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[1].duration_ms = 20;
|
||||
profile.macro_steps[1].output_button_mask = 0;
|
||||
set_end(&profile, 2);
|
||||
|
||||
ControllerSyntheticInputContext context{};
|
||||
ControllerState input =
|
||||
|
|
@ -100,12 +92,58 @@ void test_immediate_press_release_dpad_and_explicit_end() {
|
|||
"explicit end did not clear overrides and restore physical input");
|
||||
}
|
||||
|
||||
void test_macro_trigger_chord_requires_every_button() {
|
||||
ControllerProfile profile =
|
||||
profile_with_macro(ControllerProfileLogicalButton::kSouth);
|
||||
profile.macros[0].trigger_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kSouth) |
|
||||
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
|
||||
profile.macros[0].cancel_control =
|
||||
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
|
||||
profile.macro_step_count = 1;
|
||||
profile.macros[0].step_count = 1;
|
||||
profile.macro_steps[0].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[0].duration_ms = 100;
|
||||
profile.macro_steps[0].output_button_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kNorth);
|
||||
|
||||
ControllerSyntheticInputContext context{};
|
||||
ControllerState input =
|
||||
state_with_buttons(
|
||||
button_bit(ControllerProfileLogicalButton::kSouth));
|
||||
ControllerProfileTransformResult output =
|
||||
controller_synthetic_input_apply(&context, input, profile, 0);
|
||||
require(!context.macro_active &&
|
||||
has_button(output, ControllerProfileLogicalButton::kSouth),
|
||||
"partial macro chord triggered or consumed a normal button");
|
||||
|
||||
input = state_with_buttons(
|
||||
button_bit(ControllerProfileLogicalButton::kSouth));
|
||||
input.left_trigger = UINT16_MAX;
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, input, profile, 1);
|
||||
require(context.macro_active &&
|
||||
has_button(output, ControllerProfileLogicalButton::kNorth) &&
|
||||
!has_button(output, ControllerProfileLogicalButton::kSouth) &&
|
||||
output.state.left_trigger == 0,
|
||||
"completed trigger-backed macro chord did not consume inputs");
|
||||
|
||||
input = controller_neutral_state();
|
||||
input.right_trigger = UINT16_MAX;
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, input, profile, 2);
|
||||
require(!context.macro_active &&
|
||||
output.state.right_trigger == 0,
|
||||
"trigger-backed macro cancellation leaked or stayed active");
|
||||
}
|
||||
|
||||
void test_optional_field_overrides_and_motion_preservation() {
|
||||
ControllerProfile profile =
|
||||
profile_with_macro(ControllerProfileLogicalButton::kSelect);
|
||||
profile.macro_step_count = 2;
|
||||
profile.macro_step_count = 1;
|
||||
profile.macros[0].step_count = 1;
|
||||
ControllerProfileMacroStep& step = profile.macro_steps[0];
|
||||
step.type = ControllerProfileMacroStepType::kState;
|
||||
step.override_flags = kControllerProfileOverrideLeftStick |
|
||||
kControllerProfileOverrideRightStick |
|
||||
kControllerProfileOverrideLeftTrigger |
|
||||
|
|
@ -117,7 +155,6 @@ void test_optional_field_overrides_and_motion_preservation() {
|
|||
step.right_stick_y = INT16_MAX;
|
||||
step.left_trigger = 0;
|
||||
step.right_trigger = UINT16_MAX;
|
||||
set_end(&profile, 1);
|
||||
|
||||
ControllerState input = state_with_buttons(
|
||||
button_bit(ControllerProfileLogicalButton::kSelect) |
|
||||
|
|
@ -155,29 +192,23 @@ void test_optional_field_overrides_and_motion_preservation() {
|
|||
void test_zero_max_wait_and_scheduled_catch_up() {
|
||||
ControllerProfile profile =
|
||||
profile_with_macro(ControllerProfileLogicalButton::kSouth);
|
||||
profile.macro_step_count = 4;
|
||||
profile.macro_steps[0].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
profile.macro_step_count = 3;
|
||||
profile.macros[0].step_count = 3;
|
||||
profile.macro_steps[0].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[0].duration_ms = 0;
|
||||
profile.macro_steps[0].output_button_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kNorth);
|
||||
profile.macro_steps[1].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
profile.macro_steps[1].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[1].duration_ms = CONTROLLER_PROFILE_MAX_WAIT_MS;
|
||||
profile.macro_steps[1].output_button_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kEast);
|
||||
profile.macro_steps[2].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
profile.macro_steps[2].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[2].duration_ms = 0;
|
||||
profile.macro_steps[2].output_button_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kWest);
|
||||
set_end(&profile, 3);
|
||||
|
||||
ControllerSyntheticInputContext context{};
|
||||
ControllerProfileTransformResult output =
|
||||
|
|
@ -204,7 +235,8 @@ void test_zero_max_wait_and_scheduled_catch_up() {
|
|||
!context.macro_active,
|
||||
"zero-duration catch-up did not reach the explicit end");
|
||||
|
||||
profile.macro_step_count = 4;
|
||||
profile.macro_step_count = 3;
|
||||
profile.macros[0].step_count = 3;
|
||||
profile.macro_steps[0].duration_ms = 10;
|
||||
profile.macro_steps[0].output_button_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kNorth);
|
||||
|
|
@ -229,11 +261,11 @@ void test_zero_max_wait_and_scheduled_catch_up() {
|
|||
controller_profile_extract_button_mask(output.state) == 0,
|
||||
"large time jump did not finish the bounded macro");
|
||||
|
||||
profile.macro_step_count = 2;
|
||||
profile.macro_step_count = 1;
|
||||
profile.macros[0].step_count = 1;
|
||||
profile.macro_steps[0].duration_ms = 10;
|
||||
profile.macro_steps[0].output_button_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kNorth);
|
||||
set_end(&profile, 1);
|
||||
context = {};
|
||||
constexpr uint32_t macro_near_wrap = UINT32_MAX - 5u;
|
||||
output = controller_synthetic_input_apply(
|
||||
|
|
@ -257,15 +289,13 @@ void test_consumption_cancel_precedence_and_duplicate_contributors() {
|
|||
ControllerProfile profile =
|
||||
profile_with_macro(ControllerProfileLogicalButton::kSelect,
|
||||
ControllerProfileLogicalButton::kCapture);
|
||||
profile.macro_step_count = 2;
|
||||
profile.macro_steps[0].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
profile.macro_step_count = 1;
|
||||
profile.macros[0].step_count = 1;
|
||||
profile.macro_steps[0].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[0].duration_ms = 100;
|
||||
profile.macro_steps[0].output_button_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kDpadLeft);
|
||||
set_end(&profile, 1);
|
||||
profile.button_map[button_index(ControllerProfileLogicalButton::kSouth)] =
|
||||
button_index(ControllerProfileLogicalButton::kNorth);
|
||||
profile.button_map[button_index(ControllerProfileLogicalButton::kEast)] =
|
||||
|
|
@ -421,6 +451,53 @@ void test_auto_burst_toggle_cancel_and_external_cancel() {
|
|||
"Auto Burst did not restart after release following cancellation");
|
||||
}
|
||||
|
||||
void test_multiple_macro_bindings_share_step_pool() {
|
||||
ControllerProfile profile =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
profile.macros[0] = {
|
||||
button_bit(ControllerProfileLogicalButton::kSouth),
|
||||
CONTROLLER_PROFILE_NO_BUTTON, 0, 1};
|
||||
profile.macros[1] = {
|
||||
button_bit(ControllerProfileLogicalButton::kEast),
|
||||
CONTROLLER_PROFILE_NO_BUTTON, 1, 1};
|
||||
profile.macros[2].first_step = 2;
|
||||
profile.macros[3].first_step = 2;
|
||||
profile.macro_step_count = 2;
|
||||
profile.macro_steps[0].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[0].duration_ms = 1;
|
||||
profile.macro_steps[0].output_button_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kNorth);
|
||||
profile.macro_steps[1].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[1].duration_ms = 1;
|
||||
profile.macro_steps[1].output_button_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kWest);
|
||||
|
||||
ControllerSyntheticInputContext context{};
|
||||
ControllerProfileTransformResult output =
|
||||
controller_synthetic_input_apply(
|
||||
&context,
|
||||
state_with_buttons(
|
||||
button_bit(ControllerProfileLogicalButton::kEast)),
|
||||
profile, 0);
|
||||
require(context.macro_active && context.macro_index == 1 &&
|
||||
has_button(output, ControllerProfileLogicalButton::kWest),
|
||||
"second macro descriptor did not execute its shared step");
|
||||
(void)controller_synthetic_input_apply(
|
||||
&context, controller_neutral_state(), profile, 1);
|
||||
(void)controller_synthetic_input_apply(
|
||||
&context, controller_neutral_state(), profile, 2);
|
||||
output = controller_synthetic_input_apply(
|
||||
&context,
|
||||
state_with_buttons(
|
||||
button_bit(ControllerProfileLogicalButton::kSouth)),
|
||||
profile, 3);
|
||||
require(context.macro_active && context.macro_index == 0 &&
|
||||
has_button(output, ControllerProfileLogicalButton::kNorth),
|
||||
"first macro descriptor did not execute independently");
|
||||
}
|
||||
|
||||
void test_four_contexts_are_isolated() {
|
||||
ControllerProfile profile =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
|
|
@ -450,15 +527,231 @@ void test_four_contexts_are_isolated() {
|
|||
"cancelling one slot changed another slot's Auto Burst state");
|
||||
}
|
||||
|
||||
void test_parameterized_turbo_and_finite_burst() {
|
||||
ControllerProfile profile =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
profile.turbo_defaults = {10, 25, 3};
|
||||
profile.turbo_modes[0] = ControllerProfileTurboMode::kBurst;
|
||||
profile.turbo_modes[1] = ControllerProfileTurboMode::kTurbo;
|
||||
profile.turbo_override_mask = 1u << 1;
|
||||
profile.turbo_overrides[1] = {20, 80, 1};
|
||||
profile.button_map[0] = 3;
|
||||
profile.button_map[1] = 2;
|
||||
ControllerSyntheticInputContext context{};
|
||||
const ControllerState pressed = state_with_buttons(3);
|
||||
auto output = controller_synthetic_input_apply(&context, pressed, profile, 0);
|
||||
require(output.state.button_north && output.state.button_west,
|
||||
"parameterized bindings did not start immediately");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 24);
|
||||
require(output.state.button_north && output.state.button_west,
|
||||
"duty window ended early");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 25);
|
||||
require(!output.state.button_north && output.state.button_west,
|
||||
"defaults or physical-source override selected the wrong duty");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 40);
|
||||
require(!output.state.button_north && !output.state.button_west,
|
||||
"override duty boundary remained ON");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 50);
|
||||
require(!output.state.button_north && output.state.button_west,
|
||||
"override period followed the default rate");
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, controller_neutral_state(), profile, 100);
|
||||
require(output.state.button_north && !output.state.button_west,
|
||||
"finite Burst release cancelled its windows or hold Turbo latched");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 101);
|
||||
require(output.state.button_north, "fresh Burst press did not restart");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 325);
|
||||
require(output.state.button_north, "third Burst ON window ended early");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 326);
|
||||
require(!output.state.button_north, "Burst did not stop at final ON end");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 401);
|
||||
require(!output.state.button_north, "held completed Burst rearmed itself");
|
||||
|
||||
context = {};
|
||||
profile.turbo_defaults.burst_count = 1;
|
||||
constexpr uint32_t start = UINT32_MAX - 10u;
|
||||
(void)controller_synthetic_input_apply(&context, pressed, profile, start);
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 13);
|
||||
require(output.state.button_north, "one-window Burst ended early at wrap");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 14);
|
||||
require(!output.state.button_north, "one-window Burst crossed its deadline");
|
||||
context = {};
|
||||
profile.turbo_defaults = {1, 99, 255};
|
||||
(void)controller_synthetic_input_apply(&context, pressed, profile, 0);
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 254989);
|
||||
require(output.state.button_north, "maximum Burst lost its final window");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 254990);
|
||||
require(!output.state.button_north, "maximum Burst count overflowed");
|
||||
context = {};
|
||||
(void)controller_synthetic_input_apply(&context, pressed, profile, 0);
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, pressed, profile, 4000000000u);
|
||||
require(!output.state.button_north && output.state.button_west,
|
||||
"long-gap catch-up replayed missed Burst pulses or overflowed Turbo");
|
||||
context = {};
|
||||
profile.turbo_modes[0] = ControllerProfileTurboMode::kTurbo;
|
||||
profile.turbo_defaults = {30, 1, 1};
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 0);
|
||||
require(output.state.button_north, "narrow Turbo duty did not start ON");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 1);
|
||||
require(!output.state.button_north,
|
||||
"narrow duty was silently clamped to the polling cadence");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 100);
|
||||
require(output.state.button_north, "narrow duty lost its absolute cycle phase");
|
||||
context = {};
|
||||
profile.turbo_defaults = {30, 99, 1};
|
||||
(void)controller_synthetic_input_apply(&context, pressed, profile, 0);
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 33);
|
||||
require(!output.state.button_north, "99-percent duty included its OFF boundary");
|
||||
output = controller_synthetic_input_apply(&context, pressed, profile, 34);
|
||||
require(output.state.button_north, "fractional period accumulated polling drift");
|
||||
}
|
||||
|
||||
void test_shift_maps_consumption_and_physical_bindings() {
|
||||
ControllerProfile profile =
|
||||
profile_with_macro(ControllerProfileLogicalButton::kSelect);
|
||||
profile.shift.mode = ControllerProfileShiftMode::kHold;
|
||||
profile.shift.modifier = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
profile.shift.button_map[0] = 3;
|
||||
profile.button_map[0] = CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
|
||||
profile.sticks[0].invert_x = true;
|
||||
profile.triggers[1].lower_deadzone = 1000;
|
||||
profile.turbo_modes[0] = ControllerProfileTurboMode::kTurbo;
|
||||
profile.turbo_defaults = {10, 20, 3};
|
||||
ControllerState input = state_with_buttons(1);
|
||||
input.left_trigger = UINT16_MAX;
|
||||
input.right_trigger = 32768;
|
||||
input.left_stick_x = 12345;
|
||||
const auto base = controller_profile_transform(input, profile);
|
||||
ControllerSyntheticInputContext context{};
|
||||
auto output = controller_synthetic_input_apply(&context, input, profile, 0);
|
||||
require(output.state.button_north && !output.state.button_south &&
|
||||
output.state.left_trigger == 0 &&
|
||||
output.state.right_trigger < UINT16_MAX &&
|
||||
output.state.left_stick_x == base.state.left_stick_x,
|
||||
"Shift did not consume analog modifier or replaced base analog tuning");
|
||||
const uint16_t tuned_trigger = output.state.right_trigger;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 20);
|
||||
require(!output.state.button_north &&
|
||||
output.state.right_trigger == tuned_trigger,
|
||||
"Shift moved Turbo settings to the mapped output");
|
||||
input.left_trigger = 0;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 100);
|
||||
require(!output.state.button_north &&
|
||||
output.state.right_trigger == UINT16_MAX,
|
||||
"releasing hold Shift did not restore base button-to-trigger mapping");
|
||||
|
||||
context = {};
|
||||
profile.shift.mode = ControllerProfileShiftMode::kToggle;
|
||||
profile.shift.modifier = button_index(ControllerProfileLogicalButton::kSelect);
|
||||
profile.turbo_modes[0] = ControllerProfileTurboMode::kOff;
|
||||
profile.macros[0].step_count = profile.macro_step_count = 1;
|
||||
profile.macro_steps[0] = {kControllerProfileOverrideButtons, 100, 2};
|
||||
input = state_with_buttons(1u | (1u << profile.shift.modifier));
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 0);
|
||||
require(output.state.button_north && !output.state.button_select &&
|
||||
!output.state.button_east,
|
||||
"Shift modifier leaked into mapping or its competing macro");
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 1);
|
||||
require(output.state.button_north, "held toggle Shift retriggered");
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, state_with_buttons(1), profile, 2);
|
||||
require(output.state.button_north, "toggle Shift did not latch after release");
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 3);
|
||||
require(!output.state.button_north && output.state.right_trigger == UINT16_MAX,
|
||||
"second Shift rising edge did not restore base mapping");
|
||||
controller_synthetic_input_cancel(
|
||||
&context, controller_profile_extract_control_mask(input, profile));
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 4);
|
||||
require(!output.state.button_north, "cancel phantom-toggled a held modifier");
|
||||
(void)controller_synthetic_input_apply(
|
||||
&context, controller_neutral_state(), profile, 5);
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 6, 0, true);
|
||||
require(!output.state.button_north, "winning hotkey did not suppress Shift");
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 7);
|
||||
require(!output.state.button_north, "suppression release phantom-toggled Shift");
|
||||
}
|
||||
|
||||
void test_macro_playback_modes_and_bounded_cycle_skips() {
|
||||
ControllerProfile profile =
|
||||
profile_with_macro(ControllerProfileLogicalButton::kSouth);
|
||||
profile.macros[0].trigger_mask |= 1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
profile.macros[0].step_count = profile.macro_step_count = 3;
|
||||
profile.macro_steps[0] = {kControllerProfileOverrideButtons, 10, 8};
|
||||
profile.macro_steps[1] = {kControllerProfileOverrideButtons, 0, 2};
|
||||
profile.macro_steps[2] = {kControllerProfileOverrideButtons, 20, 4};
|
||||
ControllerState held = state_with_buttons(1);
|
||||
held.left_trigger = UINT16_MAX;
|
||||
ControllerSyntheticInputContext context{};
|
||||
profile.macros[0].mode = ControllerProfileMacroMode::kWhileHeld;
|
||||
auto output = controller_synthetic_input_apply(&context, held, profile, 0);
|
||||
require(output.state.button_north && output.state.left_trigger == 0,
|
||||
"while-held macro did not consume its entire physical trigger");
|
||||
output = controller_synthetic_input_apply(&context, held, profile, 10);
|
||||
require(output.state.button_west && !output.state.button_east,
|
||||
"zero-duration interior step delayed the next timed state");
|
||||
output = controller_synthetic_input_apply(&context, held, profile, 30);
|
||||
require(output.state.button_north, "while-held macro did not repeat");
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, state_with_buttons(1), profile, 31);
|
||||
require(!output.state.button_north && !output.state.button_west,
|
||||
"partial trigger release did not stop while-held playback");
|
||||
output = controller_synthetic_input_apply(&context, held, profile, 32);
|
||||
require(output.state.button_north, "completed trigger did not rearm playback");
|
||||
|
||||
context = {};
|
||||
profile.macros[0].mode = ControllerProfileMacroMode::kRepeat;
|
||||
profile.macros[0].repeat_count = 3;
|
||||
(void)controller_synthetic_input_apply(&context, held, profile, 0);
|
||||
output = controller_synthetic_input_apply(&context, held, profile, 89);
|
||||
require(output.state.button_west, "repeat count omitted its last cycle");
|
||||
output = controller_synthetic_input_apply(&context, held, profile, 90);
|
||||
require(!output.state.button_west && !output.state.button_north,
|
||||
"finite macro repeated past its exact cycle count");
|
||||
output = controller_synthetic_input_apply(&context, held, profile, 120);
|
||||
require(!output.state.button_west && !output.state.button_north,
|
||||
"held finite macro phantom-restarted after completing");
|
||||
|
||||
context = {};
|
||||
profile.macros[0].mode = ControllerProfileMacroMode::kToggle;
|
||||
constexpr uint32_t start = UINT32_MAX - 5u;
|
||||
(void)controller_synthetic_input_apply(&context, held, profile, start);
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, controller_neutral_state(), profile, 4);
|
||||
require(output.state.button_west, "toggle macro lost phase over clock wrap");
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, controller_neutral_state(), profile, 3999999994u);
|
||||
require(output.state.button_west,
|
||||
"long-gap toggle macro overflowed or iterated missed cycles");
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, held, profile, 3999999995u);
|
||||
require(!output.state.button_north && !output.state.button_west &&
|
||||
output.state.left_trigger == 0,
|
||||
"matching rising trigger did not toggle active macro off");
|
||||
(void)controller_synthetic_input_apply(
|
||||
&context, controller_neutral_state(), profile, 3999999996u);
|
||||
(void)controller_synthetic_input_apply(
|
||||
&context, held, profile, 3999999997u);
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, state_with_buttons(1u << 9), profile, 3999999998u);
|
||||
require(controller_profile_extract_button_mask(output.state) == 0,
|
||||
"configured cancel did not kill looping macro output");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_immediate_press_release_dpad_and_explicit_end();
|
||||
test_macro_trigger_chord_requires_every_button();
|
||||
test_optional_field_overrides_and_motion_preservation();
|
||||
test_zero_max_wait_and_scheduled_catch_up();
|
||||
test_consumption_cancel_precedence_and_duplicate_contributors();
|
||||
test_turbo_rate_release_and_uint32_wrap();
|
||||
test_multiple_macro_bindings_share_step_pool();
|
||||
test_auto_burst_toggle_cancel_and_external_cancel();
|
||||
test_four_contexts_are_isolated();
|
||||
test_parameterized_turbo_and_finite_burst();
|
||||
test_shift_maps_consumption_and_physical_bindings();
|
||||
test_macro_playback_modes_and_bounded_cycle_skips();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* Subset of the Pico SDK's exposed shared-bus ABI used by this transport. */
|
||||
#define BTSDIO_FWBUF_SIZE 0x1000
|
||||
|
||||
typedef enum {
|
||||
CYBT_SUCCESS = 0,
|
||||
CYBT_ERR_BADARG = 0xB1,
|
||||
CYBT_ERR_TIMEOUT = 0xB3,
|
||||
CYBT_ERR_HCI_READ_FAILED = 0xB9,
|
||||
} cybt_result_t;
|
||||
|
||||
typedef enum {
|
||||
H2B_BUF_ADDR_IDX = 0x10,
|
||||
H2B_BUF_IN_ADDR_IDX,
|
||||
H2B_BUF_OUT_ADDR_IDX,
|
||||
B2H_BUF_ADDR_IDX,
|
||||
B2H_BUF_IN_ADDR_IDX,
|
||||
B2H_BUF_OUT_ADDR_IDX,
|
||||
} cybt_addr_idx_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t host2bt_in_val;
|
||||
uint32_t host2bt_out_val;
|
||||
uint32_t bt2host_in_val;
|
||||
uint32_t bt2host_out_val;
|
||||
} cybt_fw_membuf_index_t;
|
||||
|
||||
cybt_result_t cybt_set_bt_awake(int value);
|
||||
int cybt_awake(void);
|
||||
cybt_result_t cybt_get_bt_buf_index(cybt_fw_membuf_index_t *indices);
|
||||
cybt_result_t cybt_mem_read_idx(cybt_addr_idx_t index, uint32_t offset,
|
||||
uint8_t *buffer, uint32_t length);
|
||||
cybt_result_t cybt_reg_write_idx(cybt_addr_idx_t index, uint32_t value);
|
||||
cybt_result_t cybt_toggle_bt_intr(void);
|
||||
10
tests/cyw43_packet_transport_native_stubs/cyw43_btbus.h
Normal file
10
tests/cyw43_packet_transport_native_stubs/cyw43_btbus.h
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct _cyw43_ll_t {
|
||||
int unused;
|
||||
} cyw43_ll_t;
|
||||
|
||||
int cyw43_btbus_init(cyw43_ll_t *self);
|
||||
int cyw43_btbus_read(uint8_t *buffer, uint32_t capacity, uint32_t *size);
|
||||
11
tests/cyw43_packet_transport_native_stubs/cyw43_config.h
Normal file
11
tests/cyw43_packet_transport_native_stubs/cyw43_config.h
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
void cyw43_thread_enter(void);
|
||||
void cyw43_thread_exit(void);
|
||||
void cyw43_delay_ms(uint32_t milliseconds);
|
||||
_Noreturn void panic(const char *message, ...);
|
||||
|
||||
#define CYW43_THREAD_ENTER cyw43_thread_enter();
|
||||
#define CYW43_THREAD_EXIT cyw43_thread_exit();
|
||||
8
tests/cyw43_packet_transport_native_stubs/sdk.c
Normal file
8
tests/cyw43_packet_transport_native_stubs/sdk.c
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#include "cyw43_btbus.h"
|
||||
|
||||
int test_cyw43_btbus_init(cyw43_ll_t *self);
|
||||
|
||||
/* Separate translation unit: GNU --wrap only redirects unresolved references. */
|
||||
int cyw43_btbus_init(cyw43_ll_t *self) {
|
||||
return test_cyw43_btbus_init(self);
|
||||
}
|
||||
409
tests/cyw43_packet_transport_test.c
Normal file
409
tests/cyw43_packet_transport_test.c
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
#include <assert.h>
|
||||
#include <setjmp.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cyw43_btbus.h"
|
||||
#include "cyw43_config.h"
|
||||
#include "cybt_shared_bus_driver.h"
|
||||
|
||||
static uint8_t ring[BTSDIO_FWBUF_SIZE];
|
||||
static cybt_fw_membuf_index_t indices;
|
||||
static unsigned lock_depth;
|
||||
static unsigned enters;
|
||||
static unsigned exits;
|
||||
static unsigned wake_polls;
|
||||
static unsigned delays;
|
||||
static unsigned snapshots;
|
||||
static unsigned reads;
|
||||
static unsigned publications;
|
||||
static unsigned notifications;
|
||||
static unsigned fail_read;
|
||||
static bool fail_wake;
|
||||
static bool fail_snapshot;
|
||||
static bool fail_publication;
|
||||
static bool fail_notification;
|
||||
static int awake_value;
|
||||
static bool append_on_header;
|
||||
static bool expect_panic;
|
||||
static jmp_buf panic_jump;
|
||||
|
||||
void cyw43_thread_enter(void) {
|
||||
++lock_depth;
|
||||
++enters;
|
||||
}
|
||||
|
||||
void cyw43_thread_exit(void) {
|
||||
assert(lock_depth != 0);
|
||||
--lock_depth;
|
||||
++exits;
|
||||
}
|
||||
|
||||
void cyw43_delay_ms(uint32_t milliseconds) {
|
||||
assert(lock_depth != 0);
|
||||
assert(milliseconds == 1);
|
||||
++delays;
|
||||
}
|
||||
|
||||
_Noreturn void panic(const char *message, ...) {
|
||||
if (!expect_panic) {
|
||||
fprintf(stderr, "unexpected panic: %s\n", message);
|
||||
abort();
|
||||
}
|
||||
assert(strcmp(message, "cyw43 buffer overflow") == 0);
|
||||
longjmp(panic_jump, 1);
|
||||
}
|
||||
|
||||
int test_cyw43_btbus_init(cyw43_ll_t *self) {
|
||||
(void)self;
|
||||
assert(lock_depth != 0);
|
||||
memset(&indices, 0, sizeof(indices));
|
||||
return 0;
|
||||
}
|
||||
|
||||
cybt_result_t cybt_set_bt_awake(int value) {
|
||||
assert(lock_depth != 0);
|
||||
assert(value == 1);
|
||||
return fail_wake ? CYBT_ERR_HCI_READ_FAILED : CYBT_SUCCESS;
|
||||
}
|
||||
|
||||
int cybt_awake(void) {
|
||||
assert(lock_depth != 0);
|
||||
++wake_polls;
|
||||
return awake_value;
|
||||
}
|
||||
|
||||
cybt_result_t cybt_get_bt_buf_index(cybt_fw_membuf_index_t *out) {
|
||||
assert(lock_depth != 0);
|
||||
++snapshots;
|
||||
if (fail_snapshot) {
|
||||
return CYBT_ERR_HCI_READ_FAILED;
|
||||
}
|
||||
*out = indices;
|
||||
return CYBT_SUCCESS;
|
||||
}
|
||||
|
||||
static uint32_t put_packet(uint32_t offset, uint32_t payload_length, uint8_t type) {
|
||||
uint32_t wire_length = 4 + ((payload_length + 3) & ~3u);
|
||||
assert(wire_length <= BTSDIO_FWBUF_SIZE);
|
||||
for (uint32_t byte = 0; byte < wire_length; ++byte) {
|
||||
uint8_t value = (uint8_t)(byte - 4);
|
||||
if (byte < 3) {
|
||||
value = (uint8_t)(payload_length >> (byte * 8));
|
||||
} else if (byte == 3) {
|
||||
value = type;
|
||||
}
|
||||
ring[(offset + byte) & (BTSDIO_FWBUF_SIZE - 1)] = value;
|
||||
}
|
||||
return (offset + wire_length) & (BTSDIO_FWBUF_SIZE - 1);
|
||||
}
|
||||
|
||||
cybt_result_t cybt_mem_read_idx(cybt_addr_idx_t index, uint32_t offset,
|
||||
uint8_t *buffer, uint32_t length) {
|
||||
assert(lock_depth != 0);
|
||||
assert(index == B2H_BUF_ADDR_IDX);
|
||||
assert(length >= 4 && (length & 3) == 0);
|
||||
assert((offset & 3) == 0 && offset + length <= BTSDIO_FWBUF_SIZE);
|
||||
assert(((uintptr_t)buffer & 3) == 0);
|
||||
++reads;
|
||||
if (reads == fail_read) {
|
||||
/* Model a failed SPI transfer that has already modified its destination. */
|
||||
buffer[0] = 0xEF;
|
||||
return CYBT_ERR_HCI_READ_FAILED;
|
||||
}
|
||||
memcpy(buffer, ring + offset, length);
|
||||
if (append_on_header && reads == 1) {
|
||||
indices.bt2host_in_val = put_packet(indices.bt2host_in_val, 4, 2);
|
||||
}
|
||||
return CYBT_SUCCESS;
|
||||
}
|
||||
|
||||
cybt_result_t cybt_reg_write_idx(cybt_addr_idx_t index, uint32_t value) {
|
||||
assert(lock_depth != 0);
|
||||
assert(index == B2H_BUF_OUT_ADDR_IDX);
|
||||
assert(value < BTSDIO_FWBUF_SIZE && (value & 3) == 0);
|
||||
++publications;
|
||||
if (fail_publication) {
|
||||
return CYBT_ERR_HCI_READ_FAILED;
|
||||
}
|
||||
indices.bt2host_out_val = value;
|
||||
return CYBT_SUCCESS;
|
||||
}
|
||||
|
||||
cybt_result_t cybt_toggle_bt_intr(void) {
|
||||
assert(lock_depth != 0);
|
||||
assert(publications > notifications);
|
||||
++notifications;
|
||||
return fail_notification ? CYBT_ERR_HCI_READ_FAILED : CYBT_SUCCESS;
|
||||
}
|
||||
|
||||
static void reset(void) {
|
||||
lock_depth = 0;
|
||||
expect_panic = false;
|
||||
assert(cyw43_btbus_init(NULL) == 0);
|
||||
memset(ring, 0, sizeof(ring));
|
||||
enters = exits = wake_polls = delays = snapshots = reads = publications = notifications = 0;
|
||||
fail_read = 0;
|
||||
fail_wake = fail_snapshot = fail_publication = fail_notification = false;
|
||||
append_on_header = false;
|
||||
awake_value = 1;
|
||||
}
|
||||
|
||||
static void check_unconsumed(uint32_t original_out) {
|
||||
assert(indices.bt2host_out_val == original_out);
|
||||
assert(notifications == 0);
|
||||
assert(lock_depth == 0 && enters == exits);
|
||||
}
|
||||
|
||||
static void check_packet(const uint8_t *buffer, uint32_t length, uint8_t type) {
|
||||
assert(buffer[0] == (uint8_t)length);
|
||||
assert(buffer[1] == (uint8_t)(length >> 8));
|
||||
assert(buffer[2] == (uint8_t)(length >> 16));
|
||||
assert(buffer[3] == type);
|
||||
for (uint32_t byte = 0; byte < length; ++byte) {
|
||||
assert(buffer[4 + byte] == (uint8_t)byte);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_empty_and_partial(void) {
|
||||
_Alignas(4) uint8_t buffer[32];
|
||||
uint32_t size = 123;
|
||||
reset();
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0);
|
||||
assert(snapshots == 1 && reads == 0 && publications == 0);
|
||||
check_unconsumed(0);
|
||||
|
||||
uint32_t end = put_packet(0, 5, 4);
|
||||
for (uint32_t published = 1; published <= 3; ++published) {
|
||||
indices.bt2host_in_val = published;
|
||||
size = 123;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0);
|
||||
assert(reads == 0 && publications == 0);
|
||||
check_unconsumed(0);
|
||||
}
|
||||
const uint32_t partial_lengths[] = {4, 8, 9, 11};
|
||||
for (unsigned i = 0; i < sizeof(partial_lengths) / sizeof(partial_lengths[0]); ++i) {
|
||||
indices.bt2host_in_val = partial_lengths[i];
|
||||
size = 123;
|
||||
unsigned before_reads = reads;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0);
|
||||
assert(reads == before_reads + 1 && publications == 0);
|
||||
check_unconsumed(0);
|
||||
}
|
||||
indices.bt2host_in_val = end;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9);
|
||||
check_packet(buffer, 5, 4);
|
||||
assert(indices.bt2host_out_val == end && publications == 1 && notifications == 1);
|
||||
}
|
||||
|
||||
static void test_complete_wrapped_and_full(void) {
|
||||
_Alignas(4) uint8_t buffer[BTSDIO_FWBUF_SIZE];
|
||||
const uint32_t starts[] = {0, BTSDIO_FWBUF_SIZE - 4, BTSDIO_FWBUF_SIZE - 8};
|
||||
for (unsigned i = 0; i < sizeof(starts) / sizeof(starts[0]); ++i) {
|
||||
reset();
|
||||
indices.bt2host_out_val = starts[i];
|
||||
indices.bt2host_in_val = put_packet(starts[i], 9, 2);
|
||||
uint32_t size = 0;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 13);
|
||||
check_packet(buffer, 9, 2);
|
||||
assert(indices.bt2host_out_val == indices.bt2host_in_val);
|
||||
assert(snapshots == 1 && publications == 1 && notifications == 1);
|
||||
assert(lock_depth == 0 && enters == exits);
|
||||
}
|
||||
|
||||
reset();
|
||||
indices.bt2host_in_val = put_packet(0, BTSDIO_FWBUF_SIZE - 8, 2);
|
||||
uint32_t size = 0;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0);
|
||||
assert(size == BTSDIO_FWBUF_SIZE - 4);
|
||||
check_packet(buffer, BTSDIO_FWBUF_SIZE - 8, 2);
|
||||
assert(indices.bt2host_out_val == indices.bt2host_in_val);
|
||||
|
||||
reset();
|
||||
indices.bt2host_in_val = put_packet(0, 0, 0);
|
||||
assert(cyw43_btbus_read(buffer, 4, &size) == 0 && size == 4);
|
||||
assert(reads == 1 && publications == 1 && notifications == 1);
|
||||
}
|
||||
|
||||
static void test_exact_capacity_and_snapshot_growth(void) {
|
||||
_Alignas(4) uint8_t buffer[32];
|
||||
uint32_t size = 0;
|
||||
reset();
|
||||
memset(buffer, 0xA5, sizeof(buffer));
|
||||
indices.bt2host_in_val = put_packet(0, 5, 4);
|
||||
assert(cyw43_btbus_read(buffer, 9, &size) == 0 && size == 9);
|
||||
check_packet(buffer, 5, 4);
|
||||
for (unsigned i = 9; i < sizeof(buffer); ++i) {
|
||||
assert(buffer[i] == 0xA5);
|
||||
}
|
||||
|
||||
reset();
|
||||
indices.bt2host_in_val = put_packet(0, 5, 4);
|
||||
uint32_t first_end = indices.bt2host_in_val;
|
||||
append_on_header = true;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9);
|
||||
check_packet(buffer, 5, 4);
|
||||
assert(indices.bt2host_out_val == first_end && snapshots == 1);
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 8);
|
||||
check_packet(buffer, 4, 2);
|
||||
assert(indices.bt2host_out_val == indices.bt2host_in_val);
|
||||
assert(publications == 2 && notifications == 2);
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0);
|
||||
assert(publications == 2 && notifications == 2);
|
||||
}
|
||||
|
||||
static void test_oversized_and_invalid(void) {
|
||||
_Alignas(4) uint8_t buffer[BTSDIO_FWBUF_SIZE * 2];
|
||||
uint32_t size = 123;
|
||||
reset();
|
||||
indices.bt2host_in_val = put_packet(0, 5, 4);
|
||||
assert(cyw43_btbus_read(buffer, 8, &size) != 0 && size == 0);
|
||||
check_unconsumed(0);
|
||||
assert(reads == 1 && publications == 0);
|
||||
ring[0] = ring[1] = ring[2] = 0xFF;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
check_unconsumed(0);
|
||||
|
||||
reset();
|
||||
/* This would occupy the reserved word and make full indistinguishable from empty. */
|
||||
put_packet(0, BTSDIO_FWBUF_SIZE - 4, 2);
|
||||
indices.bt2host_in_val = 4;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
check_unconsumed(0);
|
||||
|
||||
reset();
|
||||
assert(cyw43_btbus_read(NULL, sizeof(buffer), &size) != 0 && size == 0);
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), NULL) != 0);
|
||||
assert(cyw43_btbus_read(buffer + 1, sizeof(buffer) - 1, &size) != 0);
|
||||
for (uint32_t capacity = 0; capacity < 4; ++capacity) {
|
||||
assert(cyw43_btbus_read(buffer, capacity, &size) != 0 && size == 0);
|
||||
}
|
||||
assert(enters == 0 && snapshots == 0 && publications == 0);
|
||||
check_unconsumed(0);
|
||||
|
||||
for (unsigned field = 0; field < 4; ++field) {
|
||||
reset();
|
||||
switch (field) {
|
||||
case 0: indices.host2bt_in_val = BTSDIO_FWBUF_SIZE; break;
|
||||
case 1: indices.host2bt_out_val = BTSDIO_FWBUF_SIZE; break;
|
||||
case 2: indices.bt2host_in_val = BTSDIO_FWBUF_SIZE; break;
|
||||
case 3: indices.bt2host_out_val = BTSDIO_FWBUF_SIZE; break;
|
||||
}
|
||||
uint32_t original_out = indices.bt2host_out_val;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
check_unconsumed(original_out);
|
||||
assert(reads == 0 && publications == 0);
|
||||
}
|
||||
reset();
|
||||
indices.bt2host_out_val = 1;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
check_unconsumed(1);
|
||||
reset();
|
||||
indices.bt2host_in_val = BTSDIO_FWBUF_SIZE - 1;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
check_unconsumed(0);
|
||||
}
|
||||
|
||||
static void test_status_failures_and_retry(void) {
|
||||
_Alignas(4) uint8_t buffer[32];
|
||||
uint32_t size = 123;
|
||||
reset();
|
||||
fail_wake = true;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
assert(snapshots == 0 && wake_polls == 0);
|
||||
check_unconsumed(0);
|
||||
reset();
|
||||
awake_value = -1;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
assert(snapshots == 0 && wake_polls == 1);
|
||||
check_unconsumed(0);
|
||||
reset();
|
||||
awake_value = 0;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
assert(snapshots == 0 && wake_polls == 301 && delays == 301);
|
||||
check_unconsumed(0);
|
||||
reset();
|
||||
fail_snapshot = true;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
assert(reads == 0 && publications == 0);
|
||||
check_unconsumed(0);
|
||||
|
||||
/* Fail header, first wrapped payload segment, then second payload segment. */
|
||||
for (unsigned failure = 1; failure <= 3; ++failure) {
|
||||
reset();
|
||||
indices.bt2host_out_val = BTSDIO_FWBUF_SIZE - 8;
|
||||
indices.bt2host_in_val = put_packet(indices.bt2host_out_val, 9, 2);
|
||||
fail_read = failure;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
assert(publications == 0);
|
||||
check_unconsumed(BTSDIO_FWBUF_SIZE - 8);
|
||||
fail_read = 0;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 13);
|
||||
check_packet(buffer, 9, 2);
|
||||
assert(indices.bt2host_out_val == indices.bt2host_in_val);
|
||||
}
|
||||
reset();
|
||||
indices.bt2host_in_val = put_packet(0, 5, 4);
|
||||
fail_read = 3; /* Final padded word when the caller provides exact capacity. */
|
||||
assert(cyw43_btbus_read(buffer, 9, &size) != 0 && size == 0);
|
||||
assert(publications == 0);
|
||||
check_unconsumed(0);
|
||||
|
||||
reset();
|
||||
indices.bt2host_in_val = put_packet(0, 5, 4);
|
||||
fail_publication = true;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
check_unconsumed(0);
|
||||
assert(publications == 1);
|
||||
|
||||
reset();
|
||||
indices.bt2host_in_val = put_packet(0, 5, 4);
|
||||
fail_notification = true;
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
|
||||
/* Notification follows the irreversible commit; rollback would violate ownership. */
|
||||
assert(indices.bt2host_out_val == indices.bt2host_in_val);
|
||||
assert(publications == 1 && notifications == 1);
|
||||
assert(lock_depth == 0 && enters == exits);
|
||||
}
|
||||
|
||||
static void test_overflow_and_controller_reset(void) {
|
||||
_Alignas(4) uint8_t buffer[32];
|
||||
uint32_t size = 0;
|
||||
reset();
|
||||
uint32_t first_end = put_packet(0, 5, 4);
|
||||
indices.bt2host_in_val = put_packet(first_end, 8, 2);
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9);
|
||||
assert(indices.bt2host_out_val == first_end);
|
||||
indices.bt2host_in_val -= 4; /* Published unread data disappeared. */
|
||||
expect_panic = true;
|
||||
if (setjmp(panic_jump) == 0) {
|
||||
cyw43_btbus_read(buffer, sizeof(buffer), &size);
|
||||
assert(false && "overflow must retain the SDK's fatal check");
|
||||
}
|
||||
expect_panic = false;
|
||||
assert(indices.bt2host_out_val == first_end);
|
||||
assert(publications == 1 && notifications == 1);
|
||||
lock_depth = 0; /* panic does not return on hardware. */
|
||||
|
||||
reset();
|
||||
first_end = put_packet(0, 5, 4);
|
||||
indices.bt2host_in_val = put_packet(first_end, 8, 2);
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9);
|
||||
assert(cyw43_btbus_init(NULL) == 0);
|
||||
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0);
|
||||
assert(indices.bt2host_out_val == 0 && lock_depth == 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_empty_and_partial();
|
||||
test_complete_wrapped_and_full();
|
||||
test_exact_capacity_and_snapshot_growth();
|
||||
test_oversized_and_invalid();
|
||||
test_status_failures_and_retry();
|
||||
test_overflow_and_controller_reset();
|
||||
puts("cyw43 packet transport regression checks passed");
|
||||
return 0;
|
||||
}
|
||||
24
tests/haptics_experiment_native_stubs/btstack.h
Normal file
24
tests/haptics_experiment_native_stubs/btstack.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
struct btstack_timer_source_t {
|
||||
void (*process)(btstack_timer_source_t*) = nullptr;
|
||||
void* context = nullptr;
|
||||
uint64_t timeout_us = 0;
|
||||
};
|
||||
|
||||
constexpr uint8_t ERROR_CODE_SUCCESS = 0;
|
||||
enum gap_connection_type_t {
|
||||
GAP_CONNECTION_INVALID, GAP_CONNECTION_ACL, GAP_CONNECTION_LE, GAP_CONNECTION_SCO
|
||||
};
|
||||
gap_connection_type_t gap_get_connection_type(uint16_t handle);
|
||||
|
||||
void btstack_run_loop_set_timer_handler(
|
||||
btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*));
|
||||
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms);
|
||||
void btstack_run_loop_add_timer(btstack_timer_source_t* timer);
|
||||
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer);
|
||||
uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid);
|
||||
uint8_t l2cap_request_can_send_now_event(uint16_t cid);
|
||||
uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size);
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#pragma once
|
||||
|
||||
#include <cassert>
|
||||
|
||||
struct critical_section_t {
|
||||
bool initialized = false;
|
||||
};
|
||||
|
||||
inline unsigned native_haptics_lock_depth = 0;
|
||||
|
||||
inline void critical_section_init(critical_section_t* section) {
|
||||
assert(!section->initialized);
|
||||
section->initialized = true;
|
||||
}
|
||||
inline void critical_section_enter_blocking(critical_section_t* section) {
|
||||
assert(section->initialized);
|
||||
assert(native_haptics_lock_depth == 0);
|
||||
++native_haptics_lock_depth;
|
||||
}
|
||||
inline void critical_section_exit(critical_section_t*) {
|
||||
assert(native_haptics_lock_depth == 1);
|
||||
--native_haptics_lock_depth;
|
||||
}
|
||||
9
tests/haptics_experiment_native_stubs/pico/stdlib.h
Normal file
9
tests/haptics_experiment_native_stubs/pico/stdlib.h
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
uint64_t time_us_64();
|
||||
|
||||
// Match SDK section spelling so the RAM build compiles the real annotations.
|
||||
#define __not_in_flash(group) __attribute__((section(".time_critical." group)))
|
||||
#define __time_critical_func(name) __not_in_flash(#name) name
|
||||
47
tests/haptics_experiment_native_stubs/uni.h
Normal file
47
tests/haptics_experiment_native_stubs/uni.h
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#pragma once
|
||||
|
||||
#include <btstack.h>
|
||||
|
||||
struct uni_hid_device_s;
|
||||
typedef struct uni_hid_device_s uni_hid_device_t;
|
||||
|
||||
struct uni_report_parser_t {
|
||||
void (*play_dual_rumble)(uni_hid_device_t*, uint16_t, uint16_t,
|
||||
uint8_t, uint8_t) = nullptr;
|
||||
};
|
||||
|
||||
enum uni_bt_conn_protocol_t {
|
||||
UNI_BT_CONN_PROTOCOL_NONE,
|
||||
UNI_BT_CONN_PROTOCOL_BR_EDR,
|
||||
UNI_BT_CONN_PROTOCOL_BLE,
|
||||
};
|
||||
|
||||
struct uni_bt_conn_t {
|
||||
uint16_t handle = 0;
|
||||
uint16_t control_cid = 0;
|
||||
uint16_t interrupt_cid = 0;
|
||||
bool connected = false;
|
||||
uni_bt_conn_protocol_t protocol = UNI_BT_CONN_PROTOCOL_NONE;
|
||||
};
|
||||
|
||||
struct uni_circular_buffer_t {
|
||||
unsigned queued = 0;
|
||||
};
|
||||
uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer);
|
||||
|
||||
struct uni_hid_device_s {
|
||||
uint16_t vendor_id = 0;
|
||||
uint16_t product_id = 0;
|
||||
uni_report_parser_t report_parser;
|
||||
uni_bt_conn_t conn;
|
||||
uni_circular_buffer_t outgoing_buffer;
|
||||
|
||||
// Fake parser/link state. The module only sees the real fields above.
|
||||
uint16_t remote_mtu = 143;
|
||||
gap_connection_type_t connection_type = GAP_CONNECTION_ACL;
|
||||
bool parser_rumble_active = false;
|
||||
bool parser_rumble_delayed = false;
|
||||
btstack_timer_source_t parser_timer;
|
||||
bool notification_pending = false;
|
||||
bool credit = true;
|
||||
};
|
||||
899
tests/haptics_experiment_test.cpp
Normal file
899
tests/haptics_experiment_test.cpp
Normal file
|
|
@ -0,0 +1,899 @@
|
|||
#include "input/haptics_experiment.h"
|
||||
#include "input/haptics_transport_probe.h"
|
||||
#include "usb/switch/switch_haptics.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
#include <btstack.h>
|
||||
#include <pico/critical_section.h>
|
||||
#include <uni.h>
|
||||
|
||||
namespace {
|
||||
|
||||
enum class Delivery { kImmediate, kDeferred, kNever };
|
||||
enum class GenericKind { kCompatibility, kLed };
|
||||
|
||||
struct Pcm {
|
||||
uint64_t at_us;
|
||||
uint16_t cid;
|
||||
std::array<uint8_t, 143> bytes;
|
||||
};
|
||||
struct Generic {
|
||||
uni_hid_device_t* device;
|
||||
uint64_t at_us;
|
||||
GenericKind kind;
|
||||
uint8_t weak;
|
||||
uint8_t strong;
|
||||
};
|
||||
|
||||
uint64_t now_us = 10000123;
|
||||
std::array<uni_hid_device_t, 4> devices;
|
||||
std::vector<btstack_timer_source_t*> timers;
|
||||
std::vector<Pcm> pcm;
|
||||
std::vector<Generic> generic_sent;
|
||||
std::vector<Generic> generic_queue;
|
||||
Delivery delivery = Delivery::kImmediate;
|
||||
uni_hid_device_t* permission = nullptr;
|
||||
unsigned request_depth = 0;
|
||||
unsigned max_request_depth = 0;
|
||||
unsigned request_calls = 0;
|
||||
unsigned send_calls = 0;
|
||||
unsigned timer_calls = 0;
|
||||
unsigned fail_requests = 0;
|
||||
unsigned fail_sends = 0;
|
||||
uint32_t send_cost_us = 0;
|
||||
uint32_t request_cost_us = 0;
|
||||
bool reenter_send = false;
|
||||
|
||||
void no_lock() {
|
||||
assert(native_haptics_lock_depth == 0);
|
||||
}
|
||||
|
||||
HapticsExperimentDiagnostics snapshot() {
|
||||
HapticsExperimentDiagnostics out;
|
||||
haptics_experiment_snapshot(&out);
|
||||
return out;
|
||||
}
|
||||
|
||||
uni_hid_device_t* device_for_cid(uint16_t cid) {
|
||||
for (auto& device : devices) {
|
||||
if (device.conn.interrupt_cid == cid) {
|
||||
return &device;
|
||||
}
|
||||
}
|
||||
assert(false && "stale or unknown L2CAP CID");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void emit_generic(uni_hid_device_t* device, GenericKind kind,
|
||||
uint8_t weak = 0, uint8_t strong = 0) {
|
||||
no_lock();
|
||||
Generic report{device, now_us, kind, weak, strong};
|
||||
if (device->credit) {
|
||||
generic_sent.push_back(report);
|
||||
} else {
|
||||
generic_queue.push_back(report);
|
||||
++device->outgoing_buffer.queued;
|
||||
}
|
||||
}
|
||||
|
||||
void parser_off(btstack_timer_source_t* timer) {
|
||||
auto* device = static_cast<uni_hid_device_t*>(timer->context);
|
||||
assert(device->parser_rumble_active);
|
||||
device->parser_rumble_active = false;
|
||||
emit_generic(device, GenericKind::kCompatibility);
|
||||
}
|
||||
|
||||
void parser_delayed_on(btstack_timer_source_t* timer) {
|
||||
auto* device = static_cast<uni_hid_device_t*>(timer->context);
|
||||
assert(device->parser_rumble_delayed);
|
||||
device->parser_rumble_delayed = false;
|
||||
emit_generic(device, GenericKind::kCompatibility, 17, 23);
|
||||
}
|
||||
|
||||
// Behavioral fake of the relevant Bluepad32 DS5 parser contract: duration=0
|
||||
// does not emit anything if already disabled; duration>0 forces compatibility
|
||||
// and installs a timer-off. This catches a "restore" which is actually a no-op.
|
||||
void play_rumble(uni_hid_device_t* device, uint16_t delay_ms,
|
||||
uint16_t duration_ms, uint8_t weak, uint8_t strong) {
|
||||
no_lock();
|
||||
const bool was_active = device->parser_rumble_active ||
|
||||
device->parser_rumble_delayed;
|
||||
if (was_active) {
|
||||
btstack_run_loop_remove_timer(&device->parser_timer);
|
||||
}
|
||||
device->parser_rumble_active = false;
|
||||
device->parser_rumble_delayed = false;
|
||||
device->parser_timer.context = device;
|
||||
if (delay_ms != 0) {
|
||||
device->parser_rumble_delayed = true;
|
||||
device->parser_timer.process = parser_delayed_on;
|
||||
btstack_run_loop_set_timer(&device->parser_timer, delay_ms);
|
||||
btstack_run_loop_add_timer(&device->parser_timer);
|
||||
} else if (duration_ms != 0) {
|
||||
emit_generic(device, GenericKind::kCompatibility, weak, strong);
|
||||
device->parser_rumble_active = true;
|
||||
device->parser_timer.process = parser_off;
|
||||
btstack_run_loop_set_timer(&device->parser_timer, duration_ms);
|
||||
btstack_run_loop_add_timer(&device->parser_timer);
|
||||
} else if (was_active) {
|
||||
emit_generic(device, GenericKind::kCompatibility);
|
||||
}
|
||||
}
|
||||
|
||||
bool dispatch(uni_hid_device_t* device, uint16_t cid) {
|
||||
no_lock();
|
||||
if (cid == device->conn.interrupt_cid) {
|
||||
device->notification_pending = false;
|
||||
}
|
||||
permission = device->credit ? device : nullptr;
|
||||
const bool consumed = haptics_experiment_on_can_send_now(device, cid);
|
||||
if (!consumed && device->credit) {
|
||||
const auto it = std::find_if(generic_queue.begin(), generic_queue.end(),
|
||||
[device](const Generic& report) {
|
||||
return report.device == device;
|
||||
});
|
||||
if (it != generic_queue.end()) {
|
||||
Generic report = *it;
|
||||
report.at_us = now_us;
|
||||
generic_sent.push_back(report);
|
||||
generic_queue.erase(it);
|
||||
--device->outgoing_buffer.queued;
|
||||
}
|
||||
}
|
||||
permission = nullptr;
|
||||
return consumed;
|
||||
}
|
||||
|
||||
void run_until(uint64_t target_us) {
|
||||
assert(target_us >= now_us);
|
||||
unsigned iterations = 0;
|
||||
while (!timers.empty()) {
|
||||
const auto it = std::min_element(
|
||||
timers.begin(), timers.end(),
|
||||
[](const auto* a, const auto* b) { return a->timeout_us < b->timeout_us; });
|
||||
btstack_timer_source_t* timer = *it;
|
||||
if (timer->timeout_us > target_us) {
|
||||
break;
|
||||
}
|
||||
now_us = std::max(now_us, timer->timeout_us);
|
||||
timers.erase(it);
|
||||
assert(++iterations < 3000 && "recursive or permanently polling timer");
|
||||
++timer_calls;
|
||||
timer->process(timer);
|
||||
}
|
||||
now_us = std::max(now_us, target_us);
|
||||
}
|
||||
|
||||
void reset(uint64_t at_us = 10000123) {
|
||||
for (auto& device : devices) {
|
||||
haptics_experiment_detach(&device);
|
||||
}
|
||||
if (snapshot().state == HapticsExperimentState::kPending) {
|
||||
assert(haptics_experiment_request(0, snapshot().slot));
|
||||
haptics_experiment_poll();
|
||||
}
|
||||
timers.clear();
|
||||
devices = {};
|
||||
pcm.clear();
|
||||
generic_sent.clear();
|
||||
generic_queue.clear();
|
||||
now_us = at_us;
|
||||
delivery = Delivery::kImmediate;
|
||||
permission = nullptr;
|
||||
request_depth = max_request_depth = request_calls = send_calls = timer_calls = 0;
|
||||
fail_requests = fail_sends = 0;
|
||||
send_cost_us = request_cost_us = 0;
|
||||
reenter_send = false;
|
||||
for (unsigned slot = 0; slot < devices.size(); ++slot) {
|
||||
auto& device = devices[slot];
|
||||
device.vendor_id = 0x054c;
|
||||
device.product_id = 0x0ce6;
|
||||
device.conn.handle = static_cast<uint16_t>(slot);
|
||||
// Bluepad32's Classic path does not initialize this cached field.
|
||||
device.conn.protocol = UNI_BT_CONN_PROTOCOL_NONE;
|
||||
device.conn.connected = true;
|
||||
device.conn.interrupt_cid = static_cast<uint16_t>(0x40 + slot * 2);
|
||||
device.conn.control_cid = device.conn.interrupt_cid + 1;
|
||||
device.report_parser.play_dual_rumble = play_rumble;
|
||||
haptics_experiment_attach(static_cast<uint8_t>(slot), 100 + slot, &device);
|
||||
}
|
||||
assert(pcm.empty() && generic_sent.empty()); // No pairing/boot tone.
|
||||
}
|
||||
|
||||
uint64_t start(uint8_t slot = 0) {
|
||||
const uint32_t previous_id = snapshot().run_id;
|
||||
assert(haptics_experiment_request(1, slot));
|
||||
const auto pending = snapshot();
|
||||
assert(pending.run_id == previous_id + 1);
|
||||
assert(pending.slot == slot && pending.state == HapticsExperimentState::kPending);
|
||||
assert(!haptics_experiment_request(1, slot));
|
||||
assert(!haptics_experiment_request(1, static_cast<uint8_t>((slot + 1) % 4)));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kRunning);
|
||||
assert(haptics_experiment_owns(&devices[slot]));
|
||||
assert(!haptics_experiment_owns(&devices[(slot + 1) % 4]));
|
||||
return now_us;
|
||||
}
|
||||
|
||||
uint64_t due(uint64_t started, uint32_t packet, uint32_t frames = 64) {
|
||||
return started + (static_cast<uint64_t>(packet) * frames * 1000 + 2) / 3;
|
||||
}
|
||||
|
||||
void verify_block(const Pcm& packet, uint32_t index, bool forced_silence = false) {
|
||||
const auto& b = packet.bytes;
|
||||
assert(b[0] == 0xa2 && b[1] == 0x32 && b[2] == 0);
|
||||
unsigned sample_offset = 10;
|
||||
unsigned frames = 64;
|
||||
if (b[3] == 0x90) {
|
||||
assert(b[4] == 63);
|
||||
for (unsigned i = 5; i < 68; ++i) assert(b[i] == 0);
|
||||
assert(b[68] == 0x92 && b[69] == 64);
|
||||
sample_offset = 70;
|
||||
frames = 32;
|
||||
} else {
|
||||
assert(b[3] == 0x91 && b[4] == 3 && b[5] == 0x62);
|
||||
assert(b[6] == 16 && b[8] == 0xd2 && b[9] == 64);
|
||||
}
|
||||
for (unsigned i = sample_offset + frames * 2; i < 139; ++i) {
|
||||
assert(b[i] == 0);
|
||||
}
|
||||
const bool pattern_tone = index >= 48 && index < 240 &&
|
||||
((index - 48) / 12) % 2 == 0;
|
||||
const bool tone = pattern_tone && !forced_silence;
|
||||
const unsigned phase = tone ? ((index - 48) / 12) % 4 : 0;
|
||||
const unsigned side = phase == 0 ? 0 : 1;
|
||||
const double hz = phase == 0 ? 100.0 : 200.0;
|
||||
for (unsigned frame = 0; frame < frames; ++frame) {
|
||||
for (unsigned channel = 0; channel < 2; ++channel) {
|
||||
const int value = static_cast<int8_t>(b[sample_offset + frame * 2 + channel]);
|
||||
if (!tone || channel != side) {
|
||||
assert(value == 0);
|
||||
} else {
|
||||
const unsigned sample = ((index - 48) % 12) * 64 + frame;
|
||||
const int expected = static_cast<int>(std::lround(
|
||||
32.0 * std::sin(2.0 * 3.14159265358979323846 * hz * sample / 3000.0)));
|
||||
assert(std::abs(value - expected) <= 1);
|
||||
assert(std::abs(value) <= 32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void nominal_run(const char* corpus_path) {
|
||||
reset();
|
||||
const uint64_t started = start();
|
||||
// This is the BTstack synchronous reentry reproduction: the very first
|
||||
// request sends one block before request_can_send_now() returns.
|
||||
assert(pcm.size() == 1 && snapshot().synchronous_callbacks == 1);
|
||||
assert(!devices[0].notification_pending);
|
||||
const uint64_t early_tick = due(started, 1) / 1000 * 1000;
|
||||
run_until(early_tick);
|
||||
assert(pcm.size() == 1); // SDK early millisecond wake must not send early.
|
||||
run_until(started + 6148000);
|
||||
const auto done = snapshot();
|
||||
assert(done.state == HapticsExperimentState::kCompleted);
|
||||
assert(done.sent_packets == 288 && done.generated_packets == 288);
|
||||
assert(done.skipped_packets == 0 && done.send_failures == 0);
|
||||
assert(done.can_send_requests == 288 && done.synchronous_callbacks == 288);
|
||||
assert(max_request_depth == 1 && request_calls == 288);
|
||||
assert(timer_calls < 1250); // No permanent 1 ms poll for this 6.144 s run.
|
||||
assert(pcm.size() == 288);
|
||||
assert(done.first_tone_due_us == static_cast<uint32_t>(started + 1024000));
|
||||
assert(done.first_tone_sent_us == static_cast<uint32_t>(pcm[48].at_us));
|
||||
assert(done.last_sent_us == static_cast<uint32_t>(pcm.back().at_us));
|
||||
assert(done.max_send_gap_us <= 22000 && done.max_lateness_us < 1000);
|
||||
assert(done.elapsed_us >= 6147000 && done.elapsed_us < 6148000);
|
||||
assert(done.max_generate_us == 0);
|
||||
assert(!haptics_experiment_owns(&devices[0]) && timers.empty());
|
||||
assert(generic_sent.size() == 2); // Forced compatibility, then parser off.
|
||||
assert(generic_sent.front().at_us >= started + 6144000);
|
||||
assert(!devices[0].parser_rumble_active);
|
||||
for (const auto& report : generic_sent) {
|
||||
assert(report.kind == GenericKind::kCompatibility);
|
||||
assert(report.weak == 0 && report.strong == 0);
|
||||
}
|
||||
std::ofstream corpus(corpus_path, std::ios::binary);
|
||||
assert(corpus.is_open());
|
||||
for (uint32_t i = 0; i < pcm.size(); ++i) {
|
||||
assert(pcm[i].at_us >= due(started, i));
|
||||
assert(pcm[i].at_us - due(started, i) < 1000);
|
||||
if (i != 0) assert(pcm[i].bytes[7] == static_cast<uint8_t>(i * 2));
|
||||
verify_block(pcm[i], i);
|
||||
corpus.write(reinterpret_cast<const char*>(pcm[i].bytes.data()), 143);
|
||||
}
|
||||
// Reference-sized 0x10 native-mode state followed by a silent PCM block.
|
||||
// Independent known answer computed with Python zlib over the A2 prefix.
|
||||
assert(pcm[0].bytes[3] == 0x90);
|
||||
assert(pcm[0].bytes[139] == 0x00 && pcm[0].bytes[140] == 0x41 &&
|
||||
pcm[0].bytes[141] == 0xfd && pcm[0].bytes[142] == 0x53);
|
||||
corpus.close();
|
||||
run_until(now_us + 200000);
|
||||
assert(snapshot().elapsed_us == done.elapsed_us && pcm.size() == 288);
|
||||
}
|
||||
|
||||
void stalled_deadlines() {
|
||||
reset();
|
||||
const uint64_t started = start();
|
||||
run_until(due(started, 102) + 1000);
|
||||
const unsigned before = static_cast<unsigned>(pcm.size());
|
||||
const uint32_t next = snapshot().sent_packets;
|
||||
now_us += 250000; // The main loop did not run at all during this stall.
|
||||
const uint32_t current = static_cast<uint32_t>((now_us - started) * 3 / 64000);
|
||||
run_until(now_us);
|
||||
assert(pcm.size() == before + 1); // No catch-up replay burst.
|
||||
assert(snapshot().skipped_packets == current - next);
|
||||
verify_block(pcm.back(), current);
|
||||
assert(snapshot().max_send_gap_us >= 250000);
|
||||
run_until(started + 6148000);
|
||||
const auto done = snapshot();
|
||||
assert(done.state == HapticsExperimentState::kCompleted);
|
||||
assert(done.sent_packets + done.skipped_packets == 288);
|
||||
assert(done.elapsed_us < 6148000);
|
||||
}
|
||||
|
||||
void deferred_and_missing_callbacks() {
|
||||
reset();
|
||||
delivery = Delivery::kDeferred;
|
||||
const uint64_t started = start();
|
||||
assert(pcm.empty() && devices[0].notification_pending);
|
||||
run_until(started + 2300000);
|
||||
assert(request_calls == 1 && timer_calls == 0);
|
||||
assert(!dispatch(&devices[1], devices[1].conn.interrupt_cid));
|
||||
assert(dispatch(&devices[0], devices[0].conn.control_cid));
|
||||
assert(pcm.empty() && devices[0].notification_pending);
|
||||
assert(dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(pcm.size() == 1);
|
||||
assert(snapshot().max_request_wait_us == 2300000);
|
||||
assert(snapshot().synchronous_callbacks == 0);
|
||||
assert(snapshot().skipped_packets == 107);
|
||||
verify_block(pcm.back(), 107);
|
||||
delivery = Delivery::kImmediate;
|
||||
run_until(started + 6148000);
|
||||
assert(snapshot().state == HapticsExperimentState::kCompleted);
|
||||
assert(snapshot().sent_packets + snapshot().skipped_packets == 288);
|
||||
|
||||
reset();
|
||||
delivery = Delivery::kNever;
|
||||
const uint64_t missing_start = start();
|
||||
run_until(missing_start + 6248000);
|
||||
const auto missing = snapshot();
|
||||
assert(missing.state == HapticsExperimentState::kError);
|
||||
assert(missing.last_error == 4 && missing.send_failures == 1);
|
||||
assert(missing.sent_packets == 0 && missing.skipped_packets == 288);
|
||||
assert(missing.max_request_wait_us >= 6244000);
|
||||
assert(request_calls == 1 && timer_calls < 10 && timers.empty());
|
||||
assert(!haptics_experiment_owns(&devices[0]));
|
||||
assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(pcm.empty());
|
||||
}
|
||||
|
||||
void stop_preemption_and_restore() {
|
||||
reset();
|
||||
const uint64_t started = start();
|
||||
run_until(due(started, 98) + 1000);
|
||||
delivery = Delivery::kDeferred;
|
||||
run_until(due(started, 99) + 1000);
|
||||
assert(devices[0].notification_pending);
|
||||
const auto before = snapshot();
|
||||
assert(!haptics_experiment_request(0, 1));
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
run_until(now_us + 10000);
|
||||
const unsigned sent_before = static_cast<unsigned>(pcm.size());
|
||||
assert(dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(pcm.size() == sent_before + 1);
|
||||
verify_block(pcm.back(), 99, true); // Pending tone permission now sends silence.
|
||||
assert(!haptics_experiment_request(1, 0)); // Compatibility is still settling.
|
||||
run_until(now_us + 4000);
|
||||
assert(snapshot().state == HapticsExperimentState::kStopped);
|
||||
assert(snapshot().run_id == before.run_id);
|
||||
assert(!devices[0].parser_rumble_active && timers.empty());
|
||||
assert(generic_sent.size() == 2 && generic_sent[0].at_us >= pcm.back().at_us);
|
||||
const auto stopped_elapsed = snapshot().elapsed_us;
|
||||
run_until(now_us + 7000000);
|
||||
assert(pcm.size() == sent_before + 1 && snapshot().elapsed_us == stopped_elapsed);
|
||||
|
||||
reset();
|
||||
const uint32_t previous = snapshot().run_id;
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
const uint32_t pending_generation = snapshot().connection_generation;
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kStopped);
|
||||
assert(snapshot().run_id == previous + 1 && pcm.empty() && timers.empty());
|
||||
assert(snapshot().connection_generation == pending_generation);
|
||||
|
||||
// Disconnect in the ownership-settling window, immediately after restore.
|
||||
// Bluepad32 deletes the instance without removing private parser timers.
|
||||
reset();
|
||||
start();
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(haptics_experiment_owns(&devices[0]));
|
||||
assert(!devices[0].parser_rumble_active);
|
||||
haptics_experiment_detach(&devices[0]);
|
||||
const auto compatibility_count = generic_sent.size();
|
||||
devices[0] = {}; // Simulate upstream zeroing/reusing the parser instance.
|
||||
run_until(now_us + 10000);
|
||||
assert(snapshot().state == HapticsExperimentState::kDisconnected);
|
||||
assert(timers.empty() && generic_sent.size() == compatibility_count);
|
||||
|
||||
reset();
|
||||
delivery = Delivery::kNever;
|
||||
devices[0].credit = false;
|
||||
start();
|
||||
const uint64_t stopped_at = now_us;
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
// Repeated stops must not perpetually extend the lifecycle watchdog.
|
||||
run_until(stopped_at + 50000);
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
run_until(stopped_at + 202000);
|
||||
assert(snapshot().state == HapticsExperimentState::kError);
|
||||
assert(snapshot().last_error == 4 && snapshot().send_failures >= 1);
|
||||
assert(snapshot().sent_packets == 0 && !haptics_experiment_owns(&devices[0]));
|
||||
assert(timers.empty() && generic_sent.empty() && generic_queue.size() == 2);
|
||||
devices[0].credit = true;
|
||||
assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(generic_queue.empty() && generic_sent.size() == 2 && pcm.empty());
|
||||
}
|
||||
|
||||
void compatibility_queue_and_parser_timers() {
|
||||
reset();
|
||||
devices[0].credit = false;
|
||||
emit_generic(&devices[0], GenericKind::kLed);
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kError && snapshot().last_error == 6);
|
||||
assert(pcm.empty() && generic_queue.size() == 1 && !haptics_experiment_owns(&devices[0]));
|
||||
devices[0].credit = true;
|
||||
assert(!dispatch(&devices[0], devices[0].conn.control_cid));
|
||||
assert(generic_sent.size() == 1 && generic_sent[0].kind == GenericKind::kLed);
|
||||
start(); // Retry only after the unrelated report was delivered, not discarded.
|
||||
assert(pcm.size() == 1);
|
||||
devices[0].credit = false;
|
||||
emit_generic(&devices[0], GenericKind::kLed);
|
||||
devices[0].credit = true;
|
||||
const auto sent_count = pcm.size();
|
||||
assert(dispatch(&devices[0], devices[0].conn.control_cid));
|
||||
assert(dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(pcm.size() == sent_count && generic_queue.size() == 1);
|
||||
|
||||
reset();
|
||||
play_rumble(&devices[0], 3000, 100, 17, 23);
|
||||
const uint64_t started = start();
|
||||
run_until(started + 6148000);
|
||||
assert(snapshot().state == HapticsExperimentState::kCompleted);
|
||||
for (const auto& report : generic_sent) {
|
||||
assert(report.weak == 0 && report.strong == 0);
|
||||
assert(report.at_us <= started || report.at_us >= started + 6144000);
|
||||
}
|
||||
assert(!devices[0].parser_rumble_active && !devices[0].parser_rumble_delayed);
|
||||
|
||||
// Canceling an already running parser can itself queue a stop report.
|
||||
// Reject before the first native packet if that compatibility report blocks.
|
||||
reset();
|
||||
play_rumble(&devices[0], 0, 3000, 17, 23);
|
||||
devices[0].credit = false;
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().last_error == 6 && pcm.empty());
|
||||
assert(!devices[0].parser_rumble_active && timers.empty());
|
||||
}
|
||||
|
||||
void reconnect_and_pending_generation() {
|
||||
reset();
|
||||
delivery = Delivery::kDeferred;
|
||||
start();
|
||||
const uint16_t old_cid = devices[0].conn.interrupt_cid;
|
||||
haptics_experiment_detach(&devices[0]);
|
||||
const auto detached = snapshot();
|
||||
assert(detached.state == HapticsExperimentState::kDisconnected);
|
||||
assert(detached.last_error == 3 && timers.empty() && generic_sent.empty());
|
||||
assert(!dispatch(&devices[0], old_cid));
|
||||
assert(pcm.empty());
|
||||
devices[0].conn.interrupt_cid = 0x70;
|
||||
haptics_experiment_attach(0, 101, &devices[0]);
|
||||
start();
|
||||
assert(snapshot().connection_generation == 101);
|
||||
assert(dispatch(&devices[0], old_cid)); // Never permission for the new CID.
|
||||
assert(pcm.empty());
|
||||
assert(dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(pcm.size() == 1);
|
||||
haptics_experiment_attach(0, 102, &devices[0]);
|
||||
assert(snapshot().state == HapticsExperimentState::kDisconnected && timers.empty());
|
||||
|
||||
reset();
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
const uint32_t requested_generation = snapshot().connection_generation;
|
||||
haptics_experiment_detach(&devices[0]);
|
||||
haptics_experiment_attach(0, requested_generation + 1, &devices[0]);
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kDisconnected);
|
||||
assert(snapshot().connection_generation == requested_generation && pcm.empty());
|
||||
// The rejected request did not reserve the slot permanently.
|
||||
start();
|
||||
assert(snapshot().connection_generation == requested_generation + 1);
|
||||
}
|
||||
|
||||
void support_and_transport_errors() {
|
||||
reset();
|
||||
assert(!haptics_experiment_request(3, 0));
|
||||
assert(!haptics_experiment_request(1, 4));
|
||||
devices[0].remote_mtu = 142;
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kUnsupported);
|
||||
assert(snapshot().last_error == 2 && pcm.empty() && !haptics_experiment_owns(&devices[0]));
|
||||
devices[0].remote_mtu = 143;
|
||||
devices[0].connection_type = GAP_CONNECTION_LE;
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().last_error == 1 && pcm.empty());
|
||||
devices[0].connection_type = GAP_CONNECTION_ACL;
|
||||
devices[0].product_id = 0x0df2;
|
||||
start(); // Exact MTU boundary and DualSense Edge.
|
||||
assert(pcm.size() == 1);
|
||||
|
||||
reset();
|
||||
start(3);
|
||||
assert(pcm.size() == 1 && pcm.front().cid == devices[3].conn.interrupt_cid);
|
||||
assert(snapshot().connection_generation == 103);
|
||||
|
||||
reset();
|
||||
fail_requests = 1;
|
||||
start();
|
||||
assert(pcm.empty());
|
||||
run_until(now_us + 24000);
|
||||
assert(pcm.size() == 1 && snapshot().send_failures == 1);
|
||||
assert(snapshot().skipped_packets == 1);
|
||||
fail_sends = 1;
|
||||
run_until(now_us + 24000);
|
||||
assert(snapshot().send_failures == 2);
|
||||
run_until(static_cast<uint64_t>(snapshot().start_us) + 6148000);
|
||||
assert(snapshot().state == HapticsExperimentState::kError);
|
||||
assert(snapshot().last_error == 5);
|
||||
assert(snapshot().generated_packets == snapshot().sent_packets + 1);
|
||||
assert(max_request_depth == 1 && timers.empty());
|
||||
}
|
||||
|
||||
void timing_cost_reentrancy_and_wrap() {
|
||||
reset();
|
||||
send_cost_us = 500;
|
||||
request_cost_us = 200;
|
||||
reenter_send = true;
|
||||
start();
|
||||
const uint64_t started = snapshot().start_us;
|
||||
run_until(started + 6148000);
|
||||
const auto done = snapshot();
|
||||
assert(done.state == HapticsExperimentState::kCompleted);
|
||||
assert(done.sent_packets == 288 && send_calls == 288);
|
||||
assert(done.max_generate_us == 0); // Neither request nor send is generation.
|
||||
assert(done.max_request_wait_us == 200 && max_request_depth == 1);
|
||||
for (unsigned i = 0; i < pcm.size(); ++i) {
|
||||
assert(pcm[i].at_us >= due(started, i));
|
||||
assert(pcm[i].at_us - due(started, i) < 1200);
|
||||
}
|
||||
|
||||
reset((uint64_t{1} << 32) - 1000123);
|
||||
const uint64_t wrap_start = start();
|
||||
run_until(wrap_start + 6148000);
|
||||
const auto wrapped = snapshot();
|
||||
assert(wrapped.state == HapticsExperimentState::kCompleted);
|
||||
assert(wrapped.start_us == static_cast<uint32_t>(wrap_start));
|
||||
assert(wrapped.first_tone_due_us == static_cast<uint32_t>(wrap_start + 1024000));
|
||||
assert(wrapped.first_tone_sent_us == static_cast<uint32_t>(pcm[48].at_us));
|
||||
assert(wrapped.last_sent_us == static_cast<uint32_t>(pcm.back().at_us));
|
||||
assert(wrapped.elapsed_us >= 6147000 && wrapped.elapsed_us < 6148000);
|
||||
assert(wrapped.max_send_gap_us <= 22000 && wrapped.sent_packets == 288);
|
||||
}
|
||||
|
||||
void gameplay_timeline_and_lifecycle() {
|
||||
reset();
|
||||
SwitchHapticsDecoder decoder;
|
||||
const auto feed = [&](bool left) {
|
||||
const uint32_t active = (1u << 30) | (96u << 23) | (64u << 16) | (64u << 2);
|
||||
const uint32_t words[] = {left ? active : 0x40400100u,
|
||||
left ? 0x40400100u : active};
|
||||
uint8_t bytes[8]{};
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
for (unsigned byte = 0; byte < 4; ++byte) {
|
||||
bytes[side * 4 + byte] = static_cast<uint8_t>(words[side] >> (8 * byte));
|
||||
}
|
||||
}
|
||||
const auto decoded = decoder.decode(bytes);
|
||||
assert(haptics_experiment_submit(0, 100, now_us, decoded.hd));
|
||||
};
|
||||
assert(haptics_experiment_request(2, 0));
|
||||
const uint64_t started = now_us;
|
||||
feed(true); // A sole first command survives the Pending -> Running boundary.
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().mode == 1 && snapshot().state == HapticsExperimentState::kRunning);
|
||||
assert(haptics_experiment_gameplay_owns(&devices[0]));
|
||||
now_us = started + 8000;
|
||||
feed(false);
|
||||
const uint32_t frames = snapshot().packet_frames;
|
||||
run_until(due(started, 1, frames) + 1000);
|
||||
assert(pcm.size() == 2 && snapshot().host_updates == 2);
|
||||
assert(pcm[1].bytes[7] == frames / 32);
|
||||
assert(pcm[1].bytes[8] == (frames == 32 ? 0x92 : 0xd2));
|
||||
for (unsigned byte = 10 + frames * 2; byte < 139; ++byte)
|
||||
assert(pcm[1].bytes[byte] == 0);
|
||||
unsigned left_nonzero = 0, right_nonzero = 0;
|
||||
for (unsigned frame = 0; frame < frames; ++frame) {
|
||||
const auto left = pcm[1].bytes[10 + frame * 2];
|
||||
const auto right = pcm[1].bytes[11 + frame * 2];
|
||||
if (frame < 24) {
|
||||
assert(right == 0);
|
||||
left_nonzero += left != 0;
|
||||
} else {
|
||||
assert(left == 0);
|
||||
right_nonzero += right != 0;
|
||||
}
|
||||
}
|
||||
assert(left_nonzero > 10 && right_nonzero > (frames - 24) / 2);
|
||||
SwitchHapticsFrame stale{};
|
||||
stale.actuators[0].sample_count = 1;
|
||||
stale.actuators[0].samples[0].low_amplitude_q15 = 16000;
|
||||
assert(!haptics_experiment_submit(0, 101, now_us, stale));
|
||||
run_until(started + 6300000);
|
||||
assert(snapshot().state == HapticsExperimentState::kRunning);
|
||||
assert(snapshot().sent_packets > 288 && snapshot().skipped_packets == 0);
|
||||
for (unsigned byte = 10; byte < 138; ++byte) assert(pcm.back().bytes[byte] == 0);
|
||||
assert(snapshot().dropped_updates == 0 && generic_sent.empty());
|
||||
assert(haptics_experiment_feedback(&devices[0], 100, 60, 30));
|
||||
run_until(now_us + 22000);
|
||||
assert(generic_sent.empty()); // Local confirmation must not leave PCM mode.
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
run_until(now_us + 10000);
|
||||
assert(snapshot().state == HapticsExperimentState::kStopped && snapshot().mode == 1);
|
||||
assert(!haptics_experiment_owns(&devices[0]) && generic_sent.size() == 2);
|
||||
}
|
||||
|
||||
void gameplay_missing_callback_is_bounded() {
|
||||
reset();
|
||||
delivery = Delivery::kNever;
|
||||
assert(haptics_experiment_request(2, 0));
|
||||
haptics_experiment_poll();
|
||||
run_until(now_us + 105000);
|
||||
assert(snapshot().state == HapticsExperimentState::kError && snapshot().last_error == 4);
|
||||
assert(!haptics_experiment_owns(&devices[0]) && timers.empty());
|
||||
}
|
||||
|
||||
void gameplay_queued_start_and_command_overflow() {
|
||||
reset();
|
||||
devices[0].credit = false;
|
||||
emit_generic(&devices[0], GenericKind::kLed);
|
||||
assert(haptics_experiment_request(2, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kPending && pcm.empty());
|
||||
run_until(now_us + 10000);
|
||||
devices[0].credit = true;
|
||||
assert(!dispatch(&devices[0], devices[0].conn.control_cid));
|
||||
run_until(now_us + 3000);
|
||||
assert(snapshot().state == HapticsExperimentState::kRunning && pcm.size() == 1);
|
||||
assert(generic_queue.empty() && generic_sent.size() == 1);
|
||||
assert(generic_sent.front().kind == GenericKind::kLed);
|
||||
|
||||
SwitchHapticsFrame frame{};
|
||||
frame.actuators[0].sample_count = 1;
|
||||
frame.actuators[0].samples[0].low_amplitude_q15 = 20000;
|
||||
const size_t sent_before = pcm.size();
|
||||
for (unsigned i = 0; i < 17; ++i) {
|
||||
now_us += 8000;
|
||||
assert(haptics_experiment_submit(0, 100, now_us, frame));
|
||||
}
|
||||
run_until(now_us);
|
||||
assert(snapshot().state == HapticsExperimentState::kRunning);
|
||||
assert(snapshot().host_updates == 17 && snapshot().dropped_updates == 1);
|
||||
assert(snapshot().skipped_packets != 0 && pcm.size() == sent_before + 1);
|
||||
}
|
||||
|
||||
void stateful_rumble_prepare_feedback_and_zero() {
|
||||
reset();
|
||||
devices[0].credit = false;
|
||||
emit_generic(&devices[0], GenericKind::kLed);
|
||||
assert(haptics_experiment_request(2, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kPending);
|
||||
assert(haptics_experiment_submit_rumble(0, 100, now_us, 180, 0));
|
||||
assert(!haptics_experiment_submit_rumble(1, 101, now_us, 255, 255));
|
||||
assert(!haptics_experiment_submit_rumble(0, 99, now_us, 255, 255));
|
||||
run_until(now_us + 70000); // Preparation must not age out held strengths.
|
||||
devices[0].credit = true;
|
||||
assert(!dispatch(&devices[0], devices[0].conn.control_cid));
|
||||
run_until(now_us + 3000);
|
||||
assert(snapshot().state == HapticsExperimentState::kRunning);
|
||||
const auto assert_channels = [](bool left, bool right) {
|
||||
const uint32_t frames = snapshot().packet_frames;
|
||||
unsigned active[2]{};
|
||||
for (uint32_t frame = 0; frame < frames; ++frame) {
|
||||
active[0] += pcm.back().bytes[10 + frame * 2] != 0;
|
||||
active[1] += pcm.back().bytes[11 + frame * 2] != 0;
|
||||
}
|
||||
assert(left ? active[0] > frames / 2 : active[0] == 0);
|
||||
assert(right ? active[1] > frames / 2 : active[1] == 0);
|
||||
};
|
||||
run_until(now_us + 300000);
|
||||
assert_channels(true, false);
|
||||
assert(haptics_experiment_feedback(&devices[0], 255, 255, 100));
|
||||
assert(haptics_experiment_submit_rumble(0, 100, now_us, 0, 170));
|
||||
run_until(now_us + 60000);
|
||||
assert_channels(true, true);
|
||||
run_until(now_us + 200000);
|
||||
assert_channels(false, true); // Overlay reveals the newest held command.
|
||||
assert(generic_sent.size() == 1 &&
|
||||
generic_sent.front().kind == GenericKind::kLed);
|
||||
assert(haptics_experiment_submit_rumble(0, 100, now_us, 0, 0));
|
||||
run_until(now_us + 80000);
|
||||
assert_channels(false, false);
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
assert(!haptics_experiment_submit_rumble(0, 100, now_us, 255, 255));
|
||||
haptics_experiment_poll();
|
||||
run_until(now_us + 10000);
|
||||
assert(snapshot().state == HapticsExperimentState::kStopped);
|
||||
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
assert(!haptics_experiment_submit_rumble(0, 100, now_us, 255, 255));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().mode == 0 && snapshot().packet_frames == 64);
|
||||
}
|
||||
|
||||
void stateful_rumble_generation_and_overflow() {
|
||||
reset();
|
||||
assert(haptics_experiment_request(2, 0));
|
||||
assert(haptics_experiment_submit_rumble(0, 100, now_us, 255, 0));
|
||||
haptics_experiment_detach(&devices[0]);
|
||||
haptics_experiment_attach(0, 101, &devices[0]);
|
||||
assert(!haptics_experiment_submit_rumble(0, 100, now_us, 255, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kDisconnected);
|
||||
assert(haptics_experiment_request(2, 0));
|
||||
haptics_experiment_poll();
|
||||
run_until(now_us + 100000);
|
||||
for (unsigned byte = 10; byte < 138; ++byte)
|
||||
assert(pcm.back().bytes[byte] == 0);
|
||||
for (unsigned command = 0; command < 17; ++command) {
|
||||
assert(haptics_experiment_submit_rumble(
|
||||
0, 101, now_us, command == 16 ? 0 : 255, 0));
|
||||
}
|
||||
run_until(now_us + 80000);
|
||||
assert(snapshot().host_updates == 17 && snapshot().dropped_updates == 1);
|
||||
for (unsigned byte = 10; byte < 138; ++byte)
|
||||
assert(pcm.back().bytes[byte] == 0);
|
||||
assert(generic_sent.empty());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Transport attribution has its own native fixture; this fixture isolates PCM
|
||||
// scheduling and packet content from the optional measurement backend.
|
||||
void haptics_transport_probe_prepare() {}
|
||||
void haptics_transport_probe_begin(uint32_t, uint32_t, uint16_t) {}
|
||||
void haptics_transport_probe_end() {}
|
||||
void haptics_transport_probe_timer(uint32_t) {}
|
||||
void haptics_transport_probe_permission(uint32_t) {}
|
||||
void haptics_transport_probe_send(uint32_t, uint32_t, bool) {}
|
||||
|
||||
uint64_t time_us_64() {
|
||||
return now_us;
|
||||
}
|
||||
|
||||
void btstack_run_loop_set_timer_handler(
|
||||
btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)) {
|
||||
no_lock();
|
||||
timer->process = handler;
|
||||
}
|
||||
|
||||
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms) {
|
||||
no_lock();
|
||||
// Exactly pico_btstack/btstack_run_loop_async_context.c, including +1.
|
||||
timer->timeout_us = (now_us / 1000 + timeout_ms + 1) * 1000;
|
||||
}
|
||||
|
||||
void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
|
||||
no_lock();
|
||||
assert(timer->process != nullptr);
|
||||
assert(std::find(timers.begin(), timers.end(), timer) == timers.end());
|
||||
timers.push_back(timer);
|
||||
}
|
||||
|
||||
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) {
|
||||
no_lock();
|
||||
const auto it = std::find(timers.begin(), timers.end(), timer);
|
||||
if (it == timers.end()) {
|
||||
return 0;
|
||||
}
|
||||
timers.erase(it);
|
||||
return 1;
|
||||
}
|
||||
|
||||
gap_connection_type_t gap_get_connection_type(uint16_t handle) {
|
||||
no_lock();
|
||||
return handle < devices.size() ? devices[handle].connection_type
|
||||
: GAP_CONNECTION_INVALID;
|
||||
}
|
||||
|
||||
uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid) {
|
||||
no_lock();
|
||||
return device_for_cid(cid)->remote_mtu;
|
||||
}
|
||||
|
||||
uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer) {
|
||||
return buffer->queued == 0;
|
||||
}
|
||||
|
||||
uint8_t l2cap_request_can_send_now_event(uint16_t cid) {
|
||||
no_lock();
|
||||
++request_calls;
|
||||
if (fail_requests != 0) {
|
||||
--fail_requests;
|
||||
return 0x44;
|
||||
}
|
||||
auto* device = device_for_cid(cid);
|
||||
assert(!device->notification_pending);
|
||||
device->notification_pending = true;
|
||||
++request_depth;
|
||||
max_request_depth = std::max(max_request_depth, request_depth);
|
||||
now_us += request_cost_us;
|
||||
if (delivery == Delivery::kImmediate && device->credit) {
|
||||
dispatch(device, cid); // Real BTstack can call here, BEFORE return.
|
||||
}
|
||||
--request_depth;
|
||||
return ERROR_CODE_SUCCESS;
|
||||
}
|
||||
|
||||
uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) {
|
||||
no_lock();
|
||||
auto* device = device_for_cid(cid);
|
||||
assert(permission == device && device->credit);
|
||||
assert(size == 143);
|
||||
++send_calls;
|
||||
const uint64_t submitted_us = now_us;
|
||||
now_us += send_cost_us;
|
||||
if (reenter_send) {
|
||||
assert(haptics_experiment_on_can_send_now(device, cid));
|
||||
const auto concurrent_snapshot = snapshot();
|
||||
assert(concurrent_snapshot.state == HapticsExperimentState::kPending ||
|
||||
concurrent_snapshot.state == HapticsExperimentState::kRunning);
|
||||
}
|
||||
if (fail_sends != 0) {
|
||||
--fail_sends;
|
||||
return 0x55;
|
||||
}
|
||||
Pcm packet{submitted_us, cid, {}};
|
||||
std::copy(data, data + size, packet.bytes.begin());
|
||||
pcm.push_back(packet);
|
||||
return ERROR_CODE_SUCCESS;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
haptics_experiment_prepare();
|
||||
assert(snapshot().state == HapticsExperimentState::kIdle);
|
||||
nominal_run(argv[1]);
|
||||
stalled_deadlines();
|
||||
deferred_and_missing_callbacks();
|
||||
stop_preemption_and_restore();
|
||||
compatibility_queue_and_parser_timers();
|
||||
reconnect_and_pending_generation();
|
||||
support_and_transport_errors();
|
||||
timing_cost_reentrancy_and_wrap();
|
||||
gameplay_timeline_and_lifecycle();
|
||||
gameplay_queued_start_and_command_overflow();
|
||||
stateful_rumble_prepare_feedback_and_zero();
|
||||
stateful_rumble_generation_and_overflow();
|
||||
gameplay_missing_callback_is_bounded();
|
||||
std::cout << "haptics experiment behavioral regressions passed\n";
|
||||
}
|
||||
21
tests/haptics_transport_probe_native_stubs/btstack.h
Normal file
21
tests/haptics_transport_probe_native_stubs/btstack.h
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
constexpr uint8_t HCI_EVENT_PACKET = 4;
|
||||
constexpr uint8_t HCI_EVENT_NUMBER_OF_COMPLETED_PACKETS = 0x13;
|
||||
using hci_con_handle_t = uint16_t;
|
||||
struct hci_connection_t {
|
||||
uint8_t num_packets_sent = 0;
|
||||
};
|
||||
struct btstack_packet_callback_registration_t {
|
||||
void* item = nullptr;
|
||||
void (*callback)(uint8_t, uint16_t, uint8_t*, uint16_t) = nullptr;
|
||||
};
|
||||
extern "C" {
|
||||
void btstack_run_loop_base_poll_data_sources();
|
||||
void btstack_run_loop_poll_data_sources_from_irq();
|
||||
void hci_add_event_handler(btstack_packet_callback_registration_t* registration);
|
||||
hci_connection_t* hci_connection_for_handle(hci_con_handle_t handle);
|
||||
int hci_number_free_acl_slots_for_handle(hci_con_handle_t handle);
|
||||
}
|
||||
13
tests/haptics_transport_probe_native_stubs/cyw43.h
Normal file
13
tests/haptics_transport_probe_native_stubs/cyw43.h
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
int cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length);
|
||||
int cyw43_bluetooth_hci_read(uint8_t* buffer, uint32_t capacity, uint32_t* length);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
#pragma once
|
||||
|
||||
#include <cassert>
|
||||
|
||||
struct critical_section_t {
|
||||
bool initialized = false;
|
||||
};
|
||||
inline unsigned native_probe_lock_depth = 0;
|
||||
inline void critical_section_init(critical_section_t* section) {
|
||||
assert(!section->initialized);
|
||||
section->initialized = true;
|
||||
}
|
||||
inline void critical_section_enter_blocking(critical_section_t* section) {
|
||||
assert(section->initialized && native_probe_lock_depth == 0);
|
||||
++native_probe_lock_depth;
|
||||
}
|
||||
inline void critical_section_exit(critical_section_t*) {
|
||||
assert(native_probe_lock_depth == 1);
|
||||
--native_probe_lock_depth;
|
||||
}
|
||||
4
tests/haptics_transport_probe_native_stubs/pico/stdlib.h
Normal file
4
tests/haptics_transport_probe_native_stubs/pico/stdlib.h
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
uint64_t time_us_64();
|
||||
19
tests/haptics_transport_probe_native_stubs/sdk.cpp
Normal file
19
tests/haptics_transport_probe_native_stubs/sdk.cpp
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#include <btstack.h>
|
||||
#include <cyw43.h>
|
||||
|
||||
extern int (*native_write)(uint8_t*, size_t);
|
||||
extern int (*native_read)(uint8_t*, uint32_t, uint32_t*);
|
||||
extern void (*native_poll)();
|
||||
|
||||
// Keep definitions separate from test callers, just like the real SDK. Tests
|
||||
// exercise GNU ld --wrap, not direct calls to the __wrap_ implementation.
|
||||
extern "C" int cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length) {
|
||||
return native_write(buffer, length);
|
||||
}
|
||||
extern "C" int cyw43_bluetooth_hci_read(uint8_t* buffer, uint32_t capacity,
|
||||
uint32_t* length) {
|
||||
return native_read(buffer, capacity, length);
|
||||
}
|
||||
extern "C" void btstack_run_loop_base_poll_data_sources() {
|
||||
native_poll();
|
||||
}
|
||||
469
tests/haptics_transport_probe_test.cpp
Normal file
469
tests/haptics_transport_probe_test.cpp
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
#include "input/haptics_transport_probe.h"
|
||||
|
||||
#include <btstack.h>
|
||||
#include <cyw43.h>
|
||||
#include <pico/critical_section.h>
|
||||
|
||||
#include <cassert>
|
||||
#include <climits>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint16_t kHandle = 0x123;
|
||||
uint64_t clock_us = 1000;
|
||||
hci_connection_t connection{2};
|
||||
bool connected = true;
|
||||
int free_slots = 7;
|
||||
unsigned registrations = 0;
|
||||
void (*event_handler)(uint8_t, uint16_t, uint8_t*, uint16_t) = nullptr;
|
||||
unsigned writes = 0, reads = 0, polls = 0;
|
||||
uint32_t write_delay = 0, read_delay = 0, poll_delay = 0;
|
||||
int write_result = 0, read_result = 0;
|
||||
uint32_t read_length = 8;
|
||||
uint8_t* last_buffer = nullptr;
|
||||
size_t last_write_length = 0;
|
||||
uint32_t last_capacity = 0;
|
||||
uint32_t* last_length = nullptr;
|
||||
void (*write_action)() = nullptr;
|
||||
void (*read_action)() = nullptr;
|
||||
void (*poll_action)() = nullptr;
|
||||
uint8_t buffer[16]{};
|
||||
bool poll_requested = false;
|
||||
unsigned queued_input = 0;
|
||||
unsigned serviced_input = 0;
|
||||
|
||||
HapticsTransportProbe snapshot() {
|
||||
HapticsTransportProbe result;
|
||||
haptics_transport_probe_snapshot(&result);
|
||||
return result;
|
||||
}
|
||||
|
||||
void unchanged(const HapticsTransportProbe& expected) {
|
||||
const auto actual = snapshot();
|
||||
assert(std::memcmp(&actual, &expected, sizeof(actual)) == 0);
|
||||
}
|
||||
|
||||
void run_action(void (*&action)()) {
|
||||
const auto callback = action;
|
||||
action = nullptr;
|
||||
if (callback != nullptr) callback();
|
||||
}
|
||||
|
||||
int fake_write(uint8_t* data, size_t length) {
|
||||
assert(native_probe_lock_depth == 0);
|
||||
(void)snapshot(); // Real calls may synchronously reenter snapshot readers.
|
||||
++writes;
|
||||
last_buffer = data;
|
||||
last_write_length = length;
|
||||
clock_us += write_delay;
|
||||
run_action(write_action);
|
||||
if (data != nullptr && length != 0) data[0] = 0xa5;
|
||||
return write_result;
|
||||
}
|
||||
|
||||
int fake_read(uint8_t* data, uint32_t capacity, uint32_t* length) {
|
||||
assert(native_probe_lock_depth == 0);
|
||||
(void)snapshot();
|
||||
++reads;
|
||||
last_buffer = data;
|
||||
last_capacity = capacity;
|
||||
last_length = length;
|
||||
clock_us += read_delay;
|
||||
run_action(read_action);
|
||||
if (data != nullptr && capacity != 0) data[0] = 0x5a;
|
||||
if (read_result == 0 && length != nullptr) *length = read_length;
|
||||
return read_result;
|
||||
}
|
||||
|
||||
void fake_poll() {
|
||||
assert(native_probe_lock_depth == 0);
|
||||
(void)snapshot();
|
||||
++polls;
|
||||
clock_us += poll_delay;
|
||||
run_action(poll_action);
|
||||
}
|
||||
|
||||
void begin(uint32_t run = 1, uint16_t handle = kHandle) {
|
||||
haptics_transport_probe_end();
|
||||
clock_us = 1000;
|
||||
connected = true;
|
||||
connection.num_packets_sent = 2;
|
||||
free_slots = 7;
|
||||
writes = reads = polls = 0;
|
||||
write_delay = read_delay = poll_delay = 0;
|
||||
write_result = read_result = 0;
|
||||
read_length = 8;
|
||||
write_action = read_action = poll_action = nullptr;
|
||||
poll_requested = false;
|
||||
haptics_transport_probe_begin(run, 9, handle);
|
||||
assert(registrations == 1);
|
||||
}
|
||||
|
||||
void complete(uint16_t count) {
|
||||
uint8_t event[] = {0x13, 5, 1, 0x23, 0x01,
|
||||
static_cast<uint8_t>(count), static_cast<uint8_t>(count >> 8)};
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event));
|
||||
}
|
||||
|
||||
void test_inactive() {
|
||||
haptics_transport_probe_prepare();
|
||||
haptics_transport_probe_prepare();
|
||||
const auto before = snapshot();
|
||||
write_result = -7;
|
||||
read_result = 17;
|
||||
uint32_t length = 99;
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -7);
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 17);
|
||||
assert(length == 99);
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
haptics_transport_probe_timer(123);
|
||||
haptics_transport_probe_permission(456);
|
||||
haptics_transport_probe_send(789, 1000, true);
|
||||
unchanged(before);
|
||||
assert(writes == 1 && reads == 1 && polls == 1 && registrations == 0);
|
||||
}
|
||||
|
||||
void test_delay_attribution() {
|
||||
begin();
|
||||
poll_action = [] {
|
||||
clock_us += 50; // Generation is outside the caller-supplied send time.
|
||||
const uint32_t send_start = static_cast<uint32_t>(clock_us);
|
||||
clock_us += 7;
|
||||
write_delay = 3000;
|
||||
write_action = [] { connection.num_packets_sent = 5; free_slots = 1; };
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0);
|
||||
clock_us += 11;
|
||||
haptics_transport_probe_send(static_cast<uint32_t>(clock_us) - send_start,
|
||||
static_cast<uint32_t>(clock_us), true);
|
||||
clock_us += 9;
|
||||
uint32_t length = 0;
|
||||
read_delay = 80;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
read_delay = 90;
|
||||
read_length = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
read_delay = 70;
|
||||
read_result = -9;
|
||||
length = 99; // Stale output length on failure is not a packet.
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == -9);
|
||||
clock_us += 30;
|
||||
};
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
auto result = snapshot();
|
||||
assert(result.send_calls == 1 && result.total_send_us == 3018);
|
||||
assert(result.max_send_us == 3018 && result.first_tone_send_return_us == 4068);
|
||||
assert(result.write_calls == 1 && result.total_write_us == 3000);
|
||||
assert(result.max_write_us == 3000);
|
||||
assert(result.read_calls == 3 && result.read_packets == 1);
|
||||
assert(result.total_read_us == 240 && result.max_read_us == 90);
|
||||
assert(result.poll_calls == 1 && result.total_poll_us == 3347);
|
||||
assert(result.max_poll_us == 3347 && result.max_poll_gap_us == 0);
|
||||
assert(result.max_outstanding_acl == 5 && result.min_free_acl == 1);
|
||||
clock_us += 600;
|
||||
poll_delay = 100;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
result = snapshot();
|
||||
assert(result.poll_calls == 2 && result.total_poll_us == 3447);
|
||||
assert(result.max_poll_gap_us == 3947);
|
||||
}
|
||||
|
||||
void test_nested_boundaries() {
|
||||
begin();
|
||||
write_result = -23;
|
||||
write_delay = 10;
|
||||
write_action = [] {
|
||||
write_delay = 30;
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23);
|
||||
clock_us += 7;
|
||||
};
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23);
|
||||
assert(writes == 2 && snapshot().write_calls == 1);
|
||||
assert(snapshot().total_write_us == 47);
|
||||
read_delay = 11;
|
||||
read_action = [] {
|
||||
read_delay = 19;
|
||||
uint32_t length = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
clock_us += 3;
|
||||
};
|
||||
uint32_t length = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
assert(reads == 2 && snapshot().read_calls == 1);
|
||||
assert(snapshot().read_packets == 1 && snapshot().total_read_us == 33);
|
||||
poll_delay = 5;
|
||||
poll_action = [] {
|
||||
poll_delay = 13;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
write_delay = 10;
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23);
|
||||
read_delay = 7;
|
||||
read_length = 0;
|
||||
uint32_t count = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &count) == 0);
|
||||
clock_us += 2;
|
||||
};
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
const auto result = snapshot();
|
||||
assert(polls == 2 && result.poll_calls == 1 && result.total_poll_us == 37);
|
||||
assert(result.write_calls == 2 && result.total_write_us == 57);
|
||||
assert(result.read_calls == 2 && result.total_read_us == 40 && result.read_packets == 1);
|
||||
}
|
||||
|
||||
void test_returns_and_outputs() {
|
||||
begin();
|
||||
write_delay = 2;
|
||||
read_delay = 3;
|
||||
const int results[] = {0, -5, 17, INT_MIN, INT_MAX};
|
||||
for (int status : results) {
|
||||
write_result = read_result = status;
|
||||
buffer[0] = 0;
|
||||
assert(cyw43_bluetooth_hci_write(buffer, SIZE_MAX) == status);
|
||||
assert(last_buffer == buffer && last_write_length == SIZE_MAX && buffer[0] == 0xa5);
|
||||
uint32_t length = 0xfeed;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, UINT32_MAX, &length) == status);
|
||||
assert(last_buffer == buffer && last_capacity == UINT32_MAX && last_length == &length);
|
||||
assert(buffer[0] == 0x5a && length == (status == 0 ? read_length : 0xfeed));
|
||||
}
|
||||
auto result = snapshot();
|
||||
assert(writes == 5 && reads == 5 && result.write_calls == 5 && result.read_calls == 5);
|
||||
assert(result.total_write_us == 10 && result.total_read_us == 15 && result.read_packets == 1);
|
||||
haptics_transport_probe_end();
|
||||
result = snapshot();
|
||||
assert(cyw43_bluetooth_hci_write(nullptr, 0) == INT_MAX);
|
||||
assert(cyw43_bluetooth_hci_read(nullptr, 0, nullptr) == INT_MAX);
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
complete(8);
|
||||
unchanged(result);
|
||||
}
|
||||
|
||||
void test_selected_completions() {
|
||||
begin();
|
||||
uint8_t event[] = {0x13, 9, 2, 0x22, 0, 4, 0, 0x23, 0x01, 3, 0};
|
||||
clock_us = 6000;
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event));
|
||||
assert(snapshot().completion_events == 1 && snapshot().completed_packets == 3);
|
||||
assert(snapshot().max_completion_gap_us == 0);
|
||||
clock_us = 16000;
|
||||
uint8_t other[] = {0x13, 5, 1, 0x22, 0, 7, 0};
|
||||
event_handler(HCI_EVENT_PACKET, 0, other, sizeof(other));
|
||||
clock_us = 30000;
|
||||
complete(2);
|
||||
const auto before = snapshot();
|
||||
assert(before.completion_events == 2 && before.completed_packets == 5);
|
||||
assert(before.max_completion_gap_us == 24000);
|
||||
event_handler(HCI_EVENT_PACKET, 0, nullptr, 7);
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, 2);
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event) - 1);
|
||||
event_handler(2, 0, event, sizeof(event));
|
||||
event[1] = 8; // Inconsistent event parameter length.
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event));
|
||||
event[1] = 9;
|
||||
event[2] = 255; // Handle count exceeds bounded packet storage.
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event));
|
||||
unchanged(before);
|
||||
|
||||
begin(2, 0xffff);
|
||||
complete(3);
|
||||
assert(snapshot().completion_events == 0);
|
||||
assert(snapshot().max_outstanding_acl == 0 && snapshot().min_free_acl == 0);
|
||||
}
|
||||
|
||||
void test_delayed_completion_and_disconnect() {
|
||||
begin();
|
||||
connection.num_packets_sent = 5;
|
||||
free_slots = 1;
|
||||
write_delay = 7;
|
||||
write_action = [] {
|
||||
clock_us += 80000;
|
||||
connection.num_packets_sent = 0;
|
||||
free_slots = 6;
|
||||
complete(5); // Completion delivery reenters an in-flight write.
|
||||
connected = false;
|
||||
connection.num_packets_sent = 255; // The former object is now invalid.
|
||||
};
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0);
|
||||
haptics_transport_probe_end();
|
||||
const auto result = snapshot();
|
||||
assert(result.write_calls == 1 && result.total_write_us == 80007);
|
||||
assert(result.completion_events == 1 && result.completed_packets == 5);
|
||||
assert(result.max_outstanding_acl == 5 && result.min_free_acl == 1);
|
||||
complete(7);
|
||||
unchanged(result);
|
||||
}
|
||||
|
||||
void test_run_changes_during_calls() {
|
||||
begin(7);
|
||||
write_delay = 100;
|
||||
write_action = [] { haptics_transport_probe_end(); };
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0);
|
||||
assert(snapshot().active == 0 && snapshot().write_calls == 0);
|
||||
|
||||
begin(7);
|
||||
read_delay = 100;
|
||||
read_action = [] {
|
||||
haptics_transport_probe_end();
|
||||
haptics_transport_probe_begin(7, 9, kHandle); // Even identical public IDs.
|
||||
haptics_transport_probe_permission(23);
|
||||
};
|
||||
uint32_t length = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
auto result = snapshot();
|
||||
assert(result.active == 1 && result.run_id == 7 && result.connection_generation == 9);
|
||||
assert(result.connection_handle == kHandle && result.read_calls == 0 && result.read_packets == 0);
|
||||
assert(result.permission_callbacks == 1 && result.total_permission_wait_us == 23);
|
||||
|
||||
poll_delay = 100;
|
||||
poll_action = [] {
|
||||
haptics_transport_probe_begin(8, 10, kHandle);
|
||||
haptics_transport_probe_timer(17);
|
||||
};
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
result = snapshot();
|
||||
assert(result.run_id == 8 && result.connection_generation == 10 && result.poll_calls == 0);
|
||||
assert(result.timer_wakes == 1 && result.max_timer_lateness_us == 17);
|
||||
assert(result.permission_callbacks == 0 && result.total_permission_wait_us == 0);
|
||||
clock_us += 500;
|
||||
poll_delay = 20;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
assert(snapshot().poll_calls == 1 && snapshot().total_poll_us == 20);
|
||||
assert(snapshot().max_poll_gap_us == 0);
|
||||
|
||||
haptics_transport_probe_end();
|
||||
poll_action = [] { haptics_transport_probe_begin(9, 11, kHandle); };
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
assert(snapshot().run_id == 9 && snapshot().poll_calls == 0);
|
||||
assert(registrations == 1);
|
||||
}
|
||||
|
||||
void test_saturation_and_clock_wrap() {
|
||||
begin();
|
||||
clock_us = uint64_t{UINT32_MAX} - 20;
|
||||
write_delay = 40;
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0);
|
||||
assert(snapshot().total_write_us == 40);
|
||||
haptics_transport_probe_timer(UINT32_MAX - 2);
|
||||
haptics_transport_probe_timer(10);
|
||||
haptics_transport_probe_permission(UINT32_MAX - 3);
|
||||
haptics_transport_probe_permission(10);
|
||||
haptics_transport_probe_send(UINT32_MAX - 5, 0, true);
|
||||
haptics_transport_probe_send(10, 123, true);
|
||||
const auto result = snapshot();
|
||||
assert(result.timer_wakes == 2 && result.total_timer_lateness_us == UINT32_MAX);
|
||||
assert(result.max_timer_lateness_us == UINT32_MAX - 2);
|
||||
assert(result.permission_callbacks == 2 && result.total_permission_wait_us == UINT32_MAX);
|
||||
assert(result.max_permission_wait_us == UINT32_MAX - 3);
|
||||
assert(result.send_calls == 2 && result.total_send_us == UINT32_MAX);
|
||||
assert(result.max_send_us == UINT32_MAX - 5 && result.first_tone_send_return_us == 0);
|
||||
clock_us = uint64_t{UINT32_MAX} - 5;
|
||||
complete(1);
|
||||
clock_us += 20;
|
||||
complete(1);
|
||||
assert(snapshot().max_completion_gap_us == 20);
|
||||
clock_us = uint64_t{UINT32_MAX} - 5;
|
||||
poll_delay = 5;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
clock_us += 40;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
assert(snapshot().max_poll_gap_us == 45);
|
||||
}
|
||||
|
||||
void receive_one() {
|
||||
read_length = queued_input != 0 ? 8 : 0;
|
||||
uint32_t length = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
if (length != 0) {
|
||||
--queued_input;
|
||||
++serviced_input;
|
||||
}
|
||||
poll_action = receive_one;
|
||||
}
|
||||
|
||||
void test_bounded_receive_progress() {
|
||||
begin();
|
||||
haptics_transport_probe_end(); // Input must progress outside an active run.
|
||||
const auto before = snapshot();
|
||||
queued_input = 3;
|
||||
serviced_input = 0;
|
||||
poll_action = receive_one;
|
||||
poll_requested = true;
|
||||
unsigned timer_opportunities = 0;
|
||||
while (poll_requested && timer_opportunities < 10) {
|
||||
poll_requested = false;
|
||||
const unsigned handled_before = serviced_input;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
assert(serviced_input - handled_before <= 1);
|
||||
++timer_opportunities; // SDK services timers before the next poll.
|
||||
}
|
||||
assert(queued_input == 0 && serviced_input == 3);
|
||||
assert(timer_opportunities == 4 && !poll_requested);
|
||||
unchanged(before);
|
||||
}
|
||||
|
||||
void test_advertised_capacity_capture() {
|
||||
begin();
|
||||
haptics_transport_probe_end();
|
||||
uint8_t response[] = {
|
||||
0, 0, 0, HCI_EVENT_PACKET, 0x0e, 11, 1, 0x05, 0x10,
|
||||
0, 0xfd, 3, 0, 10, 0, 0, 0,
|
||||
};
|
||||
const auto before = snapshot();
|
||||
uint32_t length = 0;
|
||||
read_length = sizeof(response) - 1;
|
||||
assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0);
|
||||
unchanged(before);
|
||||
read_length = sizeof(response);
|
||||
response[9] = 1; // Failed command must not replace advertised capacity.
|
||||
assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0);
|
||||
unchanged(before);
|
||||
response[9] = 0;
|
||||
assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0);
|
||||
assert(snapshot().controller_acl_packet_bytes == 1021);
|
||||
assert(snapshot().controller_acl_packet_count == 10);
|
||||
begin(45);
|
||||
assert(snapshot().controller_acl_packet_count == 10);
|
||||
assert(snapshot().controller_acl_packet_bytes == 1021);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int (*native_write)(uint8_t*, size_t) = fake_write;
|
||||
int (*native_read)(uint8_t*, uint32_t, uint32_t*) = fake_read;
|
||||
void (*native_poll)() = fake_poll;
|
||||
|
||||
uint64_t time_us_64() { return clock_us; }
|
||||
|
||||
extern "C" void btstack_run_loop_poll_data_sources_from_irq() {
|
||||
assert(native_probe_lock_depth == 0);
|
||||
poll_requested = true;
|
||||
}
|
||||
|
||||
extern "C" void hci_add_event_handler(btstack_packet_callback_registration_t* registration) {
|
||||
assert(native_probe_lock_depth == 0 && ++registrations == 1);
|
||||
event_handler = registration->callback;
|
||||
}
|
||||
|
||||
extern "C" hci_connection_t* hci_connection_for_handle(hci_con_handle_t handle) {
|
||||
assert(native_probe_lock_depth == 0);
|
||||
return connected && handle == kHandle ? &connection : nullptr;
|
||||
}
|
||||
|
||||
extern "C" int hci_number_free_acl_slots_for_handle(hci_con_handle_t handle) {
|
||||
assert(native_probe_lock_depth == 0 && connected && handle == kHandle);
|
||||
return free_slots;
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_inactive();
|
||||
test_delay_attribution();
|
||||
test_nested_boundaries();
|
||||
test_returns_and_outputs();
|
||||
test_selected_completions();
|
||||
test_delayed_completion_and_disconnect();
|
||||
test_run_changes_during_calls();
|
||||
test_saturation_and_clock_wrap();
|
||||
test_bounded_receive_progress();
|
||||
test_advertised_capacity_capture();
|
||||
std::puts("haptics transport probe tests passed");
|
||||
}
|
||||
|
|
@ -89,6 +89,7 @@ void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id,
|
|||
uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance);
|
||||
void tud_mount_cb(void);
|
||||
void tud_umount_cb(void);
|
||||
void tud_suspend_cb(bool remote_wakeup_en);
|
||||
uint8_t const* tud_descriptor_device_cb(void);
|
||||
uint8_t const* tud_descriptor_configuration_cb(uint8_t index);
|
||||
uint16_t const* tud_descriptor_string_cb(uint8_t index, uint16_t langid);
|
||||
|
|
|
|||
65
tests/profile_playtest_test.js
Normal file
65
tests/profile_playtest_test.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
|
||||
require(path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"switch_pico_bridge",
|
||||
"web",
|
||||
"profile_playtest.js"
|
||||
));
|
||||
|
||||
const {
|
||||
transformStick,
|
||||
transformTrigger,
|
||||
stickCoordinates,
|
||||
triggerPercent,
|
||||
} = globalThis.ProfilePlaytestMath;
|
||||
|
||||
const defaultStick = {
|
||||
center_x: 0,
|
||||
center_y: 0,
|
||||
inner_deadzone: 0,
|
||||
outer_saturation: 32767,
|
||||
curve_q8_8: 256,
|
||||
invert_x: false,
|
||||
invert_y: false,
|
||||
};
|
||||
const defaultTrigger = {
|
||||
lower_deadzone: 0,
|
||||
upper_saturation: 65535,
|
||||
curve_q8_8: 256,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
transformStick({ x: -1234, y: 2345 }, defaultStick),
|
||||
{ x: -1234, y: 2345 }
|
||||
);
|
||||
assert.ok(stickCoordinates({ x: 0, y: 20000 }).top > 50,
|
||||
"positive/down input must render below center");
|
||||
assert.ok(stickCoordinates({ x: 0, y: -20000 }).top < 50,
|
||||
"negative/up input must render above center");
|
||||
assert.ok(stickCoordinates({ x: -20000, y: 0 }).left < 50,
|
||||
"negative/left input must render left of center");
|
||||
assert.deepEqual(
|
||||
transformStick(
|
||||
{ x: -1234, y: 2345 },
|
||||
{ ...defaultStick, inner_deadzone: 5000 }
|
||||
),
|
||||
{ x: 0, y: 0 }
|
||||
);
|
||||
assert.deepEqual(
|
||||
transformStick(
|
||||
{ x: -30000, y: 30000 },
|
||||
{ ...defaultStick, curve_q8_8: 512 }
|
||||
),
|
||||
{ x: -27665, y: 27664 }
|
||||
);
|
||||
assert.equal(transformTrigger(65000, defaultTrigger), 65000);
|
||||
assert.ok(triggerPercent(65000) > 99,
|
||||
"nonzero trigger input must produce a visible bar");
|
||||
assert.equal(triggerPercent(0), 0);
|
||||
assert.equal(triggerPercent(65535), 100);
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue