feat: add opt-in native Nintendo rumble backend

This commit is contained in:
Joey Yakimowich-Payne 2026-09-05 23:58:50 -06:00
commit 3fc28b1fa4
44 changed files with 4834 additions and 240 deletions

View file

@ -703,7 +703,7 @@ Set B delivery evidence:
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
### Native Switch-family HD rumble — Implemented, qualification incomplete
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.
@ -711,22 +711,25 @@ It includes implementation locations, acceptance checks and current mixed-contro
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.
mode” supports the same rumble protocol. Native output is now implemented
behind persisted per-physical-controller approval; hardware qualification is
tracked separately from the software regression results.
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.
- `ControllerRumbleOutput` retains original bytes with explicit validity and
unmodified provenance beside its decoded/scaled timeline. Unity forwarding
additionally requires agreement with the encoder's physical-state model.
- 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.
- The parser now shares a per-device four-bit counter and last-successful
rumble payload across `0x01`/`0x10` senders. Queued LED reports refresh that
payload at actual submission. Native ownership cancels compatibility timers.
- `switch_native_output.cpp` owns bounded generation-tagged queues, L2CAP
permission-driven delivery, expiry, feedback/resume and held-state coalescing.
- Adapter configuration schema 3 persists up to 16 explicit physical approvals.
Existing profile schema 6/catalog 2, bonds and wake identity are unchanged.
- Joy-Cons are currently separate, horizontally mapped controllers in
Bluepad32. A paired two-Joy-Con logical controller is not implemented.

View file

@ -246,6 +246,8 @@ 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/switch_native_output.cpp
${SWITCH_PICO_SOURCE_DIR}/usb/switch/switch_native_haptics.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
@ -276,6 +278,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
# explicitly reschedules remaining input so timers run between packets.
target_compile_definitions(switch-pico PRIVATE
SWITCH_PICO_BLUEPAD32=1
SWITCH_PICO_NATIVE_SWITCH_RUMBLE=1
SWITCH_PICO_HID_INSTANCE_COUNT=4
SWITCH_PICO_USB_OUTPUT_MODES=1
PICO_FLASH_ASSUME_CORE1_SAFE=0

View file

@ -28,7 +28,7 @@ Sources:
## 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.
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 devices use compatibility output unless explicitly approved for the separate Nintendo-native backend described in `SWITCH_FAMILY_HD_RUMBLE_PLAN.md`.
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.
@ -311,3 +311,12 @@ 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.
The later Nintendo-native implementation adds output traffic that was absent
from this cadence comparison. Its Pro-only controlled run delivered all 1,025
commands at 125 Hz, but early mixed Pro/DualSense runs exposed shared-radio
congestion. Nintendo can-send-driven delivery and held-state coalescing are
separate from the unchanged DualSense 64-frame policy. Consult
[SWITCH_FAMILY_HD_RUMBLE_PLAN.md](SWITCH_FAMILY_HD_RUMBLE_PLAN.md) for measured
results and outstanding qualification; do not treat the DualSense-only output
benchmark above as proof that simultaneous native streams are lossless.

View file

@ -325,7 +325,7 @@ Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and
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).
The standard AIO and XInput builds 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 PCM stream, in any slot; later controllers do not steal it. Nintendo native output is a separate, explicit per-controller opt-in described below. Unapproved and unsupported controllers retain their existing parser-specific output. To change the selected DualSense 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.
@ -333,6 +333,45 @@ Standard native gameplay uses **64 stereo frames at 3 kHz** per Bluetooth report
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.
### Native Nintendo rumble — opt-in, qualification in progress
The AIO backend can send Nintendo report `0x10` directly to an explicitly
approved original Pro Controller or standalone Joy-Con. Approval is keyed to
the physical Bluetooth identity and applies across all eight profiles; matching
a Nintendo name or VID/PID does **not** enable it automatically.
```sh
uv run switch-pico-config profiles list
uv run switch-pico-config config native-rumble approve --identity N --yes
uv run switch-pico-config config native-rumble status --json
uv run switch-pico-config config native-rumble revoke --identity N
```
Use the physical controller's row from `profiles list`, not the global fallback.
`config native-rumble list` also supplies approval indices; `revoke --approval N`
can remove an approval after its profile-catalog entry has been forgotten.
Approvals persist in adapter configuration schema 3 (232 bytes). Schema 1/2
migration preserves existing settings and starts with no approvals; profile
schema 6 and the profile catalog are unchanged.
The native encoder preserves safe unity bytes when synchronized, otherwise
encodes independent actuator/band/substep state with documented quantization.
It has no DualSense PCM lookback or response curve. Native output shares a
per-device counter and effective rumble state with LED subcommands. Identical
held states are coalesced without changing the 50 ms Switch watchdog; active
states refresh at 40 ms. XInput uses held low/left-160-Hz and high/right-320-Hz
effects until explicitly stopped. Standalone Joy-Cons downmix each band by
dominant amplitude, choosing left on ties; logical Joy-Con pairing is not added.
**Qualification is incomplete.** A genuine Pro-only controlled run delivered
all 1,025 commands without new loss or congestion. Earlier mixed Pro/DualSense
runs exposed substantial shared-radio pressure; the latest held-state
coalescing optimization still needs that hardware rerun. Joy-Con hardware,
four-controller operation, captured game effects, and physical actuator timing
are not qualified by the native regression suite. See
[SWITCH_FAMILY_HD_RUMBLE_PLAN.md](SWITCH_FAMILY_HD_RUMBLE_PLAN.md) for the exact
implementation, quantization policy, evidence and remaining checks.
### Hardware validation
The four-interface AIO build has been verified on a real Switch with two DualSense controllers: the Switch assigned independent controller slots, and buttons, sticks, calibrated motion, rumble, and disconnect isolation worked per controller. Fresh DualSense pairing through the BOOTSEL-open window has also been verified on hardware.
@ -363,7 +402,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 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.
- Rumble is controller-specific: UART uses SDL3 haptics; AIO uses the selected DualSense PCM stream, explicitly approved Nintendo native output, or the controller's existing Bluepad32 implementation. Native Nintendo hardware qualification remains incomplete; do not assume universal Switch-mode compatibility.
- 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.
@ -728,15 +767,15 @@ 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 |
| Executable flash image | 777,656 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 |
| Allocated/reserved SRAM, including heap and stacks | 139,344 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
The executable plus persistent reservations consume 1,060,280 bytes of flash,
leaving 3,134,024 bytes. Allocated SRAM sections leave 393,136 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.

View file

@ -1,6 +1,6 @@
# 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”.
Status: **implemented; hardware qualification incomplete**. This handoff now records the implementation and remaining acceptance work. The roadmap entry is in `ADAPTER_PARITY_PLAN.md` under “Native Switch-family HD rumble — Implemented, qualification incomplete”.
## Goal
@ -14,34 +14,118 @@ Start with a genuine original Switch Pro Controller, then original standalone Jo
- `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.
- Standard AIO/XInput builds use 300 MHz/1.3 V and optimized CYW43 transport. DualSense retains its separate 64-frame PCM stream. Nintendo native output requires explicit approval of the stable physical Bluetooth identity; unapproved devices keep compatibility output.
- 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.
- Adapter configuration is now schema 3 / 232 bytes: up to 16 physical Nintendo approvals, independent of profiles. Old schemas 1/2 migrate with no approvals and preserve their existing settings. No controller is approved merely by its name, VID/PID or parser.
- 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.
Nintendo uses no PCM stream. Its small commands still contend for radio scheduling and HCI credits; payload byte rate alone did not predict the measured mixed-controller limit.
## Implemented behavior and current evidence
- `input/switch_native_output.*` owns four bounded, generation-tagged queues.
USB publication uses an IRQ-safe BTstack wake rather than taking the radio
async-context lock. Encoding waits for L2CAP can-send permission. A prepared
schedule that becomes obsolete is discarded and resynchronized, not replayed.
- `usb/switch/switch_native_haptics.*` preserves independent sides/bands and all
representable 1/2/3-substep forms. Safe synchronized unity can retain raw bytes.
Profile changes revoke unmodified provenance. XInput parsing clears reused
HD/raw state rather than accidentally inheriting Nintendo data.
- Safe amplitude codes stop at 100 (decoder LUT index 228 / Q15 17867).
Q15 is not a wire amplitude. Frequencies clamp to indices 1..127. Exact
one-packet forms are preferred, then bounded two-packet prefixes/baselines.
Unrepresentable scaled sequences retain their time slots and choose legal
commands minimizing `abs(Q15 error) + 128 * abs(frequency-index error)`,
with lower-command ties and exact silence. This is documented quantization,
**not** a promise of lossless or perceptually equivalent arbitrary scaling.
- Mono selects the dominant contribution per band before rounding, with left
ties. Unequal side counts use the larger count and hold the shorter side's
final sample; that temporal quantization is reported. No Joy-Con pairing.
- Parser counters and effective rumble are per physical device. Queued
subcommands refresh rumble and LED state at actual submission. Native
ownership cancels duration/delayed/refresh compatibility timers; detach
retires state without writing to a dead connection.
- Local feedback overrides output without freezing the host timeline.
Switch commands expire after 50 ms. Unchanged held states coalesce while
extending that watchdog; active states retain a 40 ms refresh. XInput holds
use left-low 160 Hz / right-high 320 Hz until explicit stop.
- Read-only management operation `0x43`, diagnostic schema 2, returns four
80-byte rows. It separates received, HCI-completed, coalesced and dropped
commands. Latency percentiles are 250-us histogram upper bounds for actual
submissions; coalesced holds are excluded. No physical-onset claim.
Opt-in commands:
```sh
uv run switch-pico-config profiles list
uv run switch-pico-config config native-rumble approve --identity N --yes
uv run switch-pico-config config native-rumble status --json
uv run switch-pico-config config native-rumble revoke --identity N
```
`--identity N` uses the physical row from `profiles list`. The separate
`native-rumble list` command lists persisted approval indices; revocation by
`--approval N` works even after a controller leaves the profile catalog.
### Qualification checkpoint
- **Software:** 260 repository tests pass, including independent absolute
packet vectors, scaled/relative codec cases, actual patched parser/queue
tests, and 17 owner lifecycle/credit/coalescing scenarios. All five final
firmware variants build; AIO, feasibility and UART artifacts are refreshed.
- **Persistent data:** all 24 profiles, active indices, aliases and names
matched the pre-migration hardware checkpoint. Configuration migrated
generation 9 → 10; explicit approval of the attached Pro produced 11.
Profile schema/catalog, bonds and wake identity were not changed.
- **Pro-only radio:** genuine Pro reply firmware bytes `03 48`; a controlled
pre-coalescing 125-Hz run completed all **1,025 commands**, with **1,025
submitted reports**, **zero new drops**, and **zero congestion attempts**.
- **Mixed radio before coalescing:** can-send-driven Pro isolation with
DualSense connected completed 195 of 513 commands and dropped 318; the
idle DualSense PCM stream skipped 30 slots. Stopping its PCM stream but
keeping its input connection completed 276/513 and dropped 237. These
failures must not be relabeled as successful fidelity qualification.
- **Next hardware check:** the held-state-coalescing build is flashed and
the Pro approval persists. The Pro did not reconnect after that flash;
press its normal Home button, leaving DualSense off initially. Repeat
actuator/band isolation and confirm physical vibration, then rerun with
DualSense input/PCM and distinguish repeated holds from every-command
state changes. Reconnect, approval revocation/resume, LEDs, scaling and
stateful XInput still need integrated hardware checks.
- **Unavailable evidence:** no real-console USB/BT rumble capture corpus,
original Joy-Con L/R qualification, four-controller hardware result or
instrumented actuator onset has been obtained. Do not infer these from
synthetic vectors or HCI acceptance.
Linux's current Nintendo driver also documents disconnect risk from excessive
output traffic and uses input-report-aware throttling. This is corroborating
timing evidence, not code incorporated into this project:
https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c
## 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. |
| Switch host decoder | `src/firmware/usb/switch/switch_haptics.h/.cpp`, `SwitchHapticsDecoder`, `ControllerRumbleOutput`, `SwitchHapticsFrame` | Two sides with up to three substeps; original eight bytes now have explicit validity/unmodified provenance. |
| 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. |
| Native Nintendo owner/encoder | `src/firmware/input/switch_native_output.*`, `src/firmware/usb/switch/switch_native_haptics.*` | Per-physical opt-in, bounded queues, can-send-driven serialization, safe encoding and coalescing. |
| 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
### Parser ownership decisions
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.
1. Sequence state is per physical parser instance, shared by rumble/subcommands.
2. Applicable subcommands carry the exact last-successful rumble bytes at send.
3. Ownership transitions cancel compatibility timers and retire queued rumble.
4. `switch_encode_rumble()` remains the conventional fixed-frequency fallback;
independent native bands and compressed substeps use the new C++ encoder.
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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -265,10 +265,9 @@ index 0265f93..5c0f2bb 100644
return (psmove_instance_t*)&d->parser_data[0];
}
diff --git a/src/components/bluepad32/parser/uni_hid_parser_switch.c b/src/components/bluepad32/parser/uni_hid_parser_switch.c
index 599fc35..0105c53 100644
--- a/src/components/bluepad32/parser/uni_hid_parser_switch.c
+++ b/src/components/bluepad32/parser/uni_hid_parser_switch.c
@@ -51,13 +51,22 @@ static const int16_t DEFAULT_ACCEL_OFFSET = 0;
@@ -51,13 +51,22 @@
static const int16_t DEFAULT_ACCEL_SCALE = 16384;
static const int16_t DEFAULT_GYRO_OFFSET = 0;
static const int16_t DEFAULT_GYRO_SCALE = 13371;
@ -292,7 +291,7 @@ index 599fc35..0105c53 100644
#if ENABLE_SPI_FLASH_DUMP
static const uint32_t SWITCH_DUMP_ROM_DATA_ADDR_START = 0x20000;
static const uint32_t SWITCH_DUMP_ROM_DATA_ADDR_END = 0x30000;
@@ -72,6 +81,7 @@ enum switch_state {
@@ -72,6 +81,7 @@
STATE_READ_FACTORY_IMU_CALIBRATION, // Factory IMU calibration info
STATE_SET_FULL_REPORT, // Request report 0x30
STATE_ENABLE_IMU, // Enable/Disable gyro/accel
@ -300,7 +299,7 @@ index 599fc35..0105c53 100644
STATE_DUMP_FLASH, // Dump SPI Flash memory
STATE_UPDATE_LED, // Update LEDs
STATE_READY, // Gamepad setup ready!
@@ -111,6 +121,7 @@ enum switch_subcmd {
@@ -111,6 +121,7 @@
SUBCMD_SPI_FLASH_READ = 0x10,
SUBCMD_SET_PLAYER_LEDS = 0x30,
SUBCMD_ENABLE_IMU = 0x40,
@ -308,15 +307,20 @@ index 599fc35..0105c53 100644
};
typedef enum {
@@ -137,6 +148,7 @@ typedef struct switch_instance_s {
@@ -137,7 +148,12 @@
// Although technically, we can use one timer for delay and duration, easier to debug/maintain if we have two.
btstack_timer_source_t rumble_timer_duration;
btstack_timer_source_t rumble_timer_delayed_start;
+ btstack_timer_source_t rumble_timer_refresh;
switch_state_rumble_t rumble_state;
+ bool native_owned;
+ bool device_info_valid;
+ uint8_t packet_num;
+ uint8_t effective_rumble[8];
btstack_timer_source_t setup_timer;
@@ -322,6 +334,7 @@ static void fsm_read_user_stick_calibration(struct uni_hid_device_s* d);
@@ -322,6 +338,7 @@
static void fsm_read_factory_imu_calibration(struct uni_hid_device_s* d);
static void fsm_set_full_report(struct uni_hid_device_s* d);
static void fsm_enable_imu(struct uni_hid_device_s* d);
@ -324,7 +328,7 @@ index 599fc35..0105c53 100644
static void fsm_update_led(struct uni_hid_device_s* d);
static void fsm_ready(struct uni_hid_device_s* d);
static void process_reply_read_spi_dump(struct uni_hid_device_s* d, const uint8_t* data, int len);
@@ -333,11 +346,16 @@ static void process_reply_set_report_mode(struct uni_hid_device_s* d, const stru
@@ -333,23 +350,33 @@
static void process_reply_spi_flash_read(struct uni_hid_device_s* d, const struct switch_report_21_s* r, int len);
static void process_reply_set_player_leds(struct uni_hid_device_s* d, const struct switch_report_21_s* r, int len);
static void process_reply_enable_imu(struct uni_hid_device_s* d, const struct switch_report_21_s* r, int len);
@ -341,7 +345,24 @@ index 599fc35..0105c53 100644
static void switch_play_dual_rumble_now(uni_hid_device_t* d,
uint16_t duration_ms,
uint8_t weak_magnitude,
@@ -451,6 +469,10 @@ static void process_fsm(struct uni_hid_device_s* d) {
uint8_t strong_magnitude);
static void switch_setup_timeout_callback(btstack_timer_source_t* ts);
static void parse_stick_calibration(switch_cal_stick_t* x, switch_cal_stick_t* y, const uint8_t* data, bool is_left);
+static const uint8_t switch_neutral_rumble[8] = {
+ 0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40,
+};
+static void switch_cancel_rumble_timers(switch_instance_t* ins);
void uni_hid_parser_switch_setup(struct uni_hid_device_s* d) {
switch_instance_t* ins = get_switch_instance(d);
memset(ins, 0, sizeof(*ins));
+ memcpy(ins->effective_rumble, switch_neutral_rumble, sizeof(ins->effective_rumble));
ins->state = STATE_SETUP;
ins->mode = SWITCH_MODE_NONE;
// In case the controller doesn't answer to SUBCMD_REQ_DEV_INFO command, set a default one.
@@ -451,6 +478,10 @@
break;
case STATE_ENABLE_IMU:
logd("STATE_ENABLE_IMU\n");
@ -352,7 +373,25 @@ index 599fc35..0105c53 100644
fsm_dump_rom(d);
break;
case STATE_DUMP_FLASH:
@@ -725,6 +747,12 @@ static void process_reply_enable_imu(struct uni_hid_device_s* d, const struct sw
@@ -648,7 +679,8 @@
// Reply to SUBCMD_REQ_DEV_INFO
static void process_reply_req_dev_info(struct uni_hid_device_s* d, const struct switch_report_21_s* r, int len) {
- ARG_UNUSED(len);
+ if (len < (int)sizeof(*r) + 3 || !(r->ack & 0x80))
+ return;
switch_instance_t* ins = get_switch_instance(d);
if (ins->state > STATE_SETUP && ins->mode == SWITCH_MODE_NONE) {
bool enable_imu;
@@ -669,6 +701,7 @@
ins->firmware_version_hi = r->data[0];
ins->firmware_version_lo = r->data[1];
ins->controller_type = r->data[2];
+ ins->device_info_valid = true;
logi("Switch: Firmware version: %d.%d. Controller type=%d\n", r->data[0], r->data[1], r->data[2]);
}
@@ -725,6 +758,12 @@
ARG_UNUSED(r);
ARG_UNUSED(len);
}
@ -365,17 +404,26 @@ index 599fc35..0105c53 100644
// Process 0x21 input report: SWITCH_INPUT_SUBCMD_REPLY
static void process_input_subcmd_reply(struct uni_hid_device_s* d, const uint8_t* report, int len) {
@@ -752,6 +780,9 @@ static void process_input_subcmd_reply(struct uni_hid_device_s* d, const uint8_t
@@ -733,6 +772,8 @@
// 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
// 00
const struct switch_report_21_s* r = (const struct switch_report_21_s*)report;
+ if (len < (int)sizeof(*r))
+ return;
if ((r->ack & 0b10000000) == 0) {
loge("Switch: Error, subcommand id=0x%02x was not successful.\n", r->subcmd_id);
}
@@ -751,6 +792,9 @@
break;
case SUBCMD_ENABLE_IMU:
process_reply_enable_imu(d, r, len);
break;
+ break;
+ case SUBCMD_ENABLE_RUMBLE:
+ process_reply_enable_rumble(d, r, len);
+ break;
break;
default:
loge("Switch: Error, unexpected subcmd_id=0x%02x in report 0x21\n", r->subcmd_id);
break;
@@ -823,19 +854,26 @@ static void parse_imu(uni_hid_device_t* d, const struct switch_imu_data_s* r) {
@@ -823,19 +867,26 @@
switch_instance_t* ins = get_switch_instance(d);
uni_controller_t* ctl = &d->controller;
@ -391,6 +439,9 @@ index 599fc35..0105c53 100644
- accel[i] = (r->accel[i] * ins->cal_accel.scale[i]) / ins->imu_cal_accel_divisor[i];
- gyro[i] = mult_frac((SWITCH_IMU_PREC_RANGE_SCALE * (r->gyro[i] - ins->cal_gyro.offset[i])),
- ins->cal_gyro.scale[i], ins->imu_cal_gyro_divisor[i]);
- }
-
- // Right joycon has Y and Z axes negated.
+ if (ins->imu_cal_accel_divisor[i] == 0) {
+ accel[i] = r->accel[i] * 2;
+ } else {
@ -404,14 +455,13 @@ index 599fc35..0105c53 100644
+ 936 * SWITCH_IMU_GYRO_RES_PER_DEG_S,
+ ins->imu_cal_gyro_divisor[i]);
+ }
}
- // Right joycon has Y and Z axes negated.
+ }
+
+ // Right Joy-Con has native Y and Z axes negated.
if (ins->controller_type == SWITCH_CONTROLLER_TYPE_JCR) {
accel[1] = -accel[1];
accel[2] = -accel[2];
@@ -843,10 +881,13 @@ static void parse_imu(uni_hid_device_t* d, const struct switch_imu_data_s* r) {
@@ -843,10 +894,13 @@
gyro[2] = -gyro[2];
}
@ -429,7 +479,7 @@ index 599fc35..0105c53 100644
}
// Process 0x30 input report: SWITCH_INPUT_IMU_DATA
@@ -1172,6 +1213,18 @@ static void fsm_enable_imu(struct uni_hid_device_s* d) {
@@ -1172,6 +1226,18 @@
req->data[0] = (ins->mode == SWITCH_MODE_IMU);
send_subcmd(d, req, sizeof(out));
}
@ -448,7 +498,7 @@ index 599fc35..0105c53 100644
static void fsm_update_led(struct uni_hid_device_s* d) {
switch_instance_t* ins = get_switch_instance(d);
@@ -1203,6 +1256,13 @@ static struct switch_rumble_freq_data find_rumble_freq(uint16_t freq) {
@@ -1203,6 +1269,13 @@
return rumble_freqs[i];
}
@ -462,7 +512,15 @@ index 599fc35..0105c53 100644
static struct switch_rumble_amp_data find_rumble_amp(uint16_t amp) {
unsigned int i = 0;
if (amp > rumble_amps[0].amp) {
@@ -1259,6 +1319,7 @@ void uni_hid_parser_switch_play_dual_rumble(struct uni_hid_device_s* d,
@@ -1253,12 +1326,15 @@
}
switch_instance_t* ins = get_switch_instance(d);
+ if (ins->native_owned || ins->state == STATE_UNINIT)
+ return;
switch (ins->rumble_state) {
case SWITCH_STATE_RUMBLE_DELAYED:
btstack_run_loop_remove_timer(&ins->rumble_timer_delayed_start);
break;
case SWITCH_STATE_RUMBLE_IN_PROGRESS:
btstack_run_loop_remove_timer(&ins->rumble_timer_duration);
@ -470,15 +528,181 @@ index 599fc35..0105c53 100644
break;
default:
// Do nothing
@@ -1366,6 +1427,7 @@ static void switch_stop_rumble_now(uni_hid_device_t* d) {
@@ -1338,12 +1414,157 @@
}
// No need to protect it with a mutex since it runs in the same main thread
assert(ins->rumble_state == SWITCH_STATE_RUMBLE_IN_PROGRESS);
static void send_subcmd(uni_hid_device_t* d, struct switch_subcmd_request* r, int len) {
- static uint8_t packet_num = 0;
- r->packet_num = packet_num++;
- if (packet_num > 0x0f)
- packet_num = 0;
+ // Counter and effective rumble are refreshed by the device-send hook at
+ // actual submission, not here: this report may spend time in the queue.
r->transaction_type = (HID_MESSAGE_TYPE_DATA << 4) | HID_REPORT_TYPE_OUTPUT;
uni_hid_device_send_intr_report(d, (const uint8_t*)r, len);
+}
+
+static void switch_cancel_rumble_timers(switch_instance_t* ins) {
+ btstack_run_loop_remove_timer(&ins->rumble_timer_duration);
+ btstack_run_loop_remove_timer(&ins->rumble_timer_delayed_start);
+ btstack_run_loop_remove_timer(&ins->rumble_timer_refresh);
+ ins->rumble_timer_duration.context = NULL;
+ ins->rumble_timer_delayed_start.context = NULL;
+ ins->rumble_timer_refresh.context = NULL;
+ ins->rumble_state = SWITCH_STATE_RUMBLE_DISABLED;
+}
+
+// Preserve queued setup/LED subcommands, but retire every old rumble writer
+// across ownership changes. Compact in place with a fixed queue-size bound.
+static void switch_discard_queued_rumble(uni_hid_device_t* d) {
+ uni_circular_buffer_t* queue = &d->outgoing_buffer;
+ int read_idx = queue->head_idx;
+ int write_idx = read_idx;
+ for (int n = 0; n < UNI_CIRCULAR_BUFFER_SIZE && read_idx != queue->tail_idx; ++n) {
+ const uni_circular_buffer_data_t* entry = &queue->buffer[read_idx];
+ const bool rumble = entry->cid == d->conn.interrupt_cid &&
+ entry->data_len >= 2 && entry->data[0] == 0xa2 &&
+ entry->data[1] == OUTPUT_RUMBLE_ONLY;
+ if (!rumble) {
+ if (write_idx != read_idx)
+ queue->buffer[write_idx] = *entry;
+ write_idx = (write_idx + 1) % UNI_CIRCULAR_BUFFER_SIZE;
+ }
+ read_idx = (read_idx + 1) % UNI_CIRCULAR_BUFFER_SIZE;
+ }
+ queue->tail_idx = write_idx;
+}
+
+// Both writers use this at the actual L2CAP boundary. Failed sends neither
+// consume a counter nor change the effective actuator-word cache.
+static int switch_submit_report(uni_hid_device_t* d, uint16_t cid, uint8_t* report, uint16_t len) {
+ switch_instance_t* ins = get_switch_instance(d);
+ report[2] = ins->packet_num;
+ const int status = l2cap_send(cid, report, len);
+ if (status == ERROR_CODE_SUCCESS) {
+ ins->packet_num = (ins->packet_num + 1) & 0x0f;
+ memcpy(ins->effective_rumble, &report[3], sizeof(ins->effective_rumble));
+ }
+ return status;
+}
+
+int uni_hid_parser_switch_send_report(uni_hid_device_t* d, uint16_t cid,
+ const uint8_t* report, uint16_t len) {
+ switch_instance_t* ins = get_switch_instance(d);
+ if (ins->state == STATE_UNINIT)
+ return ERROR_CODE_SUCCESS; // A retired device must not enqueue work.
+ if (cid != d->conn.interrupt_cid || len < 11 || report[0] != 0xa2 ||
+ (report[1] != OUTPUT_RUMBLE_ONLY && report[1] != OUTPUT_RUMBLE_AND_SUBCMD))
+ return l2cap_send(cid, (uint8_t*)report, len);
+ if (ins->native_owned && report[1] == OUTPUT_RUMBLE_ONLY)
+ return ERROR_CODE_SUCCESS; // Only native_send may write this owner.
+ if (len > UNI_CIRCULAR_BUFFER_DATA_SIZE)
+ return ERROR_CODE_COMMAND_DISALLOWED;
+
+ uint8_t outgoing[UNI_CIRCULAR_BUFFER_DATA_SIZE];
+ memcpy(outgoing, report, len);
+ if (report[1] == OUTPUT_RUMBLE_AND_SUBCMD) {
+ // Repeating the SAME last accepted actuator word is Nintendo's hold
+ // behavior, including compressed words. Never use the queued copy:
+ // replaying an earlier word after intervening audio reapplies deltas.
+ // Do not synthesize an absolute endpoint here either: it changes the
+ // repeated-word history. Physical acceptance is model-qualified.
+ memcpy(&outgoing[3], ins->effective_rumble, sizeof(ins->effective_rumble));
+ if (len >= 13 && outgoing[11] == SUBCMD_SET_PLAYER_LEDS)
+ outgoing[12] = ins->gamepad_seat & 0x0f;
+ }
+ return switch_submit_report(d, cid, outgoing, len);
+}
+
+bool uni_hid_parser_switch_native_info(uni_hid_device_t* d,
+ uint8_t* type, uint8_t* firmware_hi,
+ uint8_t* firmware_lo) {
+ if (!d || d->report_parser.setup != uni_hid_parser_switch_setup)
+ return false;
+ switch_instance_t* ins = get_switch_instance(d);
+ if (ins->state != STATE_READY || !ins->device_info_valid)
+ return false;
+ if (type)
+ *type = ins->controller_type;
+ if (firmware_hi)
+ *firmware_hi = ins->firmware_version_hi;
+ if (firmware_lo)
+ *firmware_lo = ins->firmware_version_lo;
+ return true;
+}
+
+bool uni_hid_parser_switch_native_acquire(uni_hid_device_t* d) {
+ uint8_t type;
+ if (!uni_hid_parser_switch_native_info(d, &type, NULL, NULL) ||
+ type < SWITCH_CONTROLLER_TYPE_JCL || type > SWITCH_CONTROLLER_TYPE_PRO ||
+ !d->conn.connected || !d->conn.interrupt_cid)
+ return false;
+ switch_instance_t* ins = get_switch_instance(d);
+ switch_cancel_rumble_timers(ins);
+ switch_discard_queued_rumble(d);
+ ins->native_owned = true;
+ return true;
+}
+
+bool uni_hid_parser_switch_native_send(uni_hid_device_t* d, const uint8_t rumble[8]) {
+ if (!d || !rumble || d->report_parser.setup != uni_hid_parser_switch_setup)
+ return false;
+ switch_instance_t* ins = get_switch_instance(d);
+ if (!ins->native_owned || ins->state != STATE_READY || !d->conn.connected ||
+ !d->conn.interrupt_cid || !l2cap_can_send_packet_now(d->conn.interrupt_cid))
+ return false;
+ uint8_t report[11] = {0xa2, OUTPUT_RUMBLE_ONLY, 0};
+ memcpy(&report[3], rumble, 8);
+ return switch_submit_report(d, d->conn.interrupt_cid, report, sizeof(report)) == ERROR_CODE_SUCCESS;
+}
+
+void uni_hid_parser_switch_native_release(uni_hid_device_t* d) {
+ if (!d || d->report_parser.setup != uni_hid_parser_switch_setup)
+ return;
+ switch_instance_t* ins = get_switch_instance(d);
+ if (!ins->native_owned)
+ return;
+ switch_cancel_rumble_timers(ins);
+ switch_discard_queued_rumble(d);
+ ins->native_owned = false;
+ if (ins->state == STATE_READY && d->conn.connected && d->conn.interrupt_cid &&
+ memcmp(ins->effective_rumble, switch_neutral_rumble, sizeof(ins->effective_rumble)) != 0) {
+ // A neutral stop may queue under congestion, never an audio command.
+ // The owner normally stops first; never queue a redundant delayed stop.
+ struct switch_subcmd_request req = {.report_id = OUTPUT_RUMBLE_ONLY};
+ memcpy(req.rumble_left, switch_neutral_rumble, 4);
+ memcpy(req.rumble_right, &switch_neutral_rumble[4], 4);
+ send_subcmd(d, &req, 11);
+ }
+}
+
+void uni_hid_parser_switch_teardown(uni_hid_device_t* d) {
+ if (!d || d->report_parser.setup != uni_hid_parser_switch_setup)
+ return;
+ switch_instance_t* ins = get_switch_instance(d);
+ switch_cancel_rumble_timers(ins);
+ btstack_run_loop_remove_timer(&ins->setup_timer);
+ ins->setup_timer.context = NULL;
+ ins->native_owned = false;
+ ins->device_info_valid = false;
+ ins->state = STATE_UNINIT;
+ uni_circular_buffer_reset(&d->outgoing_buffer);
}
static int32_t calibrate_axis(int32_t v, switch_cal_stick_t cal) {
@@ -1364,8 +1585,9 @@
static void switch_stop_rumble_now(uni_hid_device_t* d) {
switch_instance_t* ins = get_switch_instance(d);
- // No need to protect it with a mutex since it runs in the same main thread
- assert(ins->rumble_state == SWITCH_STATE_RUMBLE_IN_PROGRESS);
+ if (ins->native_owned || ins->state == STATE_UNINIT)
+ return;
+ switch_cancel_rumble_timers(ins);
ins->rumble_state = SWITCH_STATE_RUMBLE_DISABLED;
struct switch_subcmd_request req = {0};
@@ -1379,6 +1441,24 @@ static void switch_stop_rumble_now(uni_hid_device_t* d) {
@@ -1379,11 +1601,31 @@
send_subcmd(d, (struct switch_subcmd_request*)&req, sizeof(req) - 1);
}
@ -503,7 +727,14 @@ index 599fc35..0105c53 100644
static void switch_play_dual_rumble_now(uni_hid_device_t* d,
uint16_t duration_ms,
uint8_t weak_magnitude,
@@ -1391,14 +1471,17 @@ static void switch_play_dual_rumble_now(uni_hid_device_t* d,
uint8_t strong_magnitude) {
switch_instance_t* ins = get_switch_instance(d);
+ if (ins->native_owned || ins->state == STATE_UNINIT)
+ return;
if (duration_ms == 0) {
if (ins->rumble_state != SWITCH_STATE_RUMBLE_DISABLED)
@@ -1391,14 +1633,17 @@
return;
}
@ -512,12 +743,13 @@ index 599fc35..0105c53 100644
- };
- switch_encode_rumble(req.rumble_left, weak_magnitude << 2, weak_magnitude, 500);
- switch_encode_rumble(req.rumble_right, strong_magnitude << 2, strong_magnitude, 500);
-
- // Rumble request don't include the last byte of "switch_subcmd_request": subcmd_id
- send_subcmd(d, &req, sizeof(req) - 1);
+ ins->rumble_weak_magnitude = weak_magnitude;
+ ins->rumble_strong_magnitude = strong_magnitude;
+ switch_send_dual_rumble_now(d, weak_magnitude, strong_magnitude);
- // Rumble request don't include the last byte of "switch_subcmd_request": subcmd_id
- send_subcmd(d, &req, sizeof(req) - 1);
+
+ // Refresh active rumble for Switch-compatible controllers that do not
+ // retain a single output packet, including 8BitDo Switch mode.
+ ins->rumble_timer_refresh.process = &on_switch_refresh_rumble;
@ -528,14 +760,28 @@ index 599fc35..0105c53 100644
// Set timer to turn off rumble
ins->rumble_timer_duration.process = &on_switch_set_rumble_off;
@@ -1414,6 +1497,20 @@ static void on_switch_set_rumble_on(btstack_timer_source_t* ts) {
@@ -1410,19 +1655,52 @@
static void on_switch_set_rumble_on(btstack_timer_source_t* ts) {
uni_hid_device_t* d = ts->context;
- switch_instance_t* ins = get_switch_instance(d);
+ if (!d || d->report_parser.setup != uni_hid_parser_switch_setup)
+ return;
+ switch_instance_t* ins = get_switch_instance(d);
+ if (ts != &ins->rumble_timer_delayed_start || ins->native_owned ||
+ ins->rumble_state != SWITCH_STATE_RUMBLE_DELAYED)
+ return;
+ ts->context = NULL;
switch_play_dual_rumble_now(d, ins->rumble_duration_ms, ins->rumble_weak_magnitude, ins->rumble_strong_magnitude);
}
+static void on_switch_refresh_rumble(btstack_timer_source_t* ts) {
+ uni_hid_device_t* d = btstack_run_loop_get_timer_context(ts);
+ if (!d || d->report_parser.setup != uni_hid_parser_switch_setup)
+ return;
+ switch_instance_t* ins = get_switch_instance(d);
+ if (ins->rumble_state != SWITCH_STATE_RUMBLE_IN_PROGRESS) {
+ if (ts != &ins->rumble_timer_refresh || ins->native_owned ||
+ ins->rumble_state != SWITCH_STATE_RUMBLE_IN_PROGRESS) {
+ return;
+ }
+ switch_send_dual_rumble_now(
@ -549,6 +795,26 @@ index 599fc35..0105c53 100644
static void on_switch_set_rumble_off(btstack_timer_source_t* ts) {
uni_hid_device_t* d = btstack_run_loop_get_timer_context(ts);
+ if (!d || d->report_parser.setup != uni_hid_parser_switch_setup)
+ return;
+ switch_instance_t* ins = get_switch_instance(d);
+ if (ts != &ins->rumble_timer_duration || ins->native_owned ||
+ ins->rumble_state != SWITCH_STATE_RUMBLE_IN_PROGRESS)
+ return;
switch_stop_rumble_now(d);
}
void switch_setup_timeout_callback(btstack_timer_source_t* ts) {
uni_hid_device_t* d = btstack_run_loop_get_timer_context(ts);
- switch_instance_t* ins = get_switch_instance(d);
+ if (!d || d->report_parser.setup != uni_hid_parser_switch_setup)
+ return;
+ switch_instance_t* ins = get_switch_instance(d);
+ if (ts != &ins->setup_timer || ins->state == STATE_UNINIT)
+ return;
logi("Switch: setup timer timeout, failed state: 0x%02x\n", ins->state);
process_fsm(d);
}
diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/components/bluepad32/parser/uni_hid_parser_wii.c
index be2103e..4819639 100644
--- a/src/components/bluepad32/parser/uni_hid_parser_wii.c
@ -573,10 +839,29 @@ index be2103e..4819639 100644
// Dpad works as dpad, useful to navigate menus.
ctl->gamepad.dpad |= (report[1] & 0x01) ? DPAD_DOWN : 0;
diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad32/uni_hid_device.c
index 67841e8..9fe7134 100644
--- a/src/components/bluepad32/uni_hid_device.c
+++ b/src/components/bluepad32/uni_hid_device.c
@@ -655,6 +655,7 @@ void uni_hid_device_guess_controller_type_from_pid_vid(uni_hid_device_t* d) {
@@ -447,6 +447,9 @@
return;
}
+ if (d->report_parser.setup == uni_hid_parser_switch_setup)
+ uni_hid_parser_switch_teardown(d);
+
// Disconnect child first
if (d->child)
uni_hid_device_disconnect(d->child);
@@ -490,6 +493,9 @@
loge("uni_hid_device_delete: invalid hid device: NULL\n");
return;
}
+ if (d->report_parser.setup == uni_hid_parser_switch_setup)
+ uni_hid_parser_switch_teardown(d);
+
// Delete child first
if (d->child)
@@ -655,6 +661,7 @@
d->report_parser.setup = uni_hid_parser_psmove_setup;
d->report_parser.init_report = uni_hid_parser_psmove_init_report;
d->report_parser.parse_input_report = uni_hid_parser_psmove_parse_input_report;
@ -584,6 +869,19 @@ 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);
@@ -809,7 +816,11 @@
return;
}
- int err = l2cap_send(cid, (uint8_t*)report, len);
+ int err;
+ if (d->report_parser.setup == uni_hid_parser_switch_setup)
+ err = uni_hid_parser_switch_send_report(d, cid, report, len);
+ else
+ err = l2cap_send(cid, (uint8_t*)report, len);
if (err != 0) {
logd("Could not send report (error=0x%04x). Adding it to queue\n", err);
if (uni_circular_buffer_put(&d->outgoing_buffer, cid, report, len) != 0) {
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
@ -611,3 +909,29 @@ diff --git a/src/components/bluepad32/bt/uni_bt.c b/src/components/bluepad32/bt/
uni_hid_device_send_queued_reports(device);
}
break;
diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_switch.h b/src/components/bluepad32/include/parser/uni_hid_parser_switch.h
--- a/src/components/bluepad32/include/parser/uni_hid_parser_switch.h
+++ b/src/components/bluepad32/include/parser/uni_hid_parser_switch.h
@@ -23,4 +23,22 @@
bool uni_hid_parser_switch_does_name_match(struct uni_hid_device_s* d, const char* name);
void uni_hid_parser_switch_device_dump(struct uni_hid_device_s* d);
+// BTstack-thread-only native owner. Qualification belongs to the caller:
+// type and firmware are unverified bytes from a successful device-info reply.
+bool uni_hid_parser_switch_native_info(struct uni_hid_device_s* d,
+ uint8_t* type, uint8_t* firmware_hi,
+ uint8_t* firmware_lo);
+bool uni_hid_parser_switch_native_acquire(struct uni_hid_device_s* d);
+// Retires compatibility timers and requests neutral; no audio is queued.
+void uni_hid_parser_switch_native_release(struct uni_hid_device_s* d);
+// Exactly 11 bytes on interrupt L2CAP. False means NOT submitted, never queued.
+// Caller must retry/resynchronize; true is transport acceptance, not playback.
+bool uni_hid_parser_switch_native_send(struct uni_hid_device_s* d, const uint8_t rumble[8]);
+
+// Internal device-send/lifecycle hooks, including queued subcommand retries.
+// Returns the L2CAP status, or success when discarding an obsolete writer.
+int uni_hid_parser_switch_send_report(struct uni_hid_device_s* d, uint16_t cid,
+ const uint8_t* report, uint16_t len);
+void uni_hid_parser_switch_teardown(struct uni_hid_device_s* d);
+
#endif // UNI_HID_PARSER_SWITCH_H

View file

@ -1,4 +1,5 @@
#include "configuration/adapter_configuration.h"
#include <string.h>
namespace {
@ -7,12 +8,39 @@ bool pairing_window_valid(uint16_t pairing_window_seconds) {
pairing_window_seconds <= ADAPTER_PAIRING_WINDOW_SECONDS_MAX;
}
bool native_switch_identity_eligible(const ControllerIdentity& identity) {
return identity.stable &&
identity.transport == ControllerTransport::kClassic &&
identity.vendor_id == 0x057e &&
(identity.product_id == 0x2009 ||
identity.product_id == 0x2006 ||
identity.product_id == 0x2007);
}
} // namespace
AdapterConfiguration adapter_configuration_default() {
return {};
}
bool adapter_configuration_native_switch_approved(
const AdapterConfiguration& configuration,
const ControllerIdentity& identity) {
if (!native_switch_identity_eligible(identity) ||
configuration.native_switch_controller_count >
ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY) {
return false;
}
for (size_t index = 0;
index < configuration.native_switch_controller_count; ++index) {
if (controller_identity_equal(
identity, configuration.native_switch_controllers[index])) {
return true;
}
}
return false;
}
bool adapter_requested_mode_valid(AdapterRequestedMode requested_mode) {
switch (requested_mode) {
case AdapterRequestedMode::kAuto:
@ -48,19 +76,47 @@ bool adapter_configuration_encode(const AdapterConfiguration& configuration,
if (output == nullptr ||
output_size != ADAPTER_CONFIGURATION_ENCODED_SIZE ||
!pairing_window_valid(configuration.pairing_window_seconds) ||
!adapter_requested_mode_valid(configuration.requested_mode)) {
!adapter_requested_mode_valid(configuration.requested_mode) ||
configuration.native_switch_controller_count >
ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY) {
return false;
}
memset(output, 0, output_size);
output[0] = static_cast<uint8_t>(configuration.pairing_window_seconds);
output[1] =
static_cast<uint8_t>(configuration.pairing_window_seconds >> 8);
output[2] = static_cast<uint8_t>(configuration.requested_mode);
output[3] = 0;
output[4] = 0;
output[5] = 0;
output[6] = 0;
output[7] = 0;
output[3] = configuration.native_switch_controller_count;
for (size_t index = 0;
index < configuration.native_switch_controller_count; ++index) {
const ControllerIdentity& identity =
configuration.native_switch_controllers[index];
uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE];
if (!native_switch_identity_eligible(identity) ||
!controller_identity_encode(identity, encoded, sizeof(encoded))) {
return false;
}
// Sort wire records in place so list order cannot change the CRC.
size_t position = index;
while (position > 0) {
uint8_t* previous = output + ADAPTER_CONFIGURATION_HEADER_SIZE +
(position - 1) * sizeof(encoded);
const int comparison = memcmp(encoded, previous, sizeof(encoded));
if (comparison == 0) {
return false;
}
if (comparison > 0) {
break;
}
memcpy(previous + sizeof(encoded), previous, sizeof(encoded));
--position;
}
memcpy(output + ADAPTER_CONFIGURATION_HEADER_SIZE +
position * sizeof(encoded),
encoded, sizeof(encoded));
}
return true;
}
@ -79,10 +135,18 @@ bool adapter_configuration_decode(uint16_t schema_version,
return false;
}
decoded.requested_mode = AdapterRequestedMode::kAuto;
} else if (schema_version == ADAPTER_CONFIGURATION_SCHEMA_VERSION) {
if (payload_size != ADAPTER_CONFIGURATION_ENCODED_SIZE ||
payload[3] != 0 || payload[4] != 0 || payload[5] != 0 ||
payload[6] != 0 || payload[7] != 0) {
} else if (schema_version == ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION ||
schema_version == ADAPTER_CONFIGURATION_SCHEMA_VERSION) {
const bool current =
schema_version == ADAPTER_CONFIGURATION_SCHEMA_VERSION;
const size_t expected_size =
current ? ADAPTER_CONFIGURATION_ENCODED_SIZE
: ADAPTER_CONFIGURATION_V2_ENCODED_SIZE;
if (payload_size != expected_size || payload[4] != 0 ||
payload[5] != 0 || payload[6] != 0 || payload[7] != 0 ||
(!current && payload[3] != 0) ||
payload[3] >
ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY) {
return false;
}
decoded.requested_mode =
@ -90,6 +154,31 @@ bool adapter_configuration_decode(uint16_t schema_version,
if (!adapter_requested_mode_valid(decoded.requested_mode)) {
return false;
}
if (current) {
decoded.native_switch_controller_count = payload[3];
size_t offset = ADAPTER_CONFIGURATION_HEADER_SIZE;
for (size_t index = 0;
index < decoded.native_switch_controller_count; ++index) {
ControllerIdentity& identity =
decoded.native_switch_controllers[index];
if (!controller_identity_decode(
payload + offset, CONTROLLER_IDENTITY_ENCODED_SIZE,
&identity) ||
!native_switch_identity_eligible(identity) ||
(index > 0 &&
memcmp(payload + offset - CONTROLLER_IDENTITY_ENCODED_SIZE,
payload + offset,
CONTROLLER_IDENTITY_ENCODED_SIZE) >= 0)) {
return false;
}
offset += CONTROLLER_IDENTITY_ENCODED_SIZE;
}
for (; offset < payload_size; ++offset) {
if (payload[offset] != 0) {
return false;
}
}
}
} else {
return false;
}

View file

@ -4,11 +4,19 @@
#include <stdint.h>
#include "adapter/adapter_usb_mode.h"
#include "core/controller_identity.h"
constexpr uint16_t ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION = 1;
constexpr uint16_t ADAPTER_CONFIGURATION_SCHEMA_VERSION = 2;
constexpr uint16_t ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION = 2;
constexpr uint16_t ADAPTER_CONFIGURATION_SCHEMA_VERSION = 3;
constexpr size_t ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE = 4;
constexpr size_t ADAPTER_CONFIGURATION_ENCODED_SIZE = 8;
constexpr size_t ADAPTER_CONFIGURATION_V2_ENCODED_SIZE = 8;
constexpr size_t ADAPTER_CONFIGURATION_HEADER_SIZE = 8;
constexpr size_t ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY = 16;
constexpr size_t ADAPTER_CONFIGURATION_ENCODED_SIZE =
ADAPTER_CONFIGURATION_HEADER_SIZE +
ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY *
CONTROLLER_IDENTITY_ENCODED_SIZE;
constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_MIN = 10;
constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_MAX = 300;
constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT = 60;
@ -17,6 +25,9 @@ struct AdapterConfiguration {
uint16_t pairing_window_seconds =
ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT;
AdapterRequestedMode requested_mode = AdapterRequestedMode::kAuto;
uint8_t native_switch_controller_count = 0;
ControllerIdentity native_switch_controllers[
ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY]{};
};
struct AdapterModeAvailability {
@ -27,6 +38,9 @@ struct AdapterModeAvailability {
};
AdapterConfiguration adapter_configuration_default();
bool adapter_configuration_native_switch_approved(
const AdapterConfiguration& configuration,
const ControllerIdentity& identity);
bool adapter_requested_mode_valid(AdapterRequestedMode requested_mode);
bool adapter_requested_mode_available(
AdapterRequestedMode requested_mode,

View file

@ -68,7 +68,7 @@ bool decode_storage_configuration(
return false;
}
*migration_needed =
stored.schema_version == ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION;
stored.schema_version != ADAPTER_CONFIGURATION_SCHEMA_VERSION;
return true;
}
@ -196,7 +196,7 @@ void configuration_service_initialize_on_storage_core() {
}
g_storage_core_adopted = g_storage_initialized;
if (g_storage_core_adopted && g_migration_needed) {
const AdapterConfiguration configuration = g_snapshot.configuration;
const AdapterConfiguration& configuration = g_snapshot.configuration;
if (adapter_configuration_encode(configuration, g_migration_payload,
sizeof(g_migration_payload))) {
g_migration_pending = true;

View file

@ -1,6 +1,9 @@
#include "input/bluepad32_input_backend.h"
#include "input/controller_hotkey_config.h"
#include "input/switch2_wake.h"
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
#include "input/switch_native_output.h"
#endif
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#include "input/haptics_experiment.h"
#endif
@ -104,7 +107,7 @@ struct RumbleEnvelope {
uint32_t connection_generation;
ControllerRumbleOutput rumble;
uint16_t duration_ms;
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE)
uint64_t received_us = 0;
#endif
};
@ -1268,10 +1271,38 @@ void process_configuration_timer(btstack_timer_source_t* timer) {
const uint32_t now_ms = btstack_run_loop_get_time_ms();
configuration_service_task_on_storage_core(now_ms);
profile_service_task_on_storage_core(now_ms);
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
ConfigurationServiceSnapshot configuration{};
configuration_service_snapshot(&configuration);
if (configuration.state == ConfigurationServiceState::kReady) {
uint8_t previously_owned = 0;
for (uint8_t i = 0; i < kSlotCount; ++i)
if (switch_native_output_owns(g_slots[i].device)) previously_owned |= 1u << i;
switch_native_output_configure(configuration.configuration, configuration.generation);
for (uint8_t i = 0; i < kSlotCount; ++i) {
if ((previously_owned & (1u << i)) || !switch_native_output_owns(g_slots[i].device))
continue;
RumbleEnvelope retained{};
critical_section_enter_blocking(&g_state_lock);
const BackendSlot& current = g_slots[i];
retained = current.pending_rumble;
const bool valid = current.active && retained.slot == i &&
retained.connection_generation == current.connection_generation &&
retained.duration_ms == host_rumble_duration_ms();
critical_section_exit(&g_state_lock);
if (valid) switch_native_output_submit(i, retained.connection_generation,
retained.received_us, retained.rumble,
retained.duration_ms == kXInputHostRumbleDurationMs);
}
}
#endif
}
void dispatch_rumble(uni_hid_device_t* device, uint16_t duration_ms,
uint8_t weak, uint8_t strong) {
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
if (switch_native_output_feedback(device, strong, weak, duration_ms)) return;
#endif
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
if (haptics_experiment_feedback(device, strong, weak, duration_ms)) {
return;
@ -1505,6 +1536,10 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
}
}
critical_section_exit(&g_state_lock);
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
if (host_dispatch && switch_native_output_owns(device))
host_dispatch = false; // The timestamped native queue already owns this command.
#endif
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
if (host_dispatch && haptics_experiment_gameplay_owns(device)) {
// Switch commands have already entered the timestamped timeline.
@ -1651,6 +1686,9 @@ void platform_on_device_connected(uni_hid_device_t* device) {
}
void platform_on_device_disconnected(uni_hid_device_t* device) {
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
switch_native_output_detach(device);
#endif
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
haptics_experiment_detach(device);
#endif
@ -1722,6 +1760,10 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
return UNI_ERROR_NO_SLOTS;
}
if (became_active) {
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
switch_native_output_attach(static_cast<uint8_t>(slot_index),
lighting_generation, device, connection_identity);
#endif
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
haptics_experiment_attach(
static_cast<uint8_t>(slot_index), lighting_generation, device);
@ -1842,10 +1884,19 @@ uni_platform* get_platform() {
} // namespace
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE)
extern "C" bool uni_platform_on_l2cap_can_send_now(
uni_hid_device_t* device, uint16_t cid) {
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
// Nintendo shares this event with queued LED/subcommand output. Let the
// normal queue run too; its parser refreshes payload/counter at submission.
if (switch_native_output_on_can_send_now(device, cid)) return false;
#endif
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
return haptics_experiment_on_can_send_now(device, cid);
#else
return false;
#endif
}
#endif
@ -1908,6 +1959,9 @@ void bluepad32_input_backend_init() {
critical_section_init(&g_state_lock);
configuration_service_prepare();
profile_service_prepare();
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
switch_native_output_prepare();
#endif
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
haptics_experiment_prepare();
#endif
@ -2163,7 +2217,7 @@ void bluepad32_input_backend_queue_rumble(
return;
}
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE)
const uint64_t received_us = time_us_64();
uint32_t native_generation = 0;
bool native_candidate = false;
@ -2172,14 +2226,14 @@ void bluepad32_input_backend_queue_rumble(
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (slot.active && slot.device != nullptr) {
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE)
native_generation = slot.connection_generation;
native_candidate = true;
#endif
const RumbleEnvelope envelope{
slot_index, slot.connection_generation, rumble,
duration_ms
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE)
, received_us
#endif
};
@ -2196,6 +2250,11 @@ void bluepad32_input_backend_queue_rumble(
}
}
critical_section_exit(&g_state_lock);
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
if (native_candidate)
switch_native_output_submit(slot_index, native_generation, received_us, rumble,
duration_ms == kXInputHostRumbleDurationMs);
#endif
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
if (native_candidate) {
if (duration_ms == kXInputHostRumbleDurationMs) {

View file

@ -0,0 +1,591 @@
#include "input/switch_native_output.h"
#include "usb/switch/switch_native_haptics.h"
#include "usb/switch/switch_haptics_amplitudes.h"
#include <btstack.h>
#include <pico/critical_section.h>
#include <pico/stdlib.h>
#include <uni.h>
extern "C" {
#include <parser/uni_hid_parser_switch.h>
}
#include <string.h>
namespace {
constexpr uint8_t kSlots = 4;
constexpr uint8_t kCapacity = 16;
constexpr uint64_t kHostExpiryUs = 50000;
constexpr uint64_t kRefreshUs = 40000;
constexpr uint64_t kCommandWindowUs = 8000;
constexpr uint8_t kNeutral[8] = {0, 1, 0x40, 0x40, 0, 1, 0x40, 0x40};
constexpr uint32_t kConnected = 1, kApproved = 2, kActive = 4,
kMono = 8, kFeedback = 16;
struct Command {
ControllerRumbleOutput rumble{};
uint64_t received_us = 0;
bool stateful = false;
uint64_t first_received_us = 0;
};
struct SharedSlot {
uint32_t generation = 0;
bool accepting = false;
bool stop_pending = false;
bool lost = false;
uint8_t head = 0, count = 0;
Command queue[kCapacity]{};
SwitchNativeOutputDiagnostics diagnostics{};
uint32_t latency_histogram[64]{};
};
struct OutputSlot {
uni_hid_device_t* device = nullptr;
ControllerIdentity identity{};
bool approved = false, active = false, mono = false;
bool neutral_needed = false, dirty = false, host_valid = false;
bool feedback_active = false, pending_host = false, pending_trimmed = false;
bool permission_requested = false, permission_granted = false;
uint64_t feedback_until_us = 0, last_send_us = 0, retry_us = 0;
uint64_t pending_since_us = 0;
uint64_t pending_valid_until_us = UINT64_MAX;
ControllerRumbleOutput feedback{};
Command host{};
SwitchNativeHapticsEncoder encoder{};
SwitchNativeHapticsPackets packets{};
uint8_t packet_index = 0;
};
critical_section_t g_lock;
SharedSlot g_shared[kSlots];
OutputSlot g_outputs[kSlots];
AdapterConfiguration g_configuration{};
uint32_t g_configuration_generation = 0;
bool g_configured = false, g_prepared = false, g_runloop_ready = false;
bool g_work_pending = false, g_timer_armed = false;
bool g_polling = false;
btstack_data_source_t g_data_source{};
btstack_timer_source_t g_timer{};
void poll();
void increment(uint32_t& value, uint32_t amount = 1) {
value = amount > UINT32_MAX - value ? UINT32_MAX : value + amount;
}
bool silent(const ControllerRumbleOutput& rumble) {
if (rumble.low_frequency_magnitude || rumble.high_frequency_magnitude) return false;
for (const auto& actuator : rumble.hd.actuators)
for (uint8_t i = 0; i < actuator.sample_count && i < 3; ++i)
if (actuator.samples[i].low_amplitude_q15 || actuator.samples[i].high_amplitude_q15)
return false;
return true;
}
bool same_hold(const Command& previous, const Command& next, bool endpoint) {
if (previous.stateful != next.stateful) return false;
if (next.stateful)
return previous.rumble.low_frequency_magnitude == next.rumble.low_frequency_magnitude &&
previous.rumble.high_frequency_magnitude == next.rumble.high_frequency_magnitude;
for (uint8_t side = 0; side < 2; ++side) {
const auto& a = previous.rumble.hd.actuators[side];
const auto& b = next.rumble.hd.actuators[side];
if (!a.sample_count || a.sample_count > 3 || b.sample_count != 1 ||
(!endpoint && a.sample_count != 1)) return false;
const auto& held = a.samples[a.sample_count - 1];
const auto& wanted = b.samples[0];
if (held.low_frequency_index != wanted.low_frequency_index ||
held.high_frequency_index != wanted.high_frequency_index ||
held.low_amplitude_q15 != wanted.low_amplitude_q15 ||
held.high_amplitude_q15 != wanted.high_amplitude_q15) return false;
}
return true;
}
ControllerRumbleOutput magnitudes(uint8_t low, uint8_t high) {
ControllerRumbleOutput result{};
result.low_frequency_magnitude = low;
result.high_frequency_magnitude = high;
result.hd.actuators[0].sample_count = 1;
result.hd.actuators[1].sample_count = 1;
constexpr uint32_t maximum = SwitchHapticsTables::kAmplitudeQ15[228];
result.hd.actuators[0].samples[0].low_amplitude_q15 = (maximum * low + 127) / 255;
result.hd.actuators[1].samples[0].high_amplitude_q15 = (maximum * high + 127) / 255;
return result;
}
void publish_flags(uint8_t slot) {
const OutputSlot& output = g_outputs[slot];
critical_section_enter_blocking(&g_lock);
auto& state = g_shared[slot];
state.accepting = output.active && output.approved;
state.diagnostics.flags = (output.device ? kConnected : 0) |
(output.approved ? kApproved : 0) | (output.active ? kActive : 0) |
(output.mono ? kMono : 0) | (output.feedback_active ? kFeedback : 0);
critical_section_exit(&g_lock);
}
void reset_pending(OutputSlot& output) {
output.packets = {};
output.packet_index = 0;
output.pending_host = false;
output.encoder.reset();
output.neutral_needed = true;
output.retry_us = 0;
}
void data_source(btstack_data_source_t*, btstack_data_source_callback_type_t) {
if (__atomic_exchange_n(&g_work_pending, false, __ATOMIC_ACQ_REL)) poll();
}
void timer(btstack_timer_source_t*) {
g_timer_armed = false;
poll();
}
void ensure_runloop() {
if (g_runloop_ready) return;
btstack_run_loop_set_data_source_handler(&g_data_source, data_source);
btstack_run_loop_enable_data_source_callbacks(&g_data_source, DATA_SOURCE_CALLBACK_POLL);
btstack_run_loop_add_data_source(&g_data_source);
btstack_run_loop_set_timer_handler(&g_timer, timer);
g_runloop_ready = true;
}
void schedule() {
if (g_timer_armed) {
btstack_run_loop_remove_timer(&g_timer);
g_timer_armed = false;
}
uint64_t due = UINT64_MAX;
const uint64_t now = time_us_64();
for (const auto& output : g_outputs) {
if (!output.active) continue;
if ((output.neutral_needed || output.dirty || output.packets.count) &&
!output.permission_requested && output.retry_us < due) due = output.retry_us;
if (output.feedback_active && output.feedback_until_us < due)
due = output.feedback_until_us;
if (output.host_valid && !output.host.stateful &&
output.host.received_us + kHostExpiryUs < due)
due = output.host.received_us + kHostExpiryUs;
if ((output.feedback_active || output.host_valid) &&
output.last_send_us + kRefreshUs < due)
due = output.last_send_us + kRefreshUs;
}
if (due == UINT64_MAX) return;
// Pico adds one tick itself. The handler checks absolute microseconds.
const uint64_t delta_ms = due > now ? (due - now) / 1000 : 0;
btstack_run_loop_set_timer(&g_timer, delta_ms > 0 ? delta_ms - 1 : 0);
g_timer_armed = true;
btstack_run_loop_add_timer(&g_timer);
}
bool permission(uint8_t slot) {
OutputSlot& output = g_outputs[slot];
if (output.permission_granted) return true;
if (!output.permission_requested) {
output.permission_requested = true; // Callback may be synchronous.
if (l2cap_request_can_send_now_event(output.device->conn.interrupt_cid) != 0) {
output.permission_requested = false;
output.retry_us = time_us_64() + 1000;
}
if (!output.permission_granted) {
critical_section_enter_blocking(&g_lock);
increment(g_shared[slot].diagnostics.congested_attempts);
critical_section_exit(&g_lock);
}
}
return output.permission_granted;
}
bool send(uint8_t slot, const uint8_t* bytes) {
OutputSlot& output = g_outputs[slot];
if (!permission(slot)) return false;
output.permission_granted = false;
const bool sent = uni_hid_parser_switch_native_send(output.device, bytes);
const uint64_t now = time_us_64();
critical_section_enter_blocking(&g_lock);
auto& diagnostics = g_shared[slot].diagnostics;
if (sent) {
increment(diagnostics.submitted_reports);
memcpy(diagnostics.last_wire, bytes, 8);
} else {
increment(diagnostics.congested_attempts);
}
critical_section_exit(&g_lock);
output.retry_us = sent ? 0 : now + 1000;
if (sent) output.last_send_us = now;
return sent;
}
void finish_command(uint8_t slot) {
OutputSlot& output = g_outputs[slot];
critical_section_enter_blocking(&g_lock);
SharedSlot& shared = g_shared[slot];
if (output.pending_host) {
const uint64_t elapsed = time_us_64() - output.pending_since_us;
const uint32_t latency = elapsed > UINT32_MAX ? UINT32_MAX : elapsed;
increment(shared.diagnostics.completed_commands);
increment(shared.latency_histogram[latency / 250 < 63 ? latency / 250 : 63]);
if (latency > shared.diagnostics.max_latency_us)
shared.diagnostics.max_latency_us = latency;
if (output.packets.raw) increment(shared.diagnostics.raw_commands);
if (output.packets.quantized || output.pending_trimmed)
increment(shared.diagnostics.quantized_commands);
}
critical_section_exit(&g_lock);
output.packets = {};
output.packet_index = 0;
output.pending_host = false;
}
ControllerRumbleOutput current_host(const Command& command, uint64_t now,
bool& trimmed) {
if (command.stateful) return magnitudes(command.rumble.low_frequency_magnitude,
command.rumble.high_frequency_magnitude);
ControllerRumbleOutput result = command.rumble;
const uint64_t age = now > command.received_us ? now - command.received_us : 0;
for (auto& actuator : result.hd.actuators) {
const uint8_t count = actuator.sample_count > 3 ? 3 : actuator.sample_count;
if (!count) continue;
const uint8_t first = age >= kCommandWindowUs ? count - 1 : age * count / kCommandWindowUs;
if (first) {
for (uint8_t i = first; i < count; ++i) actuator.samples[i - first] = actuator.samples[i];
actuator.sample_count = count - first;
trimmed = true;
}
}
if (trimmed) result.raw_unmodified = false;
return result;
}
void restore_compatibility(OutputSlot& output, uint64_t now) {
uni_hid_parser_switch_native_release(output.device);
output.active = false;
if (output.device->report_parser.play_dual_rumble == nullptr) return;
ControllerRumbleOutput rumble{};
uint16_t duration = 0;
if (output.feedback_active && now < output.feedback_until_us) {
rumble = output.feedback;
duration = (output.feedback_until_us - now + 999) / 1000;
} else if (output.host_valid && (output.host.stateful || now < output.host.received_us + kHostExpiryUs)) {
rumble = output.host.rumble;
duration = output.host.stateful ? UINT16_MAX :
(output.host.received_us + kHostExpiryUs - now + 999) / 1000;
}
output.device->report_parser.play_dual_rumble(output.device, 0,
silent(rumble) ? 0 : duration, rumble.high_frequency_magnitude, rumble.low_frequency_magnitude);
output.host_valid = false;
output.feedback_active = false;
output.dirty = false;
}
void process_slot(uint8_t slot) {
OutputSlot& output = g_outputs[slot];
if (!output.active) return;
const uint64_t now = time_us_64();
Command newest{};
bool have_new = false, stop = false, loss = false;
critical_section_enter_blocking(&g_lock);
SharedSlot& shared = g_shared[slot];
stop = shared.stop_pending;
loss = shared.lost;
shared.stop_pending = shared.lost = false;
if (shared.count) {
newest = shared.queue[(shared.head + shared.count - 1) % kCapacity];
have_new = true;
if (shared.count > 1) {
increment(shared.diagnostics.dropped_commands, shared.count - 1);
loss = true;
}
shared.count = shared.head = 0;
}
critical_section_exit(&g_lock);
if (have_new && !loss && !output.feedback_active) {
const bool pending_hold = output.pending_host &&
same_hold(output.host, newest, false);
const bool applied_hold = !output.pending_host && !output.dirty &&
!output.neutral_needed && !output.packets.count &&
(output.host_valid || silent(newest.rumble)) &&
same_hold(output.host, newest, true);
if (pending_hold || applied_hold) {
// A held state needs only the regular watchdog refresh, not another
// radio packet for every identical USB report. Keep original pending
// latency, but extend expiry from the newest receipt.
output.host = newest;
output.host_valid = !silent(newest.rumble);
critical_section_enter_blocking(&g_lock);
increment(g_shared[slot].diagnostics.coalesced_commands);
critical_section_exit(&g_lock);
have_new = stop = false;
}
}
if (have_new) {
if (!output.feedback_active) {
if (output.pending_host) {
critical_section_enter_blocking(&g_lock);
increment(g_shared[slot].diagnostics.dropped_commands);
critical_section_exit(&g_lock);
}
// Preparing advances the codec model even before a successful send.
// Discarding ANY prepared schedule requires a physical baseline.
if (loss || stop || output.packets.count) reset_pending(output);
output.pending_host = true;
output.pending_since_us = newest.first_received_us;
output.dirty = true;
}
output.host = newest;
output.host_valid = !silent(newest.rumble);
} else if ((stop || loss) && !output.feedback_active) {
reset_pending(output);
output.dirty = true;
}
if (output.host_valid && !output.host.stateful && now >= output.host.received_us + kHostExpiryUs) {
output.host_valid = false;
if (!output.feedback_active) {
reset_pending(output);
output.dirty = true;
}
}
if (output.feedback_active && now >= output.feedback_until_us) {
output.feedback_active = false;
reset_pending(output);
output.dirty = true;
}
if (output.packets.count && now >= output.pending_valid_until_us) {
const bool host_command = output.pending_host;
reset_pending(output);
output.pending_host = host_command;
output.dirty = true;
}
if (now < output.retry_us) return;
if (output.neutral_needed) {
if (!send(slot, kNeutral)) return;
output.encoder.reset();
output.neutral_needed = false;
critical_section_enter_blocking(&g_lock);
increment(g_shared[slot].diagnostics.resynchronizations);
critical_section_exit(&g_lock);
if (output.approved && output.dirty && !output.host_valid && !output.feedback_active) {
output.dirty = false;
finish_command(slot); // This neutral already fulfills stop/expiry.
publish_flags(slot);
return;
}
}
if (!output.approved) {
restore_compatibility(output, now);
publish_flags(slot);
return;
}
if (output.feedback_active) {
// Host updates still replace the retained timeline, but do not restart
// or interfere with an active local confirmation.
output.pending_host = false;
}
if (!output.packets.count && (output.dirty ||
((output.host_valid || output.feedback_active) && now >= output.last_send_us + kRefreshUs))) {
// Request actual credit availability before mutating the encoder model.
// Timer polling misses short free-buffer windows behind HCI credit writes.
if (!permission(slot)) return;
ControllerRumbleOutput effective{};
output.pending_trimmed = false;
if (output.feedback_active) effective = output.feedback;
else if (output.host_valid) effective = current_host(output.host, now, output.pending_trimmed);
output.pending_valid_until_us = UINT64_MAX;
if (!output.feedback_active && output.host_valid && !output.host.stateful) {
const uint64_t age = now - output.host.received_us;
for (const auto& actuator : output.host.rumble.hd.actuators) {
const uint8_t count = actuator.sample_count > 3 ? 3 : actuator.sample_count;
if (count < 2 || age >= kCommandWindowUs) continue;
const uint8_t current = age * count / kCommandWindowUs;
if (current + 1 >= count) continue;
const uint64_t boundary = output.host.received_us +
((current + 1) * kCommandWindowUs + count - 1) / count;
if (boundary < output.pending_valid_until_us)
output.pending_valid_until_us = boundary;
}
}
const uint64_t begin = time_us_64();
output.packets = output.encoder.encode(effective, output.mono, true);
const uint32_t cost = time_us_64() - begin;
critical_section_enter_blocking(&g_lock);
if (cost > g_shared[slot].diagnostics.max_encode_us)
g_shared[slot].diagnostics.max_encode_us = cost;
critical_section_exit(&g_lock);
output.packet_index = 0;
output.dirty = false;
}
// Bounded legal schedule: at most baseline + one compressed command, never
// drain an obsolete command backlog into the controller.
for (uint8_t i = 0; i < 2 && output.packet_index < output.packets.count; ++i) {
if (!send(slot, output.packets.bytes[output.packet_index])) return;
++output.packet_index;
}
if (output.packets.count && output.packet_index == output.packets.count) finish_command(slot);
publish_flags(slot);
}
void poll() {
if (g_polling) return;
g_polling = true;
for (uint8_t slot = 0; slot < kSlots; ++slot) process_slot(slot);
schedule();
g_polling = false;
}
uint32_t percentile(const SharedSlot& shared, uint32_t percent) {
const uint64_t target = (uint64_t{shared.diagnostics.completed_commands} * percent + 99) / 100;
if (!target) return 0;
uint64_t count = 0;
for (uint8_t i = 0; i < 64; ++i) {
count += shared.latency_histogram[i];
if (count >= target) return i == 63 ? shared.diagnostics.max_latency_us : (i + 1) * 250u;
}
return shared.diagnostics.max_latency_us;
}
} // namespace
void switch_native_output_prepare() {
if (g_prepared) return;
critical_section_init(&g_lock);
for (uint8_t i = 0; i < kSlots; ++i) g_shared[i].diagnostics.slot = i;
g_prepared = true;
}
void switch_native_output_attach(uint8_t slot, uint32_t generation,
uni_hid_device_t* device,
const ControllerIdentity& identity) {
if (!g_prepared || slot >= kSlots || device == nullptr || identity.vendor_id != 0x057e) return;
uint8_t type = 0, hi = 0, lo = 0;
if (!uni_hid_parser_switch_native_info(device, &type, &hi, &lo) ||
!((type == 3 && identity.product_id == 0x2009) ||
(type == 1 && identity.product_id == 0x2006) ||
(type == 2 && identity.product_id == 0x2007))) return;
ensure_runloop();
OutputSlot& output = g_outputs[slot];
output = {};
output.device = device;
output.identity = identity;
output.mono = type != 3;
critical_section_enter_blocking(&g_lock);
g_shared[slot] = {};
g_shared[slot].generation = generation;
auto& diagnostics = g_shared[slot].diagnostics;
diagnostics.slot = slot;
diagnostics.type = type;
diagnostics.firmware_hi = hi;
diagnostics.firmware_lo = lo;
diagnostics.generation = generation;
critical_section_exit(&g_lock);
output.approved = adapter_configuration_native_switch_approved(g_configuration, identity);
if (output.approved && uni_hid_parser_switch_native_acquire(device)) {
output.active = true;
reset_pending(output);
}
publish_flags(slot);
poll();
}
void switch_native_output_detach(uni_hid_device_t* device) {
if (!g_prepared || device == nullptr) return;
for (uint8_t i = 0; i < kSlots; ++i) {
if (g_outputs[i].device != device) continue;
// Parser teardown owns timer retirement; never transmit on a dead CID.
g_outputs[i] = {};
critical_section_enter_blocking(&g_lock);
g_shared[i].accepting = false;
g_shared[i].count = g_shared[i].head = 0;
g_shared[i].stop_pending = g_shared[i].lost = false;
g_shared[i].diagnostics.flags = 0;
critical_section_exit(&g_lock);
}
schedule();
}
void switch_native_output_configure(const AdapterConfiguration& configuration,
uint32_t generation) {
if (!g_prepared || (g_configured && generation == g_configuration_generation)) return;
g_configuration = configuration;
g_configuration_generation = generation;
g_configured = true;
for (uint8_t i = 0; i < kSlots; ++i) {
OutputSlot& output = g_outputs[i];
if (!output.device) continue;
const bool approved = adapter_configuration_native_switch_approved(configuration, output.identity);
if (approved == output.approved) continue;
output.approved = approved;
if (approved && !output.active && uni_hid_parser_switch_native_acquire(output.device)) {
output.active = true;
reset_pending(output);
} else if (!approved && output.active) {
reset_pending(output);
}
publish_flags(i);
}
poll();
}
bool switch_native_output_submit(uint8_t slot, uint32_t generation,
uint64_t received_us,
const ControllerRumbleOutput& rumble,
bool stateful) {
if (!g_prepared || slot >= kSlots) return false;
critical_section_enter_blocking(&g_lock);
SharedSlot& shared = g_shared[slot];
const bool accepted = shared.accepting && shared.generation == generation;
if (accepted) {
const Command update{rumble, received_us, stateful, received_us};
Command* previous = shared.count
? &shared.queue[(shared.head + shared.count - 1) % kCapacity] : nullptr;
if (previous && same_hold(*previous, update, false)) {
previous->received_us = received_us;
increment(shared.diagnostics.coalesced_commands);
} else {
if (shared.count == kCapacity) {
shared.head = (shared.head + 1) % kCapacity;
--shared.count;
shared.lost = true;
increment(shared.diagnostics.dropped_commands);
}
shared.queue[(shared.head + shared.count) % kCapacity] = update;
++shared.count;
}
shared.stop_pending = shared.stop_pending || silent(rumble);
increment(shared.diagnostics.received_commands);
}
critical_section_exit(&g_lock);
if (accepted) {
__atomic_store_n(&g_work_pending, true, __ATOMIC_RELEASE);
// Unlike execute_on_main_thread(), this IRQ-safe wake does not acquire
// the radio async-context lock on the USB core.
btstack_run_loop_poll_data_sources_from_irq();
}
return accepted;
}
bool switch_native_output_on_can_send_now(uni_hid_device_t* device, uint16_t cid) {
if (!g_prepared || device == nullptr) return false;
for (auto& output : g_outputs) {
if (output.device != device || !output.active ||
device->conn.interrupt_cid != cid || !output.permission_requested) continue;
output.permission_requested = false;
output.permission_granted = true;
output.retry_us = 0;
poll();
return true;
}
return false;
}
bool switch_native_output_owns(const uni_hid_device_t* device) {
if (!g_prepared || device == nullptr) return false;
for (const auto& output : g_outputs)
if (output.device == device && output.active) return true;
return false;
}
bool switch_native_output_feedback(uni_hid_device_t* device, uint8_t low,
uint8_t high, uint16_t duration_ms) {
if (!g_prepared || device == nullptr) return false;
for (auto& output : g_outputs) {
if (output.device != device || !output.active) continue;
output.feedback = magnitudes(low, high);
output.feedback_until_us = time_us_64() + uint64_t{duration_ms} * 1000;
output.feedback_active = duration_ms != 0;
reset_pending(output);
output.dirty = true;
poll();
return true;
}
return false;
}
void switch_native_output_snapshot(uint8_t slot, SwitchNativeOutputDiagnostics* output) {
if (output == nullptr) return;
*output = {};
if (!g_prepared || slot >= kSlots) return;
critical_section_enter_blocking(&g_lock);
const SharedSlot& shared = g_shared[slot];
*output = shared.diagnostics;
output->queue_depth = shared.count;
output->p50_upper_us = percentile(shared, 50);
output->p95_upper_us = percentile(shared, 95);
output->p99_upper_us = percentile(shared, 99);
critical_section_exit(&g_lock);
}

View file

@ -0,0 +1,54 @@
#pragma once
#include <stdint.h>
#include "configuration/adapter_configuration.h"
#include "core/controller_identity.h"
#include "usb/switch/switch_haptics.h"
struct uni_hid_device_s;
// Submission timing is a transport measurement, not actuator onset. Percentiles
// are upper bounds from 250-us buckets, with the tail bounded by observed max.
struct SwitchNativeOutputDiagnostics {
uint8_t slot = 0;
uint8_t type = 0;
uint8_t firmware_hi = 0;
uint8_t firmware_lo = 0;
uint32_t flags = 0;
uint32_t generation = 0;
uint32_t received_commands = 0;
uint32_t submitted_reports = 0;
uint32_t dropped_commands = 0;
uint32_t resynchronizations = 0;
uint32_t raw_commands = 0;
uint32_t quantized_commands = 0;
uint32_t congested_attempts = 0;
uint32_t completed_commands = 0;
uint32_t p50_upper_us = 0;
uint32_t p95_upper_us = 0;
uint32_t p99_upper_us = 0;
uint32_t max_latency_us = 0;
uint32_t queue_depth = 0;
uint8_t last_wire[8]{};
uint32_t max_encode_us = 0;
uint32_t coalesced_commands = 0;
};
// prepare/submit/snapshot are Core-0 safe. All other calls belong to BTstack.
void switch_native_output_prepare();
void switch_native_output_attach(uint8_t slot, uint32_t generation,
uni_hid_device_s* device,
const ControllerIdentity& identity);
void switch_native_output_detach(uni_hid_device_s* device);
void switch_native_output_configure(const AdapterConfiguration& configuration,
uint32_t generation);
bool switch_native_output_submit(uint8_t slot, uint32_t generation,
uint64_t received_us,
const ControllerRumbleOutput& rumble,
bool stateful);
bool switch_native_output_owns(const uni_hid_device_s* device);
bool switch_native_output_on_can_send_now(uni_hid_device_s* device, uint16_t cid);
bool switch_native_output_feedback(uni_hid_device_s* device, uint8_t low,
uint8_t high, uint16_t duration_ms);
void switch_native_output_snapshot(uint8_t slot,
SwitchNativeOutputDiagnostics* output);

View file

@ -424,6 +424,9 @@ uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude,
ControllerRumbleOutput controller_profile_scale_host_rumble(
const ControllerRumbleOutput& input, const ControllerProfile& profile) {
ControllerRumbleOutput output = input;
output.raw_unmodified = input.raw_unmodified &&
profile.strong_rumble_scale == UINT8_MAX &&
profile.weak_rumble_scale == UINT8_MAX;
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(

View file

@ -1,57 +1,11 @@
#include "usb/switch/switch_haptics.h"
#include "usb/switch/switch_haptics_amplitudes.h"
#include "usb/switch/switch_haptics_commands.h"
#include <cstring>
namespace {
enum class CommandAction : uint8_t {
Ignore,
Default,
Substitute,
Sum,
};
struct HapticCommand {
CommandAction amplitude_action;
CommandAction frequency_action;
int16_t amplitude_offset;
int16_t frequency_offset;
};
constexpr HapticCommand kCommands[32] = {
{CommandAction::Default, CommandAction::Default, 0, 0},
{CommandAction::Substitute, CommandAction::Ignore, 0, 0},
{CommandAction::Substitute, CommandAction::Ignore, 240, 0},
{CommandAction::Substitute, CommandAction::Ignore, 224, 0},
{CommandAction::Substitute, CommandAction::Ignore, 208, 0},
{CommandAction::Substitute, CommandAction::Ignore, 192, 0},
{CommandAction::Substitute, CommandAction::Ignore, 176, 0},
{CommandAction::Substitute, CommandAction::Ignore, 160, 0},
{CommandAction::Substitute, CommandAction::Ignore, 144, 0},
{CommandAction::Substitute, CommandAction::Ignore, 128, 0},
{CommandAction::Substitute, CommandAction::Ignore, 112, 0},
{CommandAction::Substitute, CommandAction::Ignore, 96, 0},
{CommandAction::Ignore, CommandAction::Substitute, 0, 5},
{CommandAction::Ignore, CommandAction::Substitute, 0, 5},
{CommandAction::Ignore, CommandAction::Substitute, 0, 0},
{CommandAction::Ignore, CommandAction::Substitute, 0, 7},
{CommandAction::Ignore, CommandAction::Substitute, 0, 7},
{CommandAction::Sum, CommandAction::Sum, 4, 1},
{CommandAction::Sum, CommandAction::Ignore, 4, 0},
{CommandAction::Sum, CommandAction::Sum, 4, -1},
{CommandAction::Sum, CommandAction::Sum, 1, 1},
{CommandAction::Sum, CommandAction::Ignore, 1, 0},
{CommandAction::Sum, CommandAction::Sum, 1, -1},
{CommandAction::Ignore, CommandAction::Sum, 0, 1},
{CommandAction::Ignore, CommandAction::Ignore, 0, 0},
{CommandAction::Ignore, CommandAction::Sum, 0, -1},
{CommandAction::Sum, CommandAction::Sum, -1, 1},
{CommandAction::Sum, CommandAction::Ignore, -1, 0},
{CommandAction::Sum, CommandAction::Sum, -1, -1},
{CommandAction::Sum, CommandAction::Sum, -4, 1},
{CommandAction::Sum, CommandAction::Ignore, -4, 0},
{CommandAction::Sum, CommandAction::Sum, -4, -1},
};
using namespace SwitchHapticsCommands;
constexpr uint32_t kNeutralWord = 0x40400100u;
constexpr uint8_t kDefaultFrequency = 64;
@ -64,41 +18,6 @@ constexpr uint8_t extract(uint32_t word) {
return static_cast<uint8_t>((word >> Shift) & Mask);
}
uint8_t apply_command(CommandAction action, int16_t offset, uint8_t current,
uint8_t default_value, uint8_t maximum) {
switch (action) {
case CommandAction::Ignore:
return current;
case CommandAction::Default:
return default_value;
case CommandAction::Substitute:
return static_cast<uint8_t>(offset);
case CommandAction::Sum: {
int result = static_cast<int>(current) + static_cast<int>(offset);
if (result < 0) {
result = 0;
} else if (result > maximum) {
result = maximum;
}
return static_cast<uint8_t>(result);
}
}
return default_value;
}
uint8_t host_amplitude_to_lut_index(uint8_t host_index) {
const unsigned index = host_index & 0x7fu;
if (index == 0) {
return 0;
}
if (index < 16) {
return static_cast<uint8_t>(7u + 8u * index);
}
if (index < 32) {
return static_cast<uint8_t>(97u + 2u * index);
}
return static_cast<uint8_t>(128u + index);
}
uint32_t load_little_endian_word(const uint8_t* bytes) {
return static_cast<uint32_t>(bytes[0]) |
@ -308,6 +227,9 @@ ControllerRumbleOutput SwitchHapticsDecoder::decode(const uint8_t payload[8]) {
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]);
std::memcpy(output.raw, payload, sizeof(output.raw));
output.raw_valid = true;
output.raw_unmodified = true;
}
const uint8_t low_peak = peaks[0].low > peaks[1].low ? peaks[0].low : peaks[1].low;

View file

@ -29,6 +29,11 @@ struct ControllerRumbleOutput {
// Switch packets carry ordered per-side substeps in addition to the
// compatibility magnitudes consumed by existing non-native backends.
SwitchHapticsFrame hd{};
// Only the Switch decoder grants provenance. Scaling retains the source
// bytes for diagnostics but revokes unmodified unless both gains are unity.
uint8_t raw[8]{};
bool raw_valid = false;
bool raw_unmodified = false;
};
typedef void (*ControllerRumbleCallback)(
uint8_t instance, const ControllerRumbleOutput& rumble);
@ -47,6 +52,7 @@ public:
ControllerRumbleOutput decode(const uint8_t payload[8]);
private:
friend class SwitchNativeHapticsEncoder;
struct ActuatorState {
uint8_t high_amplitude;
uint8_t low_amplitude;

View file

@ -0,0 +1,69 @@
#pragma once
#include <stdint.h>
// Internal command semantics shared by the host decoder and native encoder.
// Compressed forms follow the existing decoder, not the older public absolute
// rumble tables. Their physical acceptance/repeated-word behavior still needs
// qualification on each enabled controller model/firmware.
namespace SwitchHapticsCommands {
enum class CommandAction : uint8_t { Ignore, Default, Substitute, Sum };
struct HapticCommand {
CommandAction amplitude_action;
CommandAction frequency_action;
int16_t amplitude_offset;
int16_t frequency_offset;
};
constexpr HapticCommand kCommands[32] = {
{CommandAction::Default, CommandAction::Default, 0, 0},
{CommandAction::Substitute, CommandAction::Ignore, 0, 0},
{CommandAction::Substitute, CommandAction::Ignore, 240, 0},
{CommandAction::Substitute, CommandAction::Ignore, 224, 0},
{CommandAction::Substitute, CommandAction::Ignore, 208, 0},
{CommandAction::Substitute, CommandAction::Ignore, 192, 0},
{CommandAction::Substitute, CommandAction::Ignore, 176, 0},
{CommandAction::Substitute, CommandAction::Ignore, 160, 0},
{CommandAction::Substitute, CommandAction::Ignore, 144, 0},
{CommandAction::Substitute, CommandAction::Ignore, 128, 0},
{CommandAction::Substitute, CommandAction::Ignore, 112, 0},
{CommandAction::Substitute, CommandAction::Ignore, 96, 0},
{CommandAction::Ignore, CommandAction::Substitute, 0, 5},
{CommandAction::Ignore, CommandAction::Substitute, 0, 5},
{CommandAction::Ignore, CommandAction::Substitute, 0, 0},
{CommandAction::Ignore, CommandAction::Substitute, 0, 7},
{CommandAction::Ignore, CommandAction::Substitute, 0, 7},
{CommandAction::Sum, CommandAction::Sum, 4, 1},
{CommandAction::Sum, CommandAction::Ignore, 4, 0},
{CommandAction::Sum, CommandAction::Sum, 4, -1},
{CommandAction::Sum, CommandAction::Sum, 1, 1},
{CommandAction::Sum, CommandAction::Ignore, 1, 0},
{CommandAction::Sum, CommandAction::Sum, 1, -1},
{CommandAction::Ignore, CommandAction::Sum, 0, 1},
{CommandAction::Ignore, CommandAction::Ignore, 0, 0},
{CommandAction::Ignore, CommandAction::Sum, 0, -1},
{CommandAction::Sum, CommandAction::Sum, -1, 1},
{CommandAction::Sum, CommandAction::Ignore, -1, 0},
{CommandAction::Sum, CommandAction::Sum, -1, -1},
{CommandAction::Sum, CommandAction::Sum, -4, 1},
{CommandAction::Sum, CommandAction::Ignore, -4, 0},
{CommandAction::Sum, CommandAction::Sum, -4, -1},
};
inline uint8_t apply_command(CommandAction action, int16_t offset, uint8_t current,
uint8_t default_value, uint8_t maximum) {
switch (action) {
case CommandAction::Ignore: return current;
case CommandAction::Default: return default_value;
case CommandAction::Substitute: return static_cast<uint8_t>(offset);
case CommandAction::Sum: {
const int value = static_cast<int>(current) + offset;
return static_cast<uint8_t>(value < 0 ? 0 : value > maximum ? maximum : value);
}
}
return default_value;
}
constexpr uint8_t host_amplitude_to_lut_index(uint8_t host_index) {
const unsigned index = host_index & 0x7fu;
return static_cast<uint8_t>(index == 0 ? 0 : index < 16 ? 7u + 8u * index
: index < 32 ? 97u + 2u * index : 128u + index);
}
} // namespace SwitchHapticsCommands

View file

@ -0,0 +1,447 @@
#include "usb/switch/switch_native_haptics.h"
#include "usb/switch/switch_haptics_amplitudes.h"
#include "usb/switch/switch_haptics_commands.h"
#include <cstring>
namespace {
using namespace SwitchHapticsCommands;
constexpr uint8_t kSafeIndex = 228; // Absolute code 100, not Q15 100.
constexpr uint16_t kSafeQ15 = SwitchHapticsTables::kAmplitudeQ15[kSafeIndex];
constexpr uint32_t kNeutral = 0x40400100u;
struct Band {
uint8_t amplitude = 0;
uint8_t frequency = 64;
};
struct Sample {
Band low;
Band high;
};
struct Timeline {
uint8_t count = 1;
Sample samples[3]{};
};
unsigned difference(unsigned a, unsigned b) { return a > b ? a - b : b - a; }
uint16_t amplitude(Band band) { return SwitchHapticsTables::kAmplitudeQ15[band.amplitude]; }
bool same(Band a, Band b) { return a.frequency == b.frequency && amplitude(a) == amplitude(b); }
bool same(Sample a, Sample b) { return same(a.low, b.low) && same(a.high, b.high); }
bool safe(Band band) { return band.amplitude <= kSafeIndex && band.frequency >= 1 && band.frequency <= 127; }
uint8_t nearest_index(uint16_t q15) {
if (q15 == 0) return 0;
unsigned lo = 2, hi = kSafeIndex;
for (unsigned iteration = 0; iteration < 8 && lo < hi; ++iteration) {
const unsigned mid = (lo + hi) / 2;
if (SwitchHapticsTables::kAmplitudeQ15[mid] < q15) lo = mid + 1;
else hi = mid;
}
const unsigned lower = lo == 2 ? 0 : lo - 1;
return static_cast<uint8_t>(difference(q15, SwitchHapticsTables::kAmplitudeQ15[lower]) <=
difference(q15, SwitchHapticsTables::kAmplitudeQ15[lo]) ? lower : lo);
}
uint8_t absolute_code(Band band) {
const uint16_t q15 = amplitude(band);
unsigned lo = 0, hi = 100;
for (unsigned iteration = 0; iteration < 7 && lo < hi; ++iteration) {
const unsigned mid = (lo + hi) / 2;
if (SwitchHapticsTables::kAmplitudeQ15[host_amplitude_to_lut_index(mid)] < q15) lo = mid + 1;
else hi = mid;
}
const unsigned lower = lo == 0 ? 0 : lo - 1;
return static_cast<uint8_t>(difference(q15, SwitchHapticsTables::kAmplitudeQ15[host_amplitude_to_lut_index(lower)]) <=
difference(q15, SwitchHapticsTables::kAmplitudeQ15[host_amplitude_to_lut_index(lo)]) ? lower : lo);
}
Band absolute_band(Band band) {
band.amplitude = host_amplitude_to_lut_index(absolute_code(band));
return band;
}
bool absolute_exact(Band band) { return same(band, absolute_band(band)); }
uint32_t absolute_word(Sample sample) {
return 0x40000000u | (uint32_t{sample.high.frequency} << 2u) |
(uint32_t{absolute_code(sample.high)} << 9u) |
(uint32_t{sample.low.frequency} << 16u) |
(uint32_t{absolute_code(sample.low)} << 23u);
}
uint32_t command_word(uint8_t count, const uint8_t high[3], const uint8_t low[3]) {
uint32_t word = uint32_t{count} << 30u;
for (uint8_t i = 0; i < count; ++i) {
const unsigned shift = 20u - 10u * i;
word |= (uint32_t{high[i]} << shift) | (uint32_t{low[i]} << (shift + 5u));
}
return word;
}
Band apply(Band band, uint8_t index) {
const auto& command = kCommands[index];
band.amplitude = apply_command(command.amplitude_action, command.amplitude_offset,
band.amplitude, 0, 255);
band.frequency = apply_command(command.frequency_action, command.frequency_offset,
band.frequency, 64, 127);
return band;
}
// Only silence has two LUT indices for the same observable state. Deduplicating
// equivalent resulting indices bounds this depth-three search to 32*(1+2+4)
// candidates, rather than re-exploring identical commands.
bool exact_commands(Band state, const Band targets[3], uint8_t count,
uint8_t commands[3], uint8_t step = 0) {
if (step == count) return true;
uint8_t visited_amplitudes[2]{};
uint8_t visited_count = 0;
for (uint8_t command = 0; command < 32; ++command) {
const Band next = apply(state, command);
if (!safe(next) || !same(next, targets[step])) continue;
bool visited = false;
for (uint8_t i = 0; i < visited_count; ++i) visited |= visited_amplitudes[i] == next.amplitude;
if (visited) continue;
visited_amplitudes[visited_count++] = next.amplitude;
commands[step] = command;
if (exact_commands(next, targets, count, commands, step + 1)) return true;
}
return false;
}
bool inverse_value(CommandAction action, int16_t offset, uint8_t target,
uint8_t default_value, uint8_t maximum, uint8_t& baseline) {
switch (action) {
case CommandAction::Default:
baseline = default_value;
return target == default_value;
case CommandAction::Substitute:
baseline = default_value;
return target == offset;
case CommandAction::Ignore:
baseline = target;
return true;
case CommandAction::Sum: {
const int value = static_cast<int>(target) - offset;
if (value < 0 || value > maximum) return false;
baseline = static_cast<uint8_t>(value);
return true;
}
}
return false;
}
bool exact_baseline(const Band targets[3], uint8_t count, Band& baseline,
uint8_t commands[3]) {
// Invert each possible first command, then search the remaining substeps.
// The second target index handles the silent 0/1 LUT alias explicitly.
for (uint8_t alias = 0; alias < (targets[0].amplitude == 0 ? 2 : 1); ++alias) {
for (uint8_t command = 0; command < 32; ++command) {
const auto& spec = kCommands[command];
Band candidate{};
const uint8_t target_amplitude = targets[0].amplitude == 0 ? alias : targets[0].amplitude;
if (!inverse_value(spec.amplitude_action, spec.amplitude_offset, target_amplitude,
0, kSafeIndex, candidate.amplitude) ||
!inverse_value(spec.frequency_action, spec.frequency_offset, targets[0].frequency,
64, 127, candidate.frequency) || !safe(candidate) || !absolute_exact(candidate)) continue;
candidate = absolute_band(candidate);
if (exact_commands(candidate, targets, count, commands)) {
baseline = candidate;
return true;
}
}
}
return false;
}
void approximate_commands(Band state, const Band targets[3], uint8_t count,
uint8_t commands[3]) {
for (uint8_t step = 0; step < count; ++step) {
unsigned best_error = UINT32_MAX;
uint8_t best = 1; // Silence is always a legal safe candidate.
for (uint8_t command = 0; command < 32; ++command) {
const Band next = apply(state, command);
if (!safe(next) || (amplitude(targets[step]) == 0 && amplitude(next) != 0)) continue;
const unsigned error = difference(amplitude(next), amplitude(targets[step])) +
128u * difference(next.frequency, targets[step].frequency);
if (error < best_error) {
best_error = error;
best = command;
}
}
commands[step] = best;
state = apply(state, best);
}
}
void split(const Timeline& timeline, Band low[3], Band high[3]) {
for (uint8_t i = 0; i < timeline.count; ++i) {
low[i] = timeline.samples[i].low;
high[i] = timeline.samples[i].high;
}
}
bool admissible(uint32_t word, uint32_t last, bool have_last,
Sample start, const Timeline& target) {
return !have_last || word != last ||
(target.count == 1 && same(start, target.samples[0]));
}
bool exact_word(Sample start, const Timeline& target, uint32_t last, bool have_last,
uint32_t& word) {
const auto accept = [&](uint32_t candidate) {
if (!admissible(candidate, last, have_last, start, target)) return false;
word = candidate;
return true;
};
const Sample first = target.samples[0];
if (target.count == 1 && absolute_exact(first.low) && absolute_exact(first.high) &&
accept(absolute_word(first))) return true;
Band low[3]{}, high[3]{};
split(target, low, high);
uint8_t low_commands[3]{}, high_commands[3]{};
if (exact_commands(start.low, low, target.count, low_commands) &&
exact_commands(start.high, high, target.count, high_commands) &&
accept(command_word(target.count, high_commands, low_commands))) return true;
if (target.count == 1) {
// Type 3 changes one absolute amplitude or frequency coordinate.
for (uint8_t selected = 0; selected < 2; ++selected) {
const Band before = selected ? start.high : start.low;
const Band after = selected ? first.high : first.low;
if (!same(selected ? start.low : start.high, selected ? first.low : first.high)) continue;
if (before.frequency == after.frequency && absolute_exact(after) &&
accept(0x40000002u | selected | (uint32_t{absolute_code(after)} << 23u))) return true;
if (amplitude(before) == amplitude(after) &&
accept(0x40000006u | selected | (uint32_t{after.frequency} << 23u))) return true;
}
} else if (target.count == 2) {
// Type 4: one absolute band + opposite-band command, then two commands.
for (uint8_t selected = 0; selected < 2; ++selected) {
const Band absolute = selected ? first.high : first.low;
if (!absolute_exact(absolute)) continue;
Band selected_targets[3]{selected ? high[1] : low[1], {}, {}};
uint8_t selected_commands[3]{}, other_commands[3]{};
if (!exact_commands(absolute_band(absolute), selected_targets, 1, selected_commands) ||
!exact_commands(selected ? start.low : start.high, selected ? low : high,
2, other_commands)) continue;
const uint8_t high_second = selected ? selected_commands[0] : other_commands[1];
const uint8_t low_second = selected ? other_commands[1] : selected_commands[0];
const uint32_t candidate = 0x80000000u | selected |
(uint32_t{absolute.frequency} << 1u) |
(uint32_t{high_second} << 8u) | (uint32_t{low_second} << 13u) |
(uint32_t{other_commands[0]} << 18u) | (uint32_t{absolute_code(absolute)} << 23u);
if (accept(candidate)) return true;
}
}
return false;
}
void recovery_words(const Timeline& target, uint32_t& baseline_word, uint32_t& steps_word,
bool& quantized) {
Band low[3]{}, high[3]{};
split(target, low, high);
uint8_t commands[2][3]{};
Sample baseline{};
for (uint8_t band = 0; band < 2; ++band) {
Band* targets = band ? high : low;
Band& initial = band ? baseline.high : baseline.low;
if (exact_baseline(targets, target.count, initial, commands[band])) continue;
quantized = true;
if (!exact_baseline(targets, 1, initial, commands[band])) initial = absolute_band(targets[0]);
approximate_commands(initial, targets, target.count, commands[band]);
}
baseline_word = absolute_word(baseline);
steps_word = command_word(target.count, commands[1], commands[0]);
}
void store_word(uint8_t* bytes, uint32_t word) {
for (uint8_t i = 0; i < 4; ++i) bytes[i] = static_cast<uint8_t>(word >> (8u * i));
}
uint32_t load_word(const uint8_t* bytes) {
return uint32_t{bytes[0]} | (uint32_t{bytes[1]} << 8u) |
(uint32_t{bytes[2]} << 16u) | (uint32_t{bytes[3]} << 24u);
}
bool supported_word(uint32_t word) {
if (word == 0 || word == kNeutral) return true;
const unsigned count = word >> 30u;
if (count == 0) return false;
return count != 1 || (word & 0x000fffffu) == 0 || (word & 3u) == 0 || (word & 2u) != 0;
}
bool same_frame(const SwitchHapticsFrame& a, const SwitchHapticsFrame& b) {
for (uint8_t side = 0; side < 2; ++side) {
const auto& left = a.actuators[side];
const auto& right = b.actuators[side];
if (left.sample_count != right.sample_count || left.sample_count > 3) return false;
for (uint8_t step = 0; step < left.sample_count; ++step) {
const auto& x = left.samples[step];
const auto& y = right.samples[step];
if (x.low_amplitude_q15 != y.low_amplitude_q15 || x.high_amplitude_q15 != y.high_amplitude_q15 ||
x.low_frequency_index != y.low_frequency_index || x.high_frequency_index != y.high_frequency_index ||
x.low_amplitude_q15 > kSafeQ15 || x.high_amplitude_q15 > kSafeQ15 ||
x.low_frequency_index < 1 || x.low_frequency_index > 127 ||
x.high_frequency_index < 1 || x.high_frequency_index > 127) return false;
}
}
return true;
}
Band normalize_band(uint16_t q15, uint8_t frequency, bool& quantized) {
Band result{nearest_index(q15), static_cast<uint8_t>(frequency < 1 ? 1 : frequency > 127 ? 127 : frequency)};
quantized |= amplitude(result) != q15 || result.frequency != frequency;
return result;
}
} // namespace
void SwitchNativeHapticsEncoder::reset() { physical_.reset(); }
SwitchNativeHapticsPackets SwitchNativeHapticsEncoder::encode(
const ControllerRumbleOutput& input, bool mono, bool allow_raw) {
SwitchNativeHapticsPackets packets{};
if (allow_raw && !mono && input.raw_valid && input.raw_unmodified &&
supported_word(load_word(input.raw)) && supported_word(load_word(input.raw + 4))) {
SwitchHapticsDecoder candidate = physical_;
const auto decoded = candidate.decode(input.raw);
if (same_frame(decoded.hd, input.hd)) {
packets.count = 1;
packets.raw = true;
std::memcpy(packets.bytes[0], input.raw, 8);
physical_ = candidate;
return packets;
}
}
SwitchHapticsFrame desired{};
const bool conventional = input.hd.actuators[0].sample_count == 0 &&
input.hd.actuators[1].sample_count == 0;
for (uint8_t side = 0; side < 2; ++side) {
const auto& source = input.hd.actuators[side];
auto& frame = desired.actuators[side];
frame.sample_count = source.sample_count == 0 ? 1 : source.sample_count > 3 ? 3 : source.sample_count;
packets.quantized |= source.sample_count > 3;
for (uint8_t step = 0; step < frame.sample_count; ++step) {
auto& sample = frame.samples[step];
if (conventional) {
sample.low_amplitude_q15 = static_cast<uint16_t>((uint32_t{input.low_frequency_magnitude} * kSafeQ15 + 127u) / 255u);
sample.high_amplitude_q15 = static_cast<uint16_t>((uint32_t{input.high_frequency_magnitude} * kSafeQ15 + 127u) / 255u);
} else if (source.sample_count != 0) {
sample = source.samples[step];
}
}
}
if (mono) {
SwitchHapticsActuatorFrame mixed{};
const auto& left = desired.actuators[0];
const auto& right = desired.actuators[1];
mixed.sample_count = left.sample_count > right.sample_count ? left.sample_count : right.sample_count;
packets.quantized |= left.sample_count != right.sample_count;
for (uint8_t step = 0; step < mixed.sample_count; ++step) {
const auto& l = left.samples[step < left.sample_count ? step : left.sample_count - 1];
const auto& r = right.samples[step < right.sample_count ? step : right.sample_count - 1];
const auto& low = l.low_amplitude_q15 >= r.low_amplitude_q15 ? l : r;
const auto& high = l.high_amplitude_q15 >= r.high_amplitude_q15 ? l : r;
mixed.samples[step] = {low.low_frequency_index, high.high_frequency_index,
low.low_amplitude_q15, high.high_amplitude_q15};
}
desired.actuators[0] = mixed;
desired.actuators[1] = mixed;
}
Timeline target[2]{};
for (uint8_t side = 0; side < 2; ++side) {
target[side].count = desired.actuators[side].sample_count;
for (uint8_t step = 0; step < target[side].count; ++step) {
const auto& sample = desired.actuators[side].samples[step];
target[side].samples[step] = {
normalize_band(sample.low_amplitude_q15, sample.low_frequency_index, packets.quantized),
normalize_band(sample.high_amplitude_q15, sample.high_frequency_index, packets.quantized)};
}
}
uint32_t words[2]{};
bool fits[2]{};
uint32_t prefixes[2]{};
bool prefixed[2]{};
for (uint8_t side = 0; side < 2; ++side) {
const auto& state = physical_.actuators_[side];
const Sample start{{state.low_amplitude, state.low_frequency},
{state.high_amplitude, state.high_frequency}};
fits[side] = exact_word(start, target[side], state.last_word, state.have_last_word, words[side]);
if (!fits[side]) {
const auto try_prefix = [&](uint32_t prefix) {
auto after = state;
SwitchHapticsActuatorFrame ignored{};
SwitchHapticsDecoder::decode_actuator(after, prefix, ignored);
const Sample established{{after.low_amplitude, after.low_frequency},
{after.high_amplitude, after.high_frequency}};
if (!exact_word(established, target[side], after.last_word, after.have_last_word, words[side])) return false;
prefixes[side] = prefix;
return true;
};
// A one-coordinate prefix can retain a tiny relative amplitude
// that no absolute code represents, while moving its frequency.
Timeline first{};
first.samples[0] = target[side].samples[0];
uint32_t prefix = 0;
if (target[side].count > 1 &&
exact_word(start, first, state.last_word, state.have_last_word, prefix)) {
prefixed[side] = try_prefix(prefix);
}
if (!prefixed[side]) {
// Also search legal relative predecessors. In particular, the
// silent LUT index1 can lead to index2 on the next increment;
// replacing that predecessor by absolute zero would lose it.
Band low[3]{}, high[3]{};
split(target[side], low, high);
uint8_t prefix_commands[2]{};
bool have_prefix[2]{};
for (uint8_t band = 0; band < 2; ++band) {
for (uint8_t command = 0; command < 32; ++command) {
const Band after = apply(band ? start.high : start.low, command);
uint8_t suffix[3]{};
if (safe(after) && exact_commands(after, band ? high : low, target[side].count, suffix)) {
prefix_commands[band] = command;
have_prefix[band] = true;
break;
}
}
}
if (have_prefix[0] && have_prefix[1]) {
uint8_t low_prefix[3]{prefix_commands[0], 0, 0};
uint8_t high_prefix[3]{prefix_commands[1], 0, 0};
prefixed[side] = try_prefix(command_word(1, high_prefix, low_prefix));
// A one-step suffix must not equal its prefix (same-word
// suppression). Equivalent reset/hold spellings can avoid
// that collision without changing either modeled state.
for (uint8_t band = 0; band < 2 && !prefixed[side]; ++band) {
const Band before = band ? start.high : start.low;
const Band expected = apply(before, prefix_commands[band]);
uint8_t* commands = band ? high_prefix : low_prefix;
for (uint8_t command = 0; command < 32 && !prefixed[side]; ++command) {
const Band after = apply(before, command);
if (after.amplitude == expected.amplitude && after.frequency == expected.frequency) {
commands[0] = command;
prefixed[side] = try_prefix(command_word(1, high_prefix, low_prefix));
}
}
commands[0] = prefix_commands[band];
}
}
}
}
}
const bool direct = fits[0] && fits[1];
packets.count = direct ? 1 : 2;
for (uint8_t side = 0; side < 2; ++side) {
if (direct) {
store_word(packets.bytes[0] + 4u * side, words[side]);
} else if (fits[side]) {
// Do not quantize a representable side merely because its partner
// needs a baseline. Repeating its previous word holds its endpoint.
const auto& state = physical_.actuators_[side];
store_word(packets.bytes[0] + 4u * side, state.have_last_word ? state.last_word : kNeutral);
store_word(packets.bytes[1] + 4u * side, words[side]);
} else if (prefixed[side]) {
store_word(packets.bytes[0] + 4u * side, prefixes[side]);
store_word(packets.bytes[1] + 4u * side, words[side]);
} else {
uint32_t baseline = 0, steps = 0;
recovery_words(target[side], baseline, steps, packets.quantized);
store_word(packets.bytes[0] + 4u * side, baseline);
store_word(packets.bytes[1] + 4u * side, steps);
}
}
for (uint8_t packet = 0; packet < packets.count; ++packet) physical_.decode(packets.bytes[packet]);
return packets;
}

View file

@ -0,0 +1,57 @@
#pragma once
#include "usb/switch/switch_haptics.h"
struct SwitchNativeHapticsPackets {
uint8_t count = 0;
uint8_t bytes[2][8]{};
bool raw = false;
bool quantized = false;
};
// Stateful, bounded Nintendo wire encoder. No allocation or floating point.
//
// reset() assumes the caller has established neutral on the physical device.
// encode() advances the modeled physical state for ALL returned packets: submit
// them in order. Loss, rejection, partial submission, or an intervening writer
// requires neutral + reset before encoding the current host state again.
//
// Raw unity is accepted only if decoding against that model reproduces every
// input substep with safe amplitudes/frequencies. It is never used for mono.
// Absolute amplitude codes are capped at 100 (internal LUT index 228), per
// dekuNukem's documented safe range; Q15 is decoder-normalized, NOT a wire code.
// Frequencies are bounded to the documented absolute range, indices 1..127.
//
// Quantization policy:
// * Round Q15 to the nearest safe decoder LUT value; ties choose lower amplitude.
// * First try every supported one-word form without dropping any substep. Next
// try a one-step prefix establishing the first target or a relative predecessor
// without rounding a known relative amplitude; otherwise use an absolute baseline.
// Retain the original 1..3 steps in the final word. A prefix/baseline is setup,
// not an extra source substep; its duration needs hardware qualification.
// * If neither supported exact prefix nor absolute-baseline schedule works,
// choose a baseline permitting the exact first step when possible, otherwise
// the nearest absolute first state. For each remaining step choose among all
// 32 legal commands by
// |Q15 error| + 128*|log-frequency-index error|, lower command wins ties.
// A requested zero amplitude MUST stay zero. Every time slot is retained;
// no peak/latest collapse. Per-step errors are bounded by 17867 Q15 and 126
// frequency indices, not claimed perceptually equivalent. quantized reports
// amplitude/frequency rounding, safety clamping, or this sequence approximation.
// * Mono chooses the dominant amplitude independently per band, carrying that
// side's frequency (left wins ties), and duplicates it into both wire words.
// Unequal side counts use max(counts) slots, index min(slot,count-1) (hold the
// shorter side's last state). This temporal quantization sets quantized.
//
// Absolute bytes have independent public golden vectors. Compressed semantics
// and same-word suppression come from the existing host decoder and are NOT
// proof of actuator acceptance: enable only on hardware-qualified model/firmware.
class SwitchNativeHapticsEncoder {
public:
void reset();
SwitchNativeHapticsPackets encode(const ControllerRumbleOutput& input,
bool mono, bool allow_raw);
private:
SwitchHapticsDecoder physical_{};
};

View file

@ -6,6 +6,9 @@
#include "adapter/adapter_reboot.h"
#include "adapter/adapter_usb_mode.h"
#include "input/haptics_experiment.h"
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
#include "input/switch_native_output.h"
#endif
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#include "input/haptics_transport_probe.h"
#endif
@ -350,6 +353,44 @@ size_t encode_haptics_transport_probe(uint8_t* output, size_t output_size) {
#endif
}
size_t encode_native_switch_rumble(uint8_t* output, size_t output_size) {
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
uint8_t payload[kNativeSwitchRumblePayloadSize]{};
for (uint8_t slot = 0; slot < 4; ++slot) {
SwitchNativeOutputDiagnostics snapshot{};
switch_native_output_snapshot(slot, &snapshot);
uint8_t* row = payload + slot * kNativeSwitchRumbleRowSize;
row[0] = slot;
row[1] = snapshot.type;
row[2] = snapshot.firmware_hi;
row[3] = snapshot.firmware_lo;
write_u32(row + 4, snapshot.flags);
write_u32(row + 8, snapshot.generation);
write_u32(row + 12, snapshot.received_commands);
write_u32(row + 16, snapshot.submitted_reports);
write_u32(row + 20, snapshot.dropped_commands);
write_u32(row + 24, snapshot.resynchronizations);
write_u32(row + 28, snapshot.raw_commands);
write_u32(row + 32, snapshot.quantized_commands);
write_u32(row + 36, snapshot.congested_attempts);
write_u32(row + 40, snapshot.completed_commands);
write_u32(row + 44, snapshot.p50_upper_us);
write_u32(row + 48, snapshot.p95_upper_us);
write_u32(row + 52, snapshot.p99_upper_us);
write_u32(row + 56, snapshot.max_latency_us);
write_u32(row + 60, snapshot.queue_depth);
memcpy(row + 64, snapshot.last_wire, 8);
write_u32(row + 72, snapshot.max_encode_us);
write_u32(row + 76, snapshot.coalesced_commands);
}
return encode_response(Operation::kNativeSwitchRumble, Status::kOk, 0,
kNativeSwitchRumbleSchemaVersion, 0, payload, sizeof(payload), output, output_size);
#else
return encode_response(Operation::kNativeSwitchRumble, Status::kUnsupportedSchema, 0,
kNativeSwitchRumbleSchemaVersion, 0, nullptr, 0, output, output_size);
#endif
}
} // namespace
bool decode_request(Operation setup_operation, const uint8_t* input,
@ -974,6 +1015,9 @@ bool usb_configuration_management_vendor_control(
response_size =
encode_haptics_transport_probe(response, sizeof(response));
break;
case Operation::kNativeSwitchRumble:
response_size = encode_native_switch_rumble(response, sizeof(response));
break;
case Operation::kMacroCapture:
response_size = encode_macro_capture(response, sizeof(response));
break;

View file

@ -36,6 +36,9 @@ constexpr size_t kHapticsTransportProbePayloadSize = 176;
constexpr uint16_t kMacroCaptureSchemaVersion = 1;
constexpr size_t kMacroCaptureHeaderSize = 32;
constexpr size_t kMacroCaptureEventSize = 20;
constexpr uint16_t kNativeSwitchRumbleSchemaVersion = 2;
constexpr size_t kNativeSwitchRumbleRowSize = 80;
constexpr size_t kNativeSwitchRumblePayloadSize = 4 * kNativeSwitchRumbleRowSize;
constexpr size_t kMaximumResponseSize =
kResponseHeaderSize + kProfileListPayloadSize;
constexpr size_t kMaximumChunkSize =
@ -74,6 +77,7 @@ enum class Operation : uint8_t {
kHapticsExperiment = 0x40,
kHapticsTransportProbe = 0x41,
kMacroCapture = 0x42,
kNativeSwitchRumble = 0x43,
};
enum class Status : uint8_t {

View file

@ -81,8 +81,7 @@ inline bool parse_rumble_report(const uint8_t *data, uint32_t size,
data[1] != 0x08) {
return false;
}
output->low_frequency_magnitude = data[3];
output->high_frequency_magnitude = data[4];
*output = ControllerRumbleOutput{data[3], data[4]};
return true;
}

View file

@ -13,7 +13,7 @@ import sys
import time
import zlib
from collections.abc import Iterable, Sequence
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from typing import Any, Protocol, cast
@ -67,6 +67,15 @@ OP_PROFILE_IDENTIFY = 0x3C
OP_HAPTICS_EXPERIMENT = 0x40
OP_HAPTICS_TRANSPORT_PROBE = 0x41
OP_MACRO_CAPTURE = 0x42
OP_NATIVE_SWITCH_RUMBLE = 0x43
NATIVE_SWITCH_RUMBLE_SCHEMA_VERSION = 2
NATIVE_SWITCH_RUMBLE_ROW_SIZE = 80
NATIVE_SWITCH_RUMBLE_SLOT_COUNT = 4
NATIVE_SWITCH_RUMBLE_LATENCY_NOTE = (
"Latency percentiles are host-receipt-to-HCI-submission histogram upper "
"bounds in 250 us buckets (tail uses observed maximum), not physical latency. "
"Coalesced held-state commands require no new packet and are excluded."
)
MACRO_CAPTURE_SCHEMA_VERSION = 1
MACRO_CAPTURE_STATES = (
"idle",
@ -90,8 +99,14 @@ STATUS_NAMES = {
8: "storage failure",
}
CONFIGURATION_SCHEMA_VERSION = 2
CONFIGURATION_SIZE = 8
CONFIGURATION_SCHEMA_VERSION = 3
CONFIGURATION_SIZE = 232
NATIVE_SWITCH_CONTROLLER_CAPACITY = 16
NATIVE_SWITCH_RUMBLE_APPROVAL_NOTE = (
"Native rumble requires a genuine qualified Nintendo Switch Pro Controller "
"or Joy-Con. Approval applies to this physical controller across all profiles; "
"matching VID/PID is not automatic proof of clone support."
)
PAIRING_WINDOW_SECONDS_MIN = 10
PAIRING_WINDOW_SECONDS_MAX = 300
REQUESTED_MODE_AUTO = 0
@ -356,6 +371,46 @@ class RuntimeDiagnostics:
rumble_pending_slots: int
@dataclass(frozen=True)
class NativeSwitchRumbleSlot:
slot: int
parser_type: int
firmware_high: int
firmware_low: int
flags: int
generation: int
received_commands: int
submitted_reports: int
dropped_commands: int
resynchronizations: int
raw_commands: int
quantized_commands: int
congested_attempts: int
completed_commands: int
p50_upper_us: int
p95_upper_us: int
p99_upper_us: int
max_latency_us: int
queue_depth: int
last_wire_low_u32: int
last_wire_high_u32: int
max_encode_us: int
coalesced_commands: int
def to_json_object(self) -> dict[str, Any]:
return {
**asdict(self),
"connected": bool(self.flags & 1),
"approved": bool(self.flags & 2),
"active": bool(self.flags & 4),
"mono": bool(self.flags & 8),
"feedback": bool(self.flags & 16),
"last_wire_hex": struct.pack(
"<II", self.last_wire_low_u32, self.last_wire_high_u32
).hex(),
}
@dataclass(frozen=True)
class HapticsExperimentDiagnostics:
run_id: int
@ -529,6 +584,8 @@ class AdapterConfiguration:
generation: int
crc: int
requested_mode: int = REQUESTED_MODE_AUTO
native_switch_controllers: tuple[ControllerIdentity, ...] = ()
schema_version: int = CONFIGURATION_SCHEMA_VERSION
@dataclass(frozen=True)
@ -2581,6 +2638,77 @@ def read_runtime_diagnostics(device: UsbDevice) -> RuntimeDiagnostics:
)
def parse_native_switch_rumble(
envelope: Envelope,
) -> tuple[NativeSwitchRumbleSlot, ...]:
_raise_status(envelope)
if (
envelope.schema_version != NATIVE_SWITCH_RUMBLE_SCHEMA_VERSION
or len(envelope.payload)
!= NATIVE_SWITCH_RUMBLE_ROW_SIZE * NATIVE_SWITCH_RUMBLE_SLOT_COUNT
):
raise ConfigManagerError("unsupported native Nintendo rumble diagnostics")
slots = []
for slot in range(NATIVE_SWITCH_RUMBLE_SLOT_COUNT):
values = struct.unpack_from(
"<4B19I", envelope.payload, slot * NATIVE_SWITCH_RUMBLE_ROW_SIZE
)
if values[0] != slot or values[4] & ~0x1F:
raise ConfigManagerError("invalid native Nintendo rumble diagnostic row")
slots.append(NativeSwitchRumbleSlot(*values))
return tuple(slots)
def read_native_switch_rumble(device: UsbDevice) -> tuple[NativeSwitchRumbleSlot, ...]:
return parse_native_switch_rumble(_control_in(device, OP_NATIVE_SWITCH_RUMBLE))
def _print_native_switch_rumble_status(
slots: tuple[NativeSwitchRumbleSlot, ...], *, json_output: bool
) -> None:
if json_output:
print(
json.dumps(
{
"schema_version": NATIVE_SWITCH_RUMBLE_SCHEMA_VERSION,
"latency_note": NATIVE_SWITCH_RUMBLE_LATENCY_NOTE,
"slots": [slot.to_json_object() for slot in slots],
}
)
)
return
print(NATIVE_SWITCH_RUMBLE_LATENCY_NOTE)
for slot in slots:
values = slot.to_json_object()
print(
f"Slot {slot.slot}: parser type {slot.parser_type}, firmware bytes "
f"{slot.firmware_high:02x}:{slot.firmware_low:02x}, generation {slot.generation}"
)
print(
" "
+ " ".join(
f"{name}={str(values[name]).lower()}"
for name in ("connected", "approved", "active", "mono", "feedback")
)
)
print(
f" Commands: received={slot.received_commands} completed={slot.completed_commands} "
f"raw={slot.raw_commands} quantized={slot.quantized_commands} "
f"dropped={slot.dropped_commands} coalesced={slot.coalesced_commands}"
)
print(
f" Reports: submitted={slot.submitted_reports} "
f"congested={slot.congested_attempts} resynchronizations={slot.resynchronizations} "
f"queue_depth={slot.queue_depth}"
)
print(
f" Latency upper bounds (us): p50={slot.p50_upper_us} "
f"p95={slot.p95_upper_us} p99={slot.p99_upper_us}; "
f"max={slot.max_latency_us}; max_encode={slot.max_encode_us}"
)
print(f" Last wire bytes: {values['last_wire_hex']}")
def parse_haptics_experiment(envelope: Envelope) -> HapticsExperimentDiagnostics:
_raise_status(envelope)
if envelope.schema_version != HAPTICS_EXPERIMENT_SCHEMA_VERSION:
@ -2986,17 +3114,67 @@ def _run_haptics_experiment_command(
time.sleep(min(0.1, remaining))
def _canonical_native_switch_controllers(
identities: tuple[ControllerIdentity, ...],
) -> tuple[ControllerIdentity, ...]:
if (
type(identities) is not tuple
or len(identities) > NATIVE_SWITCH_CONTROLLER_CAPACITY
):
raise ConfigManagerError("invalid native rumble approval list")
for identity in identities:
if (
not isinstance(identity, ControllerIdentity)
or not identity.stable
or identity.transport != TRANSPORT_CLASSIC
or identity.vendor_id != 0x057E
or identity.product_id not in (0x2009, 0x2006, 0x2007)
):
raise ConfigManagerError(
"native rumble approval requires a stable Classic Nintendo "
"Pro Controller or Joy-Con identity"
)
if len(set(identities)) != len(identities):
raise ConfigManagerError("duplicate native rumble approval")
return tuple(sorted(identities, key=ControllerIdentity.to_bytes))
def read_configuration(device: UsbDevice) -> AdapterConfiguration:
envelope = _control_in(device, OP_CONFIGURATION_READ)
_raise_status(envelope)
if (
envelope.schema_version != CONFIGURATION_SCHEMA_VERSION
or len(envelope.payload) != CONFIGURATION_SIZE
or envelope.payload[3:] != bytes(5)
):
payload = envelope.payload
identities: tuple[ControllerIdentity, ...] = ()
if envelope.schema_version == 1:
if len(payload) != 4 or payload[2:] != bytes(2):
raise ConfigManagerError("unsupported configuration object")
requested_mode = REQUESTED_MODE_AUTO
elif envelope.schema_version == 2:
if len(payload) != 8 or payload[3:] != bytes(5):
raise ConfigManagerError("unsupported configuration object")
requested_mode = payload[2]
elif envelope.schema_version == CONFIGURATION_SCHEMA_VERSION:
if len(payload) != CONFIGURATION_SIZE:
raise ConfigManagerError("unsupported configuration object")
count = payload[3]
end = 8 + count * CONTROLLER_IDENTITY_SIZE
if (
count > NATIVE_SWITCH_CONTROLLER_CAPACITY
or payload[4:8] != bytes(4)
or payload[end:] != bytes(CONFIGURATION_SIZE - end)
):
raise ConfigManagerError("invalid native rumble approval encoding")
identities = tuple(
ControllerIdentity.from_bytes(
payload[offset : offset + CONTROLLER_IDENTITY_SIZE]
)
for offset in range(8, end, CONTROLLER_IDENTITY_SIZE)
)
if identities != _canonical_native_switch_controllers(identities):
raise ConfigManagerError("noncanonical native rumble approval order")
requested_mode = payload[2]
else:
raise ConfigManagerError("unsupported configuration object")
pairing_window_seconds = struct.unpack_from("<H", envelope.payload)[0]
requested_mode = envelope.payload[2]
pairing_window_seconds = struct.unpack_from("<H", payload)[0]
if not (
PAIRING_WINDOW_SECONDS_MIN
<= pairing_window_seconds
@ -3010,6 +3188,8 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration:
generation=envelope.generation,
crc=envelope.payload_crc,
requested_mode=requested_mode,
native_switch_controllers=identities,
schema_version=envelope.schema_version,
)
@ -3049,11 +3229,42 @@ def write_configuration(
configuration.requested_mode
) is not int or not 0 <= configuration.requested_mode < len(REQUESTED_MODE_NAMES):
raise ConfigManagerError("invalid requested USB mode")
payload = struct.pack(
"<HB5x",
configuration.pairing_window_seconds,
configuration.requested_mode,
identities = _canonical_native_switch_controllers(
configuration.native_switch_controllers
)
if configuration.schema_version == CONFIGURATION_SCHEMA_VERSION:
payload = (
struct.pack(
"<HBB4x",
configuration.pairing_window_seconds,
configuration.requested_mode,
len(identities),
)
+ b"".join(identity.to_bytes() for identity in identities)
+ bytes(
(NATIVE_SWITCH_CONTROLLER_CAPACITY - len(identities))
* CONTROLLER_IDENTITY_SIZE
)
)
elif configuration.schema_version in (1, 2):
if identities:
raise ConfigManagerError(
"native rumble approval requires schema 3 firmware"
)
if configuration.schema_version == 1:
if configuration.requested_mode != REQUESTED_MODE_AUTO:
raise ConfigManagerError(
"schema 1 does not support a requested USB mode"
)
payload = struct.pack("<H2x", configuration.pairing_window_seconds)
else:
payload = struct.pack(
"<HB5x",
configuration.pairing_window_seconds,
configuration.requested_mode,
)
else:
raise ConfigManagerError("unsupported configuration schema")
transaction_id = _host_transaction_id()
_control_out(
device,
@ -3061,7 +3272,7 @@ def write_configuration(
struct.pack(
"<IHHI",
transaction_id,
CONFIGURATION_SCHEMA_VERSION,
configuration.schema_version,
len(payload),
_crc32(payload),
),
@ -3077,6 +3288,28 @@ def write_configuration(
return _wait_for_transaction(device, transaction_id, timeout)
def set_native_switch_rumble_approval(
device: UsbDevice,
identity: ControllerIdentity,
approved: bool,
timeout: float,
) -> TransactionStatus:
"""Persist explicit physical-controller approval without changing its profiles."""
_require_bool(approved, "native rumble approval")
_canonical_native_switch_controllers((identity,))
before = read_configuration(device)
if before.schema_version != CONFIGURATION_SCHEMA_VERSION:
raise ConfigManagerError("native rumble approval requires schema 3 firmware")
identities = tuple(
item for item in before.native_switch_controllers if item != identity
)
if approved:
identities += (identity,)
return write_configuration(
device, replace(before, native_switch_controllers=identities), timeout
)
def reset_configuration(device: UsbDevice, timeout: float) -> TransactionStatus:
transaction_id = _host_transaction_id()
_control_out(device, OP_CONFIGURATION_RESET, struct.pack("<I", transaction_id))
@ -3971,6 +4204,47 @@ def build_parser() -> argparse.ArgumentParser:
)
config_reset = config_commands.add_parser("reset", help="restore defaults")
config_reset.add_argument("--yes", action="store_true")
native_rumble = config_commands.add_parser(
"native-rumble",
help="manage physical-controller native Nintendo rumble approvals",
description=NATIVE_SWITCH_RUMBLE_APPROVAL_NOTE,
)
native_commands = native_rumble.add_subparsers(
dest="native_rumble_command", required=True
)
native_commands.add_parser("list", help="list stored identities and approvals")
native_status = native_commands.add_parser(
"status", help="show per-slot native Nintendo rumble qualification counters"
)
native_status.add_argument(
"--json", action="store_true", help="emit JSON diagnostics"
)
for action in ("approve", "revoke"):
native_action = native_commands.add_parser(
action,
help=f"{action} native rumble for one stored physical controller",
description=NATIVE_SWITCH_RUMBLE_APPROVAL_NOTE,
)
native_selector = native_action.add_mutually_exclusive_group(required=True)
native_selector.add_argument(
"--identity",
type=_identity_index,
metavar="N",
help="physical identity index from profiles list or native-rumble list",
)
if action == "revoke":
native_selector.add_argument(
"--approval",
type=_identity_index,
metavar="N",
help="approval index from native-rumble list, including forgotten identities",
)
if action == "approve":
native_action.add_argument(
"--yes",
action="store_true",
help="confirm this is a genuine qualified Pro Controller or Joy-Con",
)
profiles = commands.add_parser(
"profiles",
@ -4054,6 +4328,18 @@ def main(argv: Sequence[str] | None = None) -> int:
if args.command == "profiles" and args.profile_command == "reset" and not args.yes:
print("error: profiles reset requires --yes", file=sys.stderr)
return 2
if (
args.command == "config"
and args.config_command == "native-rumble"
and args.native_rumble_command == "approve"
and not args.yes
):
print(
"error: native-rumble approve requires --yes. "
+ NATIVE_SWITCH_RUMBLE_APPROVAL_NOTE,
file=sys.stderr,
)
return 2
imported_profile: ControllerProfile | None = None
try:
@ -4120,16 +4406,16 @@ def main(argv: Sequence[str] | None = None) -> int:
)
print(f"generation={configuration.generation}")
print(f"crc={configuration.crc:08x}")
for identity in configuration.native_switch_controllers:
print(
f"native_switch_controller={identity.address_text} "
f"VID:PID {identity.vendor_id:04X}:{identity.product_id:04X}"
)
elif args.config_command == "set":
before = read_configuration(device)
status = write_configuration(
device,
AdapterConfiguration(
pairing_window_seconds=args.pairing_window_seconds,
generation=before.generation,
crc=before.crc,
requested_mode=before.requested_mode,
),
replace(before, pairing_window_seconds=args.pairing_window_seconds),
args.timeout,
)
print(
@ -4137,6 +4423,62 @@ def main(argv: Sequence[str] | None = None) -> int:
f"{status.stored_generation} "
f"(CRC {status.stored_crc:08x})."
)
elif args.config_command == "native-rumble":
approval_index = getattr(args, "approval", None)
entries = (
list_profiles(device)
if args.native_rumble_command != "status" and approval_index is None
else ()
)
if args.native_rumble_command == "status":
_print_native_switch_rumble_status(
read_native_switch_rumble(device), json_output=args.json
)
elif args.native_rumble_command == "list":
configuration = read_configuration(device)
print(NATIVE_SWITCH_RUMBLE_APPROVAL_NOTE)
if configuration.schema_version != CONFIGURATION_SCHEMA_VERSION:
print("Native rumble approval requires schema 3 firmware.")
_print_profiles(entries)
for index, entry in enumerate(entries):
if entry.identity in configuration.native_switch_controllers:
print(
f"Approved identity {index}: {entry.identity.address_text}"
)
for index, identity in enumerate(
configuration.native_switch_controllers
):
print(
f"Approval {index}: {identity.address_text} "
f"VID:PID {identity.vendor_id:04X}:{identity.product_id:04X} "
f"(revoke with --approval {index})"
)
if not configuration.native_switch_controllers:
print("No native rumble approvals.")
else:
if approval_index is None:
identity = _resolve_profile_identity(entries, args.identity)
else:
configuration = read_configuration(device)
if approval_index >= len(
configuration.native_switch_controllers
):
raise ConfigManagerError(
"approval index is out of range; use native-rumble list"
)
identity = configuration.native_switch_controllers[
approval_index
]
approved = args.native_rumble_command == "approve"
status = set_native_switch_rumble_approval(
device, identity, approved, args.timeout
)
print(NATIVE_SWITCH_RUMBLE_APPROVAL_NOTE)
print(
f"{'Approved' if approved else 'Revoked'} native rumble for "
f"controller {identity.address_text} at "
f"generation {status.stored_generation}."
)
else:
status = reset_configuration(device, args.timeout)
print(f"Reset configuration at generation {status.stored_generation}.")

View file

@ -101,18 +101,27 @@ ConfigurationStorageIo pico_configuration_storage_io() {
namespace {
void seed_legacy_configuration() {
void seed_legacy_configuration(bool v2) {
ConfigurationStorage seed;
const uint8_t legacy[] = {90, 0, 0, 0};
const uint8_t legacy[] = {
90, 0, static_cast<uint8_t>(v2 ? AdapterRequestedMode::kXInput
: AdapterRequestedMode::kAuto),
0, 0, 0, 0, 0,
};
require(seed.initialize(fake_io()), "legacy seed storage init failed");
require(seed.commit(ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION,
legacy, sizeof(legacy)) ==
require(seed.commit(
v2 ? ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION
: ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION,
legacy, v2 ? ADAPTER_CONFIGURATION_V2_ENCODED_SIZE
: ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE) ==
ConfigurationStorageResult::kOk,
"legacy seed commit failed");
}
void test_service_lifecycle_and_mutations() {
seed_legacy_configuration();
void test_service_lifecycle_and_mutations(bool v2) {
seed_legacy_configuration(v2);
const AdapterRequestedMode original_mode =
v2 ? AdapterRequestedMode::kXInput : AdapterRequestedMode::kAuto;
const int programs_after_seed = g_flash.program_count;
const int erases_after_seed = g_flash.erase_count;
@ -123,9 +132,9 @@ void test_service_lifecycle_and_mutations() {
configuration_service_snapshot(&snapshot);
require(snapshot.state == ConfigurationServiceState::kReady &&
snapshot.configuration.pairing_window_seconds == 90 &&
snapshot.configuration.requested_mode ==
AdapterRequestedMode::kAuto,
"Core 0 did not decode and publish the v1 configuration");
snapshot.configuration.requested_mode == original_mode &&
snapshot.configuration.native_switch_controller_count == 0,
"Core 0 did not preserve legacy settings without approval");
require(g_flash.program_count == programs_after_seed &&
g_flash.erase_count == erases_after_seed,
"pre-USB initialization wrote flash");
@ -145,16 +154,16 @@ void test_service_lifecycle_and_mutations() {
require(configuration_service_set_mode(
2, AdapterRequestedMode::kSwitch, implemented) ==
ConfigurationTransactionStatus::kBusy,
"host mutation displaced the pending v1 migration");
"host mutation displaced the pending legacy migration");
configuration_service_task_on_storage_core(0);
configuration_service_snapshot(&snapshot);
require(snapshot.state == ConfigurationServiceState::kReady &&
snapshot.configuration.requested_mode ==
AdapterRequestedMode::kAuto &&
snapshot.configuration.requested_mode == original_mode &&
snapshot.configuration.native_switch_controller_count == 0 &&
snapshot.transaction.status ==
ConfigurationTransactionStatus::kIdle,
"v1 migration changed configuration or host transaction state");
"legacy migration changed configuration or host transaction state");
ConfigurationStorage after_migration;
require(after_migration.initialize(fake_io()) &&
@ -163,15 +172,16 @@ void test_service_lifecycle_and_mutations() {
ADAPTER_CONFIGURATION_SCHEMA_VERSION &&
after_migration.snapshot().payload_size ==
ADAPTER_CONFIGURATION_ENCODED_SIZE,
"power cycle did not observe the migrated v2 record");
"power cycle did not observe the migrated v3 record");
AdapterConfiguration migrated{};
require(adapter_configuration_decode(
after_migration.snapshot().schema_version,
after_migration.snapshot().payload,
after_migration.snapshot().payload_size, &migrated) &&
migrated.pairing_window_seconds == 90 &&
migrated.requested_mode == AdapterRequestedMode::kAuto,
"migrated v2 bytes did not preserve v1 configuration");
migrated.requested_mode == original_mode &&
migrated.native_switch_controller_count == 0,
"migrated v3 bytes did not preserve legacy configuration");
require(configuration_service_set_mode(
10, AdapterRequestedMode::kSwitch, implemented) ==
@ -391,7 +401,7 @@ void test_service_lifecycle_and_mutations() {
snapshot.transaction.status ==
ConfigurationTransactionStatus::kCommitted &&
snapshot.reset_generation == reset_generation_before + 1,
"configuration reset did not durably restore v2 defaults");
"configuration reset did not durably restore v3 defaults");
constexpr uint32_t kCapturedHostMode = 50;
require(configuration_service_set_mode(
@ -508,13 +518,178 @@ void test_abandoned_host_receive_does_not_block_recovery() {
"recovery Auto did not become latest reboot authority");
}
void queue_configuration(uint32_t transaction_id,
const AdapterConfiguration& configuration) {
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_encode(configuration, payload, sizeof(payload)),
"service approval fixture did not encode");
require(configuration_service_begin(
transaction_id, ADAPTER_CONFIGURATION_SCHEMA_VERSION,
sizeof(payload), configuration_crc32(payload, sizeof(payload))) ==
ConfigurationTransactionStatus::kReceiving &&
configuration_service_append(transaction_id, 0, payload,
sizeof(payload)) ==
ConfigurationTransactionStatus::kReceiving &&
configuration_service_commit(transaction_id) ==
ConfigurationTransactionStatus::kPending,
"approval configuration did not reach pending commit");
}
void test_native_switch_approval_preservation_and_revocation() {
ControllerIdentity pro{};
pro.stable = true;
pro.transport = ControllerTransport::kClassic;
pro.address[0] = 0x02;
pro.address[5] = 1;
pro.vendor_id = 0x057e;
pro.product_id = 0x2009;
ControllerIdentity left = pro;
left.address[5] = 2;
left.product_id = 0x2006;
AdapterConfiguration configuration{};
configuration.pairing_window_seconds = 90;
configuration.native_switch_controller_count = 2;
configuration.native_switch_controllers[0] = pro;
configuration.native_switch_controllers[1] = left;
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
ConfigurationStorage seed;
require(adapter_configuration_encode(configuration, payload, sizeof(payload)) &&
seed.initialize(fake_io()) &&
seed.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload)) == ConfigurationStorageResult::kOk,
"approved controller configuration did not persist");
configuration_service_prepare();
configuration_service_initialize_pre_usb();
ConfigurationServiceSnapshot snapshot{};
configuration_service_snapshot(&snapshot);
require(adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"pre-USB snapshot did not expose persisted approvals");
configuration_service_initialize_on_storage_core();
const AdapterModeAvailability implemented{true, true, true, true};
require(configuration_service_set_mode(
1, AdapterRequestedMode::kSwitch, implemented) ==
ConfigurationTransactionStatus::kPending,
"approved configuration blocked host mode selection");
configuration_service_task_on_storage_core(0);
configuration_service_snapshot(&snapshot);
require(snapshot.configuration.requested_mode == AdapterRequestedMode::kSwitch &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"host mode selection erased approval");
require(configuration_service_set_mode_internal(
0x80000002u, AdapterRequestedMode::kXInput, implemented) ==
ConfigurationTransactionStatus::kPending,
"approved configuration blocked internal mode selection");
configuration_service_task_on_storage_core(1000);
configuration_service_snapshot(&snapshot);
require(snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"internal mode selection erased approval");
configuration = snapshot.configuration;
configuration.pairing_window_seconds = 120;
queue_configuration(3, configuration);
configuration_service_task_on_storage_core(2000);
configuration_service_snapshot(&snapshot);
require(snapshot.configuration.pairing_window_seconds == 120 &&
snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"pairing-window update erased approvals or requested mode");
const uint32_t approved_generation = snapshot.generation;
const uint32_t approved_crc = snapshot.payload_crc;
configuration = snapshot.configuration;
configuration.native_switch_controller_count = 1;
configuration.native_switch_controllers[0] = left;
queue_configuration(4, configuration);
configuration_service_snapshot(&snapshot);
require(adapter_configuration_native_switch_approved(
snapshot.configuration, pro),
"approval was revoked before durable commit");
g_flash.fail_program = true;
configuration_service_task_on_storage_core(3000);
g_flash.fail_program = false;
configuration_service_snapshot(&snapshot);
require(snapshot.transaction.status ==
ConfigurationTransactionStatus::kStorageError &&
snapshot.generation == approved_generation &&
snapshot.payload_crc == approved_crc &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro),
"failed revocation changed the published durable configuration");
ConfigurationStorage after_failure;
AdapterConfiguration recovered{};
require(after_failure.initialize(fake_io()) &&
adapter_configuration_decode(
after_failure.snapshot().schema_version,
after_failure.snapshot().payload,
after_failure.snapshot().payload_size, &recovered) &&
adapter_configuration_native_switch_approved(recovered, pro) &&
adapter_configuration_native_switch_approved(recovered, left),
"interrupted revocation destroyed persisted approvals");
queue_configuration(5, configuration);
configuration_service_task_on_storage_core(3000);
configuration_service_snapshot(&snapshot);
require(snapshot.transaction.status ==
ConfigurationTransactionStatus::kCommitted &&
snapshot.configuration.pairing_window_seconds == 120 &&
snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
!adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"successful revocation did not preserve other approval and settings");
ConfigurationStorage after_revocation;
require(after_revocation.initialize(fake_io()) &&
adapter_configuration_decode(
after_revocation.snapshot().schema_version,
after_revocation.snapshot().payload,
after_revocation.snapshot().payload_size, &recovered) &&
!adapter_configuration_native_switch_approved(recovered, pro) &&
adapter_configuration_native_switch_approved(recovered, left),
"controller-specific revocation did not survive power cycle");
require(configuration_service_reset(6) ==
ConfigurationTransactionStatus::kPending,
"approved configuration reset was not queued");
configuration_service_snapshot(&snapshot);
require(adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"reset erased approval before commit");
configuration_service_task_on_storage_core(4000);
configuration_service_snapshot(&snapshot);
require(snapshot.transaction.status == ConfigurationTransactionStatus::kCommitted &&
!adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"configuration reset did not revoke persisted approval");
}
} // namespace
int main(int argc, char**) {
if (argc > 1) {
test_abandoned_host_receive_does_not_block_recovery();
int main(int argc, char** argv) {
if (argc < 2 || strcmp(argv[1], "lifecycle") == 0) {
test_service_lifecycle_and_mutations(false);
} else if (strcmp(argv[1], "v2-migration") == 0) {
test_service_lifecycle_and_mutations(true);
} else if (strcmp(argv[1], "native-approvals") == 0) {
test_native_switch_approval_preservation_and_revocation();
} else {
test_service_lifecycle_and_mutations();
require(strcmp(argv[1], "abandoned-receive") == 0,
"unknown service test scenario");
test_abandoned_host_receive_does_not_block_recovery();
}
return 0;
}

View file

@ -74,6 +74,18 @@ ConfigurationStorageIo fake_io(FakeFlash* flash) {
};
}
ControllerIdentity nintendo_identity(uint8_t address_suffix,
uint16_t product_id = 0x2009) {
ControllerIdentity identity{};
identity.stable = true;
identity.transport = ControllerTransport::kClassic;
identity.address[0] = 0x02;
identity.address[5] = address_suffix;
identity.vendor_id = 0x057e;
identity.product_id = product_id;
return identity;
}
void test_schema_encoding() {
AdapterConfiguration configuration{};
configuration.pairing_window_seconds = 90;
@ -81,8 +93,8 @@ void test_schema_encoding() {
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_encode(configuration, payload,
sizeof(payload)),
"valid v2 configuration did not encode");
const uint8_t expected[] = {
"valid v3 configuration did not encode");
const uint8_t expected[ADAPTER_CONFIGURATION_ENCODED_SIZE] = {
90,
0,
static_cast<uint8_t>(AdapterRequestedMode::kXInput),
@ -93,15 +105,16 @@ void test_schema_encoding() {
0,
};
require(memcmp(payload, expected, sizeof(expected)) == 0,
"v2 configuration bytes are not canonical");
"v3 configuration bytes are not canonical");
AdapterConfiguration decoded{};
require(adapter_configuration_decode(
ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload), &decoded) &&
decoded.pairing_window_seconds == 90 &&
decoded.requested_mode == AdapterRequestedMode::kXInput,
"v2 configuration did not round trip");
decoded.requested_mode == AdapterRequestedMode::kXInput &&
decoded.native_switch_controller_count == 0,
"v3 configuration did not round trip");
const AdapterRequestedMode valid_modes[] = {
AdapterRequestedMode::kAuto,
@ -136,12 +149,15 @@ void test_schema_encoding() {
"availability API could not enable future modes");
const uint8_t legacy[] = {120, 0, 0, 0};
decoded.native_switch_controller_count = 1;
decoded.native_switch_controllers[0] = nintendo_identity(1);
require(adapter_configuration_decode(
ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION, legacy,
sizeof(legacy), &decoded) &&
decoded.pairing_window_seconds == 120 &&
decoded.requested_mode == AdapterRequestedMode::kAuto,
"v1 configuration did not migrate to auto");
decoded.requested_mode == AdapterRequestedMode::kAuto &&
decoded.native_switch_controller_count == 0,
"v1 configuration did not migrate without approvals");
uint8_t malformed_legacy[sizeof(legacy)];
memcpy(malformed_legacy, legacy, sizeof(legacy));
malformed_legacy[3] = 1;
@ -154,6 +170,30 @@ void test_schema_encoding() {
sizeof(legacy) - 1, &decoded),
"v1 record with wrong size was accepted");
const uint8_t v2[] = {
120, 0, static_cast<uint8_t>(AdapterRequestedMode::kMac), 0, 0, 0, 0, 0,
};
require(adapter_configuration_decode(
ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION, v2, sizeof(v2),
&decoded) &&
decoded.pairing_window_seconds == 120 &&
decoded.requested_mode == AdapterRequestedMode::kMac &&
decoded.native_switch_controller_count == 0,
"v2 migration changed settings or granted native rumble");
for (size_t index = 3; index < sizeof(v2); ++index) {
uint8_t malformed[sizeof(v2)];
memcpy(malformed, v2, sizeof(v2));
malformed[index] = 1;
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION, malformed,
sizeof(malformed), &decoded),
"v2 nonzero reserved byte was accepted");
}
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION, v2, sizeof(v2) - 1,
&decoded),
"v2 record with wrong size was accepted");
for (size_t index = 3; index < sizeof(payload); ++index) {
uint8_t malformed[sizeof(payload)];
memcpy(malformed, payload, sizeof(payload));
@ -161,7 +201,7 @@ void test_schema_encoding() {
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_SCHEMA_VERSION, malformed,
sizeof(malformed), &decoded),
"v2 nonzero reserved byte was accepted");
"v3 nonzero reserved or unused byte was accepted");
}
uint8_t invalid_mode[sizeof(payload)];
memcpy(invalid_mode, payload, sizeof(payload));
@ -173,13 +213,13 @@ void test_schema_encoding() {
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload) - 1, &decoded),
"short v2 record was accepted");
"short v3 record was accepted");
uint8_t oversized[ADAPTER_CONFIGURATION_ENCODED_SIZE + 1]{};
memcpy(oversized, payload, sizeof(payload));
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_SCHEMA_VERSION, oversized,
sizeof(oversized), &decoded),
"oversized v2 record was accepted");
"oversized v3 record was accepted");
configuration.pairing_window_seconds = 9;
require(!adapter_configuration_encode(configuration, payload,
@ -194,7 +234,193 @@ void test_schema_encoding() {
configuration.requested_mode = AdapterRequestedMode::kAuto;
require(!adapter_configuration_encode(configuration, oversized,
sizeof(oversized)),
"v2 encoder accepted a noncanonical output size");
"v3 encoder accepted a noncanonical output size");
}
void test_native_switch_approval_identity_and_canonical_encoding() {
const ControllerIdentity pro = nintendo_identity(3);
const ControllerIdentity left = nintendo_identity(1, 0x2006);
const ControllerIdentity right = nintendo_identity(2, 0x2007);
AdapterConfiguration configuration{};
require(!adapter_configuration_native_switch_approved(configuration, pro),
"a supported model was approved without an explicit identity");
configuration.native_switch_controller_count = 3;
configuration.native_switch_controllers[0] = pro;
configuration.native_switch_controllers[1] = left;
configuration.native_switch_controllers[2] = right;
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_encode(configuration, payload, sizeof(payload)),
"original Switch controller approvals did not encode");
AdapterConfiguration decoded{};
require(adapter_configuration_decode(payload, sizeof(payload), &decoded) &&
adapter_configuration_native_switch_approved(decoded, pro) &&
adapter_configuration_native_switch_approved(decoded, left) &&
adapter_configuration_native_switch_approved(decoded, right) &&
!adapter_configuration_native_switch_approved(
decoded, nintendo_identity(4)) &&
!adapter_configuration_native_switch_approved(
decoded, nintendo_identity(3, 0x2006)),
"approval did not remain specific to the complete controller identity");
configuration.native_switch_controllers[0] = right;
configuration.native_switch_controllers[1] = pro;
configuration.native_switch_controllers[2] = left;
uint8_t reordered[sizeof(payload)]{};
require(adapter_configuration_encode(configuration, reordered,
sizeof(reordered)) &&
memcmp(payload, reordered, sizeof(payload)) == 0,
"approval insertion order changed the persisted bytes or CRC");
configuration.native_switch_controller_count = 0;
require(!adapter_configuration_native_switch_approved(configuration, pro) &&
adapter_configuration_encode(configuration, reordered,
sizeof(reordered)) &&
adapter_configuration_decode(reordered, sizeof(reordered),
&decoded) &&
!adapter_configuration_native_switch_approved(decoded, pro),
"revoked array entries remained approved after encode and decode");
configuration.native_switch_controller_count =
ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY;
for (size_t index = 0;
index < configuration.native_switch_controller_count; ++index) {
configuration.native_switch_controllers[index] =
nintendo_identity(static_cast<uint8_t>(
configuration.native_switch_controller_count - index));
}
require(adapter_configuration_encode(configuration, payload, sizeof(payload)) &&
adapter_configuration_decode(payload, sizeof(payload), &decoded) &&
adapter_configuration_native_switch_approved(
decoded, nintendo_identity(1)) &&
adapter_configuration_native_switch_approved(
decoded, nintendo_identity(16)),
"a full approval array lost its boundary identities");
++configuration.native_switch_controller_count;
require(!adapter_configuration_encode(configuration, payload, sizeof(payload)) &&
!adapter_configuration_native_switch_approved(configuration, pro),
"an oversized approval array was accepted");
payload[3] = configuration.native_switch_controller_count;
require(!adapter_configuration_decode(payload, sizeof(payload), &decoded),
"an oversized encoded approval count was accepted");
}
void test_native_switch_approval_rejects_invalid_records() {
AdapterConfiguration configuration{};
configuration.native_switch_controller_count = 2;
configuration.native_switch_controllers[0] = nintendo_identity(1);
configuration.native_switch_controllers[1] = nintendo_identity(2);
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_encode(configuration, payload, sizeof(payload)),
"approval validation fixture did not encode");
configuration.native_switch_controllers[1] =
configuration.native_switch_controllers[0];
uint8_t malformed[sizeof(payload)]{};
require(!adapter_configuration_encode(configuration, malformed,
sizeof(malformed)),
"duplicate approval identities encoded");
memcpy(malformed, payload, sizeof(payload));
constexpr size_t first = ADAPTER_CONFIGURATION_HEADER_SIZE;
constexpr size_t second = first + CONTROLLER_IDENTITY_ENCODED_SIZE;
memcpy(malformed + second, malformed + first,
CONTROLLER_IDENTITY_ENCODED_SIZE);
AdapterConfiguration decoded{};
require(!adapter_configuration_decode(malformed, sizeof(malformed), &decoded),
"duplicate encoded approval identities were accepted");
memcpy(malformed + first, payload + second, CONTROLLER_IDENTITY_ENCODED_SIZE);
memcpy(malformed + second, payload + first, CONTROLLER_IDENTITY_ENCODED_SIZE);
require(!adapter_configuration_decode(malformed, sizeof(malformed), &decoded),
"noncanonical approval ordering was accepted");
configuration.native_switch_controller_count = 1;
ControllerIdentity invalid[] = {
controller_identity_global(), nintendo_identity(1),
nintendo_identity(1), nintendo_identity(1), nintendo_identity(1, 0x2069),
};
invalid[1].stable = false;
invalid[2].transport = ControllerTransport::kBle;
invalid[3].vendor_id = 0x1234;
for (const ControllerIdentity& identity : invalid) {
configuration.native_switch_controllers[0] = identity;
require(!adapter_configuration_encode(configuration, malformed,
sizeof(malformed)) &&
!adapter_configuration_native_switch_approved(
configuration, identity),
"global, unstable, BLE, or ineligible controller was approved");
}
const uint8_t invalid_fields[][2] = {
{0, 0}, {0, 2}, {1, 0}, {1, 2}, {1, 3}, {3, 1},
{10, 0x34}, {12, 0x69},
};
for (const auto& field : invalid_fields) {
memcpy(malformed, payload, sizeof(payload));
malformed[first + field[0]] = field[1];
require(!adapter_configuration_decode(malformed, sizeof(malformed),
&decoded),
"malformed or ineligible encoded identity was accepted");
}
memcpy(malformed, payload, sizeof(payload));
malformed[sizeof(malformed) - 1] = 1;
require(!adapter_configuration_decode(malformed, sizeof(malformed), &decoded),
"nonzero unused approval bytes were accepted");
}
void test_legacy_migration_power_loss() {
const uint16_t versions[] = {
ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION,
ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION,
};
for (uint16_t version : versions) {
const bool v2 = version == ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION;
const uint8_t legacy[] = {
90, 0, static_cast<uint8_t>(v2 ? AdapterRequestedMode::kXInput
: AdapterRequestedMode::kAuto),
0, 0, 0, 0, 0,
};
const size_t legacy_size = v2 ? ADAPTER_CONFIGURATION_V2_ENCODED_SIZE
: ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE;
FakeFlash flash;
ConfigurationStorage store;
require(store.initialize(fake_io(&flash)) &&
store.commit(version, legacy, legacy_size) ==
ConfigurationStorageResult::kOk,
"legacy migration seed did not persist");
AdapterConfiguration migrated{};
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_decode(version, legacy, legacy_size,
&migrated) &&
adapter_configuration_encode(migrated, payload,
sizeof(payload)),
"legacy settings did not convert to schema3");
flash.fail_after_programs = flash.successful_programs;
require(store.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload)) ==
ConfigurationStorageResult::kIoError,
"failed migration reported success");
flash.fail_after_programs = -1;
ConfigurationStorage recovered;
require(recovered.initialize(fake_io(&flash)) &&
recovered.snapshot().schema_version == version &&
recovered.snapshot().payload_size == legacy_size &&
memcmp(recovered.snapshot().payload, legacy, legacy_size) == 0,
"migration power loss destroyed the legacy settings");
require(recovered.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload)) ==
ConfigurationStorageResult::kOk,
"migration retry failed");
ConfigurationStorage rebooted;
AdapterConfiguration decoded{};
require(rebooted.initialize(fake_io(&flash)) &&
rebooted.snapshot().schema_version ==
ADAPTER_CONFIGURATION_SCHEMA_VERSION &&
adapter_configuration_decode(
rebooted.snapshot().schema_version,
rebooted.snapshot().payload,
rebooted.snapshot().payload_size, &decoded) &&
decoded.pairing_window_seconds == 90 &&
decoded.requested_mode == migrated.requested_mode &&
decoded.native_switch_controller_count == 0,
"migration retry changed settings or granted rumble approval");
}
}
void test_two_copy_recovery() {
@ -320,12 +546,34 @@ void test_transaction_validation() {
CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE + 1, crc) ==
ConfigurationTransactionStatus::kTooLarge,
"oversized transaction was accepted");
require(transaction.begin(
13, ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION,
ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE, 0) ==
ConfigurationTransactionStatus::kUnsupportedSchema &&
transaction.begin(
14, ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION,
ADAPTER_CONFIGURATION_V2_ENCODED_SIZE, 0) ==
ConfigurationTransactionStatus::kUnsupportedSchema,
"legacy host writes could silently erase stored approvals");
payload[3] = ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY + 1;
require(transaction.begin(
15, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload),
configuration_crc32(payload, sizeof(payload))) ==
ConfigurationTransactionStatus::kReceiving &&
transaction.append(15, 0, payload, sizeof(payload)) ==
ConfigurationTransactionStatus::kReceiving &&
transaction.finish(15) ==
ConfigurationTransactionStatus::kMalformed,
"valid CRC allowed a malformed approval list to reach storage");
}
} // namespace
int main() {
test_schema_encoding();
test_native_switch_approval_identity_and_canonical_encoding();
test_native_switch_approval_rejects_invalid_records();
test_legacy_migration_power_loss();
test_two_copy_recovery();
test_interrupted_write_retains_previous_generation();
test_transaction_validation();

View file

@ -0,0 +1,323 @@
#include "usb/switch/switch_native_haptics.h"
#include "profile/controller_profile.h"
#include "profile/controller_profile_transform.h"
#include <cstdlib>
#include <cstring>
#include <iostream>
namespace {
constexpr uint8_t kNeutral[8] = {0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kSeed[8] = {0x00, 0x21, 0x40, 0x48, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kOne[8] = {0x00, 0x00, 0x10, 0x69, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kTwo[8] = {0x00, 0x74, 0x1c, 0xa9, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kThree[8] = {0x78, 0x77, 0x1c, 0xe9, 0x00, 0x01, 0x40, 0x40};
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
void expect_packet(const SwitchNativeHapticsPackets& packets, const uint8_t expected[8],
const char* message) {
require(packets.count == 1 && std::memcmp(packets.bytes[0], expected, 8) == 0, message);
}
ControllerRumbleOutput single(SwitchHapticsSample left, SwitchHapticsSample right = {}) {
ControllerRumbleOutput output{};
output.hd.actuators[0].sample_count = 1;
output.hd.actuators[1].sample_count = 1;
output.hd.actuators[0].samples[0] = left;
output.hd.actuators[1].samples[0] = right;
return output;
}
ControllerRumbleOutput play(SwitchHapticsDecoder& device, const SwitchNativeHapticsPackets& packets) {
require(packets.count >= 1 && packets.count <= 2, "encoder exceeded bounded packet schedule");
ControllerRumbleOutput result{};
for (uint8_t i = 0; i < packets.count; ++i) {
result = device.decode(packets.bytes[i]);
for (const auto& side : result.hd.actuators) {
require(side.sample_count >= 1 && side.sample_count <= 3, "invalid generated substep count");
for (uint8_t step = 0; step < side.sample_count; ++step) {
const auto& sample = side.samples[step];
require(sample.low_amplitude_q15 <= 17867 && sample.high_amplitude_q15 <= 17867,
"generated an amplitude above documented absolute code 100");
require(sample.low_frequency_index >= 1 && sample.low_frequency_index <= 127 &&
sample.high_frequency_index >= 1 && sample.high_frequency_index <= 127,
"generated frequency outside public absolute range");
}
}
}
return result;
}
void expect_sample(const SwitchHapticsSample& actual, const SwitchHapticsSample& expected) {
require(actual.low_frequency_index == expected.low_frequency_index &&
actual.high_frequency_index == expected.high_frequency_index &&
actual.low_amplitude_q15 == expected.low_amplitude_q15 &&
actual.high_amplitude_q15 == expected.high_amplitude_q15,
"native output lost band frequency/amplitude or substep order");
}
void test_public_absolute_goldens() {
// Independent dekuNukem rumble_data_table.md byte example: HF=0x1a8,
// HA=0x88, LF=0x63, LA=0x804d => a8 89 e3 4d. The input amplitudes are
// this project's normalized LUT values, not the public physical amplitudes.
SwitchNativeHapticsEncoder encoder;
constexpr uint8_t expected[8] = {0xa8, 0x89, 0xe3, 0x4d, 0x80, 0x00, 0x40, 0x52};
const auto output = encoder.encode(single({99, 106, 3371, 8933}, {64, 32, 4467, 0}), false, false);
expect_packet(output, expected, "public absolute vector/band-actuator isolation mismatch");
require(!output.raw && !output.quantized, "exact absolute vector was changed");
expect_packet(encoder.encode({}, false, false), kNeutral, "conventional zero was not exact neutral");
constexpr uint8_t safe_max[8] = {0x00, 0xc9, 0x40, 0x72, 0x00, 0xc9, 0x40, 0x72};
expect_packet(encoder.encode({255, 255}, false, true), safe_max,
"conventional maximum did not map to safe fixed carriers");
const auto clamped = encoder.encode(single({64, 64, 32767, 32767}, {64, 64, 32767, 32767}), false, true);
expect_packet(clamped, safe_max, "unsafe HD amplitude escaped wire code100 clamp");
require(clamped.quantized && !clamped.raw, "safety clamp was not observable");
}
void test_provenance_and_profile_gains() {
SwitchHapticsDecoder host;
SwitchNativeHapticsEncoder encoder;
const ControllerRumbleOutput conventional{33, 71};
require(!conventional.raw_valid && !conventional.raw_unmodified,
"conventional rumble acquired Nintendo wire provenance");
require(!host.decode(nullptr).raw_valid, "missing payload acquired raw provenance");
constexpr uint8_t max_left[8] = {0x00, 0xc9, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40};
const auto decoded = host.decode(max_left);
require(decoded.raw_valid && decoded.raw_unmodified && std::memcmp(decoded.raw, max_left, 8) == 0,
"Switch decode did not preserve original bytes and provenance");
ControllerProfile profile{};
profile.strong_rumble_scale = 255;
profile.weak_rumble_scale = 255;
const auto unity = controller_profile_scale_host_rumble(decoded, profile);
const auto raw = encoder.encode(unity, false, true);
expect_packet(raw, max_left, "unity did not preserve independently specified wire bytes");
require(raw.raw, "safe synchronized unity did not use raw path");
profile.strong_rumble_scale = 64;
profile.weak_rumble_scale = 128;
const auto scaled = controller_profile_scale_host_rumble(decoded, profile);
require(scaled.raw_valid && !scaled.raw_unmodified, "profile gains failed to revoke raw fast-path permission");
constexpr uint8_t intermediate[8] = {0x00, 0x89, 0x40, 0x52, 0x00, 0x01, 0x40, 0x40};
const auto intermediate_packets = encoder.encode(scaled, false, true);
expect_packet(intermediate_packets, intermediate, "intermediate band gains treated Q15 as wire amplitude");
require(!intermediate_packets.raw && intermediate_packets.quantized,
"intermediate Q15 rounding was not reported");
profile.strong_rumble_scale = 255;
profile.weak_rumble_scale = 255;
require(!controller_profile_scale_host_rumble(scaled, profile).raw_unmodified,
"later unity gain restored revoked raw provenance");
profile.strong_rumble_scale = 0;
profile.weak_rumble_scale = 0;
expect_packet(encoder.encode(controller_profile_scale_host_rumble(decoded, profile), false, true),
kNeutral, "zero gains did not produce exact silence");
}
ControllerRumbleOutput sequence(uint8_t count) {
auto output = single({65, 65, 2139, 2282});
auto& side = output.hd.actuators[0];
side.sample_count = count;
side.samples[1] = {65, 66, 2139, 2093};
side.samples[2] = {65, 66, 2093, 2093};
return output;
}
void test_compressed_goldens_and_resynchronization() {
// Independently hand-packed command indices from existing protocol forms:
// H=[+4/+1Hz, -4/+1Hz, hold], L=[+1/+1Hz, hold, -1/hold].
// These defend bit placement/order independently of decoder round trips;
// they do not claim physical-controller acceptance of compressed forms.
const uint8_t* goldens[3] = {kOne, kTwo, kThree};
for (uint8_t count = 1; count <= 3; ++count) {
SwitchNativeHapticsEncoder encoder;
encoder.encode(single({64, 64, 2093, 2093}), false, false);
const auto packets = encoder.encode(sequence(count), false, false);
expect_packet(packets, goldens[count - 1], "compressed one/two/three-step golden mismatch");
require(!packets.quantized, "representable ordered substeps were quantized");
}
SwitchHapticsDecoder host;
SwitchNativeHapticsEncoder encoder;
encoder.encode(host.decode(kSeed), false, true);
const auto commands = host.decode(kThree);
const auto forwarded = encoder.encode(commands, false, true);
expect_packet(forwarded, kThree, "synchronized compressed unity was not exact");
require(forwarded.raw, "safe synchronized compressed unity did not use raw");
const auto repeated = host.decode(kThree);
require(repeated.hd.actuators[0].sample_count == 1, "host repeat did not retain endpoint");
require(encoder.encode(repeated, false, true).raw, "same-word hold unexpectedly lost synchronization");
encoder.reset();
SwitchHapticsDecoder device;
const auto recovery = encoder.encode(commands, false, true);
require(!recovery.raw && recovery.count == 2 && !recovery.quantized,
"dropped history did not trigger exact baseline recovery");
const auto result = play(device, recovery);
require(result.hd.actuators[0].sample_count == 3, "recovery collapsed three substeps");
for (uint8_t step = 0; step < 3; ++step) expect_sample(result.hd.actuators[0].samples[step], sequence(3).hd.actuators[0].samples[step]);
// A stale raw envelope after changed profile/output must be compared with
// physical state, not trusted solely because its flags still say unity.
encoder.encode(single({64, 64, 4467, 0}), false, false);
require(!encoder.encode(repeated, false, true).raw, "raw reuse ignored changed physical state");
}
void test_absolute_plus_commands_and_selected_coordinate() {
SwitchNativeHapticsEncoder encoder;
SwitchHapticsDecoder host;
SwitchHapticsDecoder device;
play(device, encoder.encode(host.decode(kSeed), false, true));
// Type 4: H absolute code32/frequency70; L command20, then H24/L17.
constexpr uint8_t type4[8] = {0x8d, 0x38, 0x52, 0x90, 0x00, 0x01, 0x40, 0x40};
const auto desired = host.decode(type4);
const auto encoded = encoder.encode(desired, false, false);
require(encoded.count == 1, "representable mixed form needed extra packets");
const auto actual = play(device, encoded);
require(actual.hd.actuators[0].sample_count == 2, "mixed form lost a substep");
expect_sample(actual.hd.actuators[0].samples[0], {65, 70, 2139, 4096});
expect_sample(actual.hd.actuators[0].samples[1], {66, 70, 2332, 4096});
require(!encoded.quantized, "representable mixed absolute/relative form was quantized");
// Preserve non-absolute low amplitude index134 while updating only H freq.
constexpr uint8_t selected[8] = {0x07, 0x00, 0x00, 0x68, 0x00, 0x01, 0x40, 0x40};
const auto frequency = host.decode(selected);
const auto selected_packets = encoder.encode(frequency, false, false);
require(selected_packets.count == 1, "selected coordinate needed extra packets");
const auto selected_result = play(device, selected_packets);
expect_sample(selected_result.hd.actuators[0].samples[0], {66, 80, 2332, 4096});
}
void test_relative_only_amplitude_survives_prefixes() {
SwitchNativeHapticsEncoder encoder;
SwitchHapticsDecoder host;
SwitchHapticsDecoder device;
// Reach internal index2 through two distinct increment words. It is below
// the first nonzero absolute code (index15), yet is a legal relative state.
constexpr uint8_t first[8] = {0x00, 0x00, 0x50, 0x6b, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t second[8] = {0x00, 0x00, 0x50, 0x69, 0x00, 0x01, 0x40, 0x40};
play(device, encoder.encode(host.decode(first), false, true));
play(device, encoder.encode(host.decode(second), false, true));
auto input = single({80, 64, 134, 134});
input.hd.actuators[0].sample_count = 3;
input.hd.actuators[0].samples[1] = {81, 64, 137, 134};
input.hd.actuators[0].samples[2] = {82, 64, 140, 134};
const auto packets = encoder.encode(input, false, false);
constexpr uint8_t frequency_prefix[4] = {0x06, 0x00, 0x00, 0x68};
require(packets.count == 2 && !packets.quantized &&
std::memcmp(packets.bytes[0], frequency_prefix, 4) == 0,
"single-coordinate prefix rounded an existing relative-only amplitude");
const auto result = play(device, packets);
require(result.hd.actuators[0].sample_count == 3, "state prefix lost temporal slots");
for (uint8_t step = 0; step < 3; ++step)
expect_sample(result.hd.actuators[0].samples[step], input.hd.actuators[0].samples[step]);
// The right side now needs an absolute baseline; its partner's relative
// amplitude must not be rounded just to make both setup words absolute.
input.hd.actuators[0].samples[0] = {82, 64, 140, 134};
input.hd.actuators[0].samples[1] = {82, 64, 143, 134};
input.hd.actuators[0].samples[2] = {83, 64, 146, 134};
input.hd.actuators[1] = sequence(3).hd.actuators[0];
const auto partner_recovery = encoder.encode(input, false, false);
const auto partner_result = play(device, partner_recovery);
require(partner_recovery.count == 2 && !partner_recovery.quantized,
"partner recovery quantized an independently representable side");
for (uint8_t side = 0; side < 2; ++side)
for (uint8_t step = 0; step < 3; ++step)
expect_sample(partner_result.hd.actuators[side].samples[step], input.hd.actuators[side].samples[step]);
// Resync from established neutral must distinguish internal silent index1
// from absolute zero, or the representable first index2 step gets rounded.
encoder.reset();
device.reset();
input = single({64, 64, 134, 0});
input.hd.actuators[0].sample_count = 3;
input.hd.actuators[0].samples[1] = {65, 64, 137, 0};
input.hd.actuators[0].samples[2] = {66, 64, 140, 0};
const auto relative_recovery = encoder.encode(input, false, false);
const auto recovered = play(device, relative_recovery);
require(relative_recovery.count == 2 && !relative_recovery.quantized,
"relative-only predecessor was lost during recovery");
for (uint8_t step = 0; step < 3; ++step)
expect_sample(recovered.hd.actuators[0].samples[step], input.hd.actuators[0].samples[step]);
encoder.reset();
device.reset();
const auto collision = encoder.encode(single({66, 64, 134, 0}), false, false);
const auto collision_result = play(device, collision);
require(collision.count == 2 && !collision.quantized &&
std::memcmp(collision.bytes[0], collision.bytes[1], 4) != 0,
"same-word prefix collision suppressed a representable relative increment");
expect_sample(collision_result.hd.actuators[0].samples[0], {66, 64, 134, 0});
}
void test_mono_bands_ties_and_temporal_policy() {
SwitchNativeHapticsEncoder encoder;
auto input = single({32, 80, 4467, 2093}, {96, 100, 2093, 8933});
constexpr uint8_t mono[8] = {0x90, 0x89, 0x20, 0x52, 0x90, 0x89, 0x20, 0x52};
expect_packet(encoder.encode(input, true, true), mono, "mono did not independently select dominant bands");
input.hd.actuators[1].samples[0].low_amplitude_q15 = 4467;
expect_packet(encoder.encode(input, true, true), mono, "mono tie did not retain left band frequency");
// Dominance is evaluated BEFORE LUT rounding: 4468 > 4467 although both
// round to the same safe amplitude. The right low-band frequency must win.
input.hd.actuators[1].samples[0].low_amplitude_q15 = 4468;
constexpr uint8_t near_tie[8] = {0x90, 0x89, 0x60, 0x52, 0x90, 0x89, 0x60, 0x52};
expect_packet(encoder.encode(input, true, true), near_tie, "LUT rounding changed mono dominance");
encoder.reset();
SwitchHapticsDecoder device;
input = sequence(3);
input.hd.actuators[1].samples[0] = {65, 66, 0, 4467};
const auto packets = encoder.encode(input, true, false);
const auto result = play(device, packets);
require(packets.quantized && result.hd.actuators[0].sample_count == 3 &&
result.hd.actuators[1].sample_count == 3,
"unequal-side temporal quantization was hidden or dropped slots");
for (uint8_t step = 0; step < 3; ++step) {
const auto expected = SwitchHapticsSample{65, 66, sequence(3).hd.actuators[0].samples[step].low_amplitude_q15, 4467};
expect_sample(result.hd.actuators[0].samples[step], expected);
expect_sample(result.hd.actuators[1].samples[step], expected);
}
}
void test_unrepresentable_timeline_and_safety() {
SwitchNativeHapticsEncoder encoder;
SwitchHapticsDecoder device;
auto input = single({64, 64, 17867, 0});
auto& left = input.hd.actuators[0];
left.sample_count = 3;
left.samples[1] = {127, 1, 0, 17867};
left.samples[2] = {1, 127, 17867, 0};
const auto packets = encoder.encode(input, false, false);
const auto result = play(device, packets);
const auto& actual = result.hd.actuators[0];
require(packets.count == 2 && packets.quantized && actual.sample_count == 3,
"unrepresentable sequence did not expose bounded three-slot quantization");
require(actual.samples[0].low_amplitude_q15 == 17867 && actual.samples[0].high_amplitude_q15 == 0 &&
actual.samples[1].low_amplitude_q15 == 0 && actual.samples[1].high_amplitude_q15 > 0 &&
actual.samples[2].low_amplitude_q15 > 0 && actual.samples[2].high_amplitude_q15 == 0,
"quantization collapsed band transitions or introduced sound into a zero band");
SwitchHapticsDecoder host;
// Unsafe host command substitute240 briefly peaks then stops; checking only
// final endpoint would mistakenly authorize the unsafe raw packet.
constexpr uint8_t unsafe_steps[8] = {0x21, 0x84, 0x10, 0xc4, 0x00, 0x01, 0x40, 0x40};
const auto unsafe = encoder.encode(host.decode(unsafe_steps), false, true);
require(!unsafe.raw && unsafe.quantized, "unsafe intermediate raw amplitude passed through");
play(device, unsafe);
constexpr uint8_t reserved[8] = {0x01, 0x00, 0x00, 0x40, 0x00, 0x01, 0x40, 0x40};
require(!encoder.encode(host.decode(reserved), false, true).raw,
"reserved discriminator was passed through as a qualified native form");
}
} // namespace
int main() {
test_public_absolute_goldens();
test_provenance_and_profile_gains();
test_compressed_goldens_and_resynchronization();
test_absolute_plus_commands_and_selected_coordinate();
test_relative_only_amplitude_survives_prefixes();
test_mono_bands_ties_and_temporal_policy();
test_unrepresentable_timeline_and_safety();
return 0;
}

View file

@ -0,0 +1,31 @@
#pragma once
#include <stdint.h>
enum btstack_data_source_callback_type_t {
DATA_SOURCE_CALLBACK_POLL = 4,
};
struct btstack_data_source_t {
void (*handler)(btstack_data_source_t*, btstack_data_source_callback_type_t) = nullptr;
uint16_t callbacks = 0;
};
struct btstack_timer_source_t {
void (*handler)(btstack_timer_source_t*) = nullptr;
uint64_t due_us = 0;
};
void btstack_run_loop_set_data_source_handler(
btstack_data_source_t* source,
void (*handler)(btstack_data_source_t*, btstack_data_source_callback_type_t));
void btstack_run_loop_enable_data_source_callbacks(btstack_data_source_t* source,
uint16_t callbacks);
void btstack_run_loop_add_data_source(btstack_data_source_t* source);
void btstack_run_loop_poll_data_sources_from_irq();
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);
bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer);
uint8_t l2cap_request_can_send_now_event(uint16_t cid);

View file

@ -0,0 +1,17 @@
#pragma once
#include <uni.h>
#ifdef __cplusplus
extern "C" {
#endif
bool uni_hid_parser_switch_native_info(uni_hid_device_t* device, uint8_t* type,
uint8_t* firmware_hi, uint8_t* firmware_lo);
bool uni_hid_parser_switch_native_acquire(uni_hid_device_t* device);
bool uni_hid_parser_switch_native_send(uni_hid_device_t* device,
const uint8_t rumble[8]);
void uni_hid_parser_switch_native_release(uni_hid_device_t* device);
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,27 @@
#pragma once
#include <stdint.h>
struct uni_hid_device_s;
using uni_hid_device_t = uni_hid_device_s;
using uni_play_dual_rumble_t = void (*)(uni_hid_device_t*, uint16_t, uint16_t,
uint8_t, uint8_t);
struct uni_report_parser_t {
uni_play_dual_rumble_t play_dual_rumble = nullptr;
};
// Only the parser boundary is faked. Native sends either enter the byte sink
// synchronously or fail without retaining a packet; no hidden transmit queue.
struct uni_hid_device_s {
uni_report_parser_t report_parser{};
struct { uint16_t interrupt_cid = 0; } conn;
bool connected = true;
bool info_ready = true;
bool acquire_allowed = true;
bool native_owned = false;
uint8_t controller_type = 3;
unsigned acquisitions = 0;
unsigned releases = 0;
};

View file

@ -0,0 +1,712 @@
#include "input/switch_native_output.h"
#include <btstack.h>
#include <parser/uni_hid_parser_switch.h>
#include <uni.h>
#include <algorithm>
#include <array>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <initializer_list>
#include <iostream>
#include <map>
#include <vector>
namespace {
// These are rumble payloads at the parser's native-send boundary, not simulated
// HCI packets or evidence of physical playback. Only accepted sends advance the
// independent decoder representing the controller's received command history.
constexpr uint8_t kNeutral[8] = {0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kSeed[8] = {0x00, 0x21, 0x40, 0x48, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kThree[8] = {0x78, 0x77, 0x1c, 0xe9, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kDifferent[8] = {0xa8, 0x89, 0xe3, 0x4d, 0x80, 0x00, 0x40, 0x52};
constexpr SwitchHapticsSample kSteps[3] = {
{65, 65, 2139, 2282}, {65, 66, 2139, 2093}, {65, 66, 2093, 2093},
};
constexpr uint32_t kGeneration = 7;
const char* scenario = "startup";
uint64_t now_us = 1000000;
bool poll_requested = false;
bool writable = true;
std::deque<bool> send_results;
std::vector<btstack_data_source_t*> sources;
std::vector<btstack_timer_source_t*> timers;
std::map<uint16_t, uni_hid_device_t*> radio_devices;
std::deque<uint16_t> permission_requests;
uint16_t next_cid = 0x40;
bool credit_event_only = false, in_credit_event = false;
struct WireFrame {
uni_hid_device_t* device;
uint64_t submitted_us;
std::array<uint8_t, 8> bytes;
ControllerRumbleOutput decoded;
};
struct CompatibilityCall {
uni_hid_device_t* device;
uint16_t delay_ms;
uint16_t duration_ms;
uint8_t weak;
uint8_t strong;
size_t wire_position;
uint64_t submitted_us;
};
std::vector<WireFrame> wire;
std::vector<CompatibilityCall> compatibility;
std::map<uni_hid_device_t*, SwitchHapticsDecoder> physical;
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << scenario << ": " << message << '\n';
std::exit(1);
}
}
// Run the actual registered data-source and timer callbacks. The IRQ wake is
// deliberately deferred: submitting on the producer never calls the owner.
void run_until(uint64_t target_us) {
require(target_us >= now_us, "fake clock moved backwards");
for (unsigned dispatches = 0; dispatches < 10000; ++dispatches) {
if (writable && !credit_event_only && !permission_requests.empty()) {
const auto cid = permission_requests.front();
permission_requests.pop_front();
switch_native_output_on_can_send_now(radio_devices[cid], cid);
continue;
}
if (poll_requested) {
poll_requested = false;
const auto ready = sources;
for (auto* source : ready) {
if (source->callbacks & DATA_SOURCE_CALLBACK_POLL) {
require(source->handler != nullptr, "data source lacks handler");
source->handler(source, DATA_SOURCE_CALLBACK_POLL);
}
}
continue;
}
const auto next = std::min_element(timers.begin(), timers.end(),
[](const auto* a, const auto* b) { return a->due_us < b->due_us; });
if (next == timers.end() || (*next)->due_us > target_us) {
now_us = target_us;
return;
}
auto* timer = *next;
timers.erase(next);
require(timer->due_us >= now_us && timer->handler != nullptr,
"invalid timer deadline or callback");
now_us = timer->due_us;
timer->handler(timer);
}
require(false, "runloop did not quiesce within bounded dispatches");
}
void flush() { run_until(now_us); }
void advance_ms(uint32_t milliseconds) { run_until(now_us + uint64_t{milliseconds} * 1000); }
void conventional(uni_hid_device_t* device, uint16_t delay_ms,
uint16_t duration_ms, uint8_t weak, uint8_t strong) {
require(device->connected && !device->native_owned,
"compatibility output ran before native ownership was released");
compatibility.push_back({device, delay_ms, duration_ms, weak, strong, wire.size(), now_us});
}
uni_hid_device_t device() {
uni_hid_device_t result{};
result.report_parser.play_dual_rumble = conventional;
return result;
}
ControllerIdentity identity(uint8_t address = 1, uint16_t product = 0x2009) {
ControllerIdentity result{};
result.stable = true;
result.transport = ControllerTransport::kClassic;
result.address[0] = 0x24;
result.address[1] = 0x68;
result.address[5] = address;
result.vendor_id = 0x057e;
result.product_id = product;
return result;
}
AdapterConfiguration persisted(std::initializer_list<ControllerIdentity> identities) {
auto configuration = adapter_configuration_default();
for (const auto& approved : identities) {
configuration.native_switch_controllers[configuration.native_switch_controller_count++] = approved;
}
std::array<uint8_t, ADAPTER_CONFIGURATION_ENCODED_SIZE> bytes{};
require(adapter_configuration_encode(configuration, bytes.data(), bytes.size()),
"could not persist approval fixture through real configuration codec");
AdapterConfiguration restored{};
require(adapter_configuration_decode(ADAPTER_CONFIGURATION_SCHEMA_VERSION,
bytes.data(), bytes.size(), &restored),
"could not restore persisted approval fixture");
return restored;
}
void attach_approved(uni_hid_device_t& target) {
switch_native_output_configure(persisted({identity()}), 1);
switch_native_output_attach(0, kGeneration, &target, identity());
require(switch_native_output_owns(&target), "persisted physical approval did not acquire owner");
}
SwitchNativeOutputDiagnostics diagnostics(uint8_t slot = 0) {
SwitchNativeOutputDiagnostics result{};
switch_native_output_snapshot(slot, &result);
return result;
}
void submit(const ControllerRumbleOutput& rumble, bool stateful = false,
uint8_t slot = 0, uint32_t generation = kGeneration) {
require(switch_native_output_submit(slot, generation, now_us, rumble, stateful),
"current approved host command was rejected");
}
ControllerRumbleOutput three_steps() {
SwitchHapticsDecoder host;
host.decode(kSeed);
return host.decode(kThree);
}
const WireFrame& last_frame(uni_hid_device_t& target) {
const auto found = std::find_if(wire.rbegin(), wire.rend(),
[&](const auto& frame) { return frame.device == &target; });
require(found != wire.rend(), "controller has no accepted output");
return *found;
}
size_t frame_count(uni_hid_device_t& target) {
return std::count_if(wire.begin(), wire.end(),
[&](const auto& frame) { return frame.device == &target; });
}
bool is_neutral(const WireFrame& frame) {
return std::memcmp(frame.bytes.data(), kNeutral, sizeof(kNeutral)) == 0;
}
void expect_bytes(const WireFrame& frame, const uint8_t expected[8], const char* message) {
require(std::memcmp(frame.bytes.data(), expected, 8) == 0, message);
}
void expect_sample(const SwitchHapticsSample& actual, const SwitchHapticsSample& expected) {
require(actual.low_frequency_index == expected.low_frequency_index &&
actual.high_frequency_index == expected.high_frequency_index &&
actual.low_amplitude_q15 == expected.low_amplitude_q15 &&
actual.high_amplitude_q15 == expected.high_amplitude_q15,
"accepted output has wrong actuator band amplitude or frequency");
}
void expect_state(uni_hid_device_t& target, SwitchHapticsSample left,
SwitchHapticsSample right = {}) {
const auto& frame = last_frame(target).decoded.hd;
require(frame.actuators[0].sample_count > 0 && frame.actuators[1].sample_count > 0,
"accepted payload has no decoded endpoint");
expect_sample(frame.actuators[0].samples[frame.actuators[0].sample_count - 1], left);
expect_sample(frame.actuators[1].samples[frame.actuators[1].sample_count - 1], right);
}
void expect_three_steps(uni_hid_device_t& target) {
const auto& left = last_frame(target).decoded.hd.actuators[0];
require(left.sample_count == 3, "bounded schedule collapsed ordered host substeps");
for (uint8_t i = 0; i < 3; ++i) expect_sample(left.samples[i], kSteps[i]);
expect_state(target, kSteps[2]);
}
void expect_silence_since(size_t first) {
for (size_t i = first; i < wire.size(); ++i) {
require(is_neutral(wire[i]), "stopped or disconnected host vibration reappeared");
}
}
void test_approval() {
auto target = device();
switch_native_output_configure(persisted({}), 1);
switch_native_output_attach(0, kGeneration, &target, identity());
require(!switch_native_output_owns(&target) && target.acquisitions == 0,
"Nintendo VID/PID automatically enabled native output");
require(!switch_native_output_submit(0, kGeneration, now_us, {255, 255}, true) &&
!switch_native_output_feedback(&target, 255, 255, 10),
"unapproved native host or feedback output was accepted");
flush();
require(wire.empty() && compatibility.empty(), "unapproved attach disturbed conventional output");
switch_native_output_configure(persisted({identity(2)}), 2);
require(!switch_native_output_owns(&target) && wire.empty(),
"approval leaked to another physical address of the same model");
switch_native_output_configure(persisted({identity()}), 3);
require(switch_native_output_owns(&target) && target.acquisitions == 1,
"persisted matching identity did not acquire native output");
require(is_neutral(last_frame(target)), "approval handoff did not establish physical neutral");
SwitchHapticsDecoder host;
const size_t before = wire.size();
submit(host.decode(kSeed));
require(wire.size() == before, "producer submission bypassed deferred runloop wake");
flush();
expect_bytes(last_frame(target), kSeed, "approved unity output changed a safe native payload");
}
void test_model_gate() {
auto mismatch = device();
mismatch.controller_type = 1;
switch_native_output_configure(persisted({identity()}), 1);
switch_native_output_attach(0, kGeneration, &mismatch, identity());
require(!switch_native_output_owns(&mismatch) && mismatch.acquisitions == 0 && wire.empty(),
"approved identity overrode mismatched parser controller type");
auto unavailable = device();
unavailable.info_ready = false;
switch_native_output_attach(0, kGeneration, &unavailable, identity());
require(!switch_native_output_owns(&unavailable) && wire.empty(),
"missing parser device-info enabled native output");
auto unstable = device();
auto transient = identity();
transient.stable = false;
switch_native_output_attach(0, kGeneration, &unstable, transient);
require(!switch_native_output_owns(&unstable) && wire.empty(),
"unstable identity inherited a persisted approval");
switch_native_output_detach(&unstable);
auto refused = device();
refused.acquire_allowed = false;
switch_native_output_attach(0, kGeneration, &refused, identity());
require(!switch_native_output_owns(&refused) &&
!switch_native_output_submit(0, kGeneration, now_us, {255, 0}, true) && wire.empty(),
"failed parser acquisition swallowed compatibility host commands");
}
void test_revocation(bool expired) {
auto target = device();
attach_approved(target);
SwitchHapticsDecoder host;
submit(host.decode(kSeed));
flush();
advance_ms(5);
const auto latest = host.decode(kDifferent);
const uint64_t receipt = now_us;
submit(latest); // Revoke before the queued replacement reaches the owner.
const size_t before = wire.size();
writable = false;
switch_native_output_configure(persisted({}), 2);
require(switch_native_output_owns(&target) && target.releases == 0 && compatibility.empty(),
"revocation released owner before congestion allowed neutral");
require(!switch_native_output_submit(0, kGeneration, now_us, {255, 255}, true),
"revoked identity continued accepting native host updates");
advance_ms(expired ? 55 : 10);
require(wire.size() == before && compatibility.empty(),
"blocked neutral leaked output or resumed compatibility early");
writable = true;
advance_ms(1);
require(!switch_native_output_owns(&target) && target.releases == 1 && compatibility.size() == 1,
"neutral completion did not release and resume conventional output");
const auto& resumed = compatibility.back();
require(resumed.device == &target && resumed.delay_ms == 0 &&
resumed.wire_position > before && is_neutral(wire[resumed.wire_position - 1]),
"compatibility did not follow the accepted neutral barrier");
if (expired) {
require(resumed.duration_ms == 0 && resumed.weak == 0 && resumed.strong == 0,
"revocation resurrected an expired host effect");
} else {
const uint16_t remaining = static_cast<uint16_t>((receipt + 50000 - resumed.submitted_us + 999) / 1000);
require(resumed.duration_ms == remaining &&
resumed.weak == latest.high_frequency_magnitude &&
resumed.strong == latest.low_frequency_magnitude,
"compatibility resumed stale magnitudes or restarted the 50ms host lifetime");
}
const size_t released = wire.size();
advance_ms(100);
require(wire.size() == released && compatibility.size() == 1,
"retired native timer wrote after compatibility resumed");
}
void test_generation() {
auto old = device();
auto other = device();
auto replacement = device();
switch_native_output_configure(persisted({identity(), identity(2)}), 1);
switch_native_output_attach(0, kGeneration, &old, identity());
switch_native_output_attach(1, 21, &other, identity(2));
submit({255, 0}, true);
flush();
submit(three_steps());
old.connected = false;
const size_t old_count = frame_count(old);
switch_native_output_detach(&old);
switch_native_output_attach(0, kGeneration + 1, &replacement, identity());
const size_t replacement_count = frame_count(replacement);
require(is_neutral(last_frame(replacement)), "reconnect inherited a previous physical baseline");
require(!switch_native_output_submit(0, kGeneration, now_us, {255, 255}, true),
"old generation was accepted after same-address reconnect");
submit({0, 255}, true, 1, 21);
advance_ms(60); // Includes the outstanding producer wake and old refresh/expiry deadlines.
require(frame_count(old) == old_count && frame_count(replacement) == replacement_count,
"old queued command or timer touched disconnected/replacement device");
expect_state(other, {}, {64, 64, 0, 17867});
submit({255, 0}, true, 0, kGeneration + 1);
flush();
expect_state(replacement, {64, 64, 17867, 0});
expect_state(other, {}, {64, 64, 0, 17867});
require(diagnostics(0).completed_commands == 1 && diagnostics(1).completed_commands == 1,
"slot or generation completion accounting crossed controllers");
}
void test_overflow() {
auto target = device();
attach_approved(target);
const auto before_diagnostics = diagnostics();
const size_t before = wire.size();
SwitchHapticsDecoder host;
for (unsigned i = 0; i < 39; ++i)
submit(host.decode((i & 1u) ? kDifferent : kSeed));
submit(host.decode(kThree));
require(wire.size() == before && diagnostics().queue_depth <= 16,
"producer bypassed bounded deferred command queue");
flush();
require(wire.size() > before && wire.size() <= before + 3 && is_neutral(wire[before]),
"queue loss replayed a backlog instead of neutral plus bounded newest schedule");
expect_three_steps(target);
const auto after = diagnostics();
require(after.received_commands == 40 && after.dropped_commands == 39 &&
after.completed_commands == 1 && after.queue_depth == 0 &&
after.resynchronizations > before_diagnostics.resynchronizations,
"queue pressure did not distinguish discarded commands from the completed newest state");
const size_t drained = wire.size();
advance_ms(12);
require(wire.size() == drained, "owner caught up obsolete queued vibrations after draining");
}
void test_retry() {
auto target = device();
attach_approved(target);
const size_t before = wire.size();
const auto before_diagnostics = diagnostics();
send_results = {false};
submit(three_steps());
flush();
require(wire.size() == before && diagnostics().completed_commands == 0,
"failed first schedule packet counted as physical output or completion");
send_results = {true, false};
advance_ms(1);
require(wire.size() == before + 1 && diagnostics().completed_commands == 0,
"baseline-only partial submission counted as complete host command");
advance_ms(1);
expect_three_steps(target);
const auto after = diagnostics();
require(wire.size() == before + 2 && after.completed_commands == 1 &&
after.dropped_commands == 0 && after.congested_attempts == 2 &&
after.resynchronizations == before_diagnostics.resynchronizations,
"short congestion retry duplicated baseline, dropped history, or miscounted completion");
}
void test_partial_replacement() {
auto target = device();
attach_approved(target);
send_results = {true, false};
submit(three_steps());
flush();
require(diagnostics().completed_commands == 0, "partial schedule was already complete");
const size_t before = wire.size();
SwitchHapticsDecoder latest_host;
submit(latest_host.decode(kDifferent));
flush();
require(wire.size() == before + 2 && is_neutral(wire[before]),
"replacing prepared/partially sent schedule omitted the physical reset barrier");
expect_bytes(last_frame(target), kDifferent, "old prepared tail replaced newest host output");
const auto after = diagnostics();
require(after.completed_commands == 1 && after.dropped_commands == 1,
"discarded partial command was reported as completed or vanished from loss accounting");
const size_t replaced = wire.size();
advance_ms(12);
require(wire.size() == replaced, "discarded prepared tail was retried after replacement");
}
void test_stalled_schedule(bool partial) {
auto target = device();
attach_approved(target);
writable = partial;
if (partial) send_results = {true, false};
const size_t before = wire.size();
submit(three_steps());
flush();
require(wire.size() == before + (partial ? 1 : 0), "incorrect initial blocked schedule setup");
writable = false;
advance_ms(20);
require(diagnostics().completed_commands == 0, "unsubmitted stalled schedule was counted complete");
const size_t stalled = wire.size();
writable = true;
advance_ms(1);
require(wire.size() > stalled, "current endpoint was not submitted after congestion");
if (partial) require(is_neutral(wire[stalled]),
"partially submitted expired timeline lacked physical resynchronization");
for (size_t i = stalled; i < wire.size(); ++i) {
if (is_neutral(wire[i])) continue;
const auto& left = wire[i].decoded.hd.actuators[0];
require(left.sample_count == 1, "congestion replayed host substeps whose time had passed");
expect_sample(left.samples[0], kSteps[2]);
}
expect_state(target, kSteps[2]);
require(diagnostics().completed_commands == 1,
"resynchronized latest endpoint did not finish its current command");
}
void test_feedback_resume() {
auto target = device();
attach_approved(target);
SwitchHapticsDecoder host;
submit(host.decode(kSeed));
flush();
const uint64_t receipt = now_us;
require(switch_native_output_feedback(&target, 0, 255, 6), "approved local feedback was rejected");
submit(host.decode(kThree));
flush();
expect_state(target, {}, {64, 64, 0, 17867});
advance_ms(5);
expect_state(target, {}, {64, 64, 0, 17867});
const size_t before_resume = wire.size();
advance_ms(1);
require(wire.size() > before_resume && is_neutral(wire[before_resume]),
"local-feedback handoff omitted host resynchronization");
require(last_frame(target).decoded.hd.actuators[0].sample_count == 1,
"feedback resumed host timeline from its beginning instead of current substep");
expect_state(target, kSteps[2]);
run_until(receipt + 49000);
expect_state(target, kSteps[2]);
run_until(receipt + 50000);
require(is_neutral(last_frame(target)), "Switch host effect survived its original 50ms deadline");
const size_t expired = wire.size();
advance_ms(100);
expect_silence_since(expired);
}
void test_feedback_outlives_host() {
auto target = device();
attach_approved(target);
require(switch_native_output_feedback(&target, 0, 255, 60), "local feedback was rejected");
const uint64_t receipt = now_us;
submit(three_steps());
flush();
run_until(receipt + 49000);
expect_state(target, {}, {64, 64, 0, 17867});
const size_t before_expiry = wire.size();
run_until(receipt + 50000);
require(wire.size() == before_expiry,
"expiry of suppressed host command interrupted active local feedback");
run_until(receipt + 59000);
expect_state(target, {}, {64, 64, 0, 17867});
run_until(receipt + 60000);
require(is_neutral(last_frame(target)), "feedback completion resurrected already expired host output");
const size_t finished = wire.size();
advance_ms(100);
expect_silence_since(finished);
}
void test_feedback_congestion() {
auto target = device();
attach_approved(target);
SwitchHapticsDecoder host;
submit(host.decode(kSeed));
flush();
send_results = {true, false}; // Neutral reaches the sink; prepared feedback does not.
require(switch_native_output_feedback(&target, 0, 255, 30), "local feedback was rejected");
require(is_neutral(last_frame(target)), "feedback congestion fixture did not accept its neutral barrier");
submit(host.decode(kThree));
flush();
advance_ms(1);
expect_state(target, {}, {64, 64, 0, 17867});
advance_ms(28);
expect_state(target, {}, {64, 64, 0, 17867});
advance_ms(1);
expect_state(target, kSteps[2]);
require(last_frame(target).decoded.hd.actuators[0].sample_count == 1,
"congested feedback restarted suppressed host substeps on resume");
}
void test_stateful() {
auto target = device();
attach_approved(target);
submit({255, 0}, true);
flush();
expect_state(target, {64, 64, 17867, 0});
advance_ms(120);
expect_state(target, {64, 64, 17867, 0});
require(diagnostics().completed_commands == 1,
"stateful refreshes were counted as additional host commands");
require(switch_native_output_feedback(&target, 0, 255, 5), "stateful overlay feedback was rejected");
expect_state(target, {}, {64, 64, 0, 17867});
advance_ms(5);
expect_state(target, {64, 64, 17867, 0});
advance_ms(200);
expect_state(target, {64, 64, 17867, 0});
const size_t before_zero = wire.size();
submit({}, true);
flush();
require(wire.size() > before_zero && is_neutral(wire[before_zero]),
"explicit XInput zero did not immediately stop physical output");
expect_silence_since(before_zero);
const size_t stopped = wire.size();
advance_ms(200);
require(wire.size() == stopped && diagnostics().completed_commands == 2,
"stateful zero kept refreshing or changed host completion accounting");
}
void test_credit_driven_delivery() {
auto target = device();
attach_approved(target);
credit_event_only = true;
SwitchHapticsDecoder host;
const auto before = wire.size();
submit(host.decode(kSeed));
flush();
advance_ms(5);
require(wire.size() == before, "output attempted without an available credit window");
require(!permission_requests.empty(), "native owner did not request credit notification");
const auto cid = permission_requests.front();
permission_requests.pop_front();
in_credit_event = true;
require(switch_native_output_on_can_send_now(&target, cid), "credit event was ignored");
in_credit_event = false;
expect_bytes(last_frame(target), kSeed, "credit window did not submit current native state");
}
void test_held_state_coalescing() {
auto target = device();
attach_approved(target);
SwitchHapticsDecoder host;
submit(host.decode(kSeed));
flush();
const auto started = wire.size();
for (unsigned i = 0; i < 10; ++i) {
advance_ms(8);
submit(host.decode(kSeed));
flush();
}
require(diagnostics().received_commands == 11 &&
diagnostics().completed_commands == 1 &&
diagnostics().coalesced_commands == 10 &&
diagnostics().dropped_commands == 0,
"held state was lost or incorrectly counted as new radio submissions");
require(wire.size() <= started + 2, "identical reports caused redundant radio traffic");
for (size_t i = started; i < wire.size(); ++i)
expect_bytes(wire[i], kSeed, "refresh changed a held native effect");
advance_ms(49);
require(!is_neutral(last_frame(target)), "coalescing failed to extend the host watchdog");
advance_ms(2);
require(is_neutral(last_frame(target)), "held-state watchdog did not expire");
}
void test_pending_hold_preserves_initial_latency() {
auto target = device();
attach_approved(target);
writable = false;
SwitchHapticsDecoder host;
const uint64_t first = now_us;
for (unsigned i = 0; i < 3; ++i) {
submit(host.decode(kSeed));
flush();
advance_ms(8);
}
writable = true;
advance_ms(1);
expect_bytes(last_frame(target), kSeed, "pending coalescence changed the current effect");
require(diagnostics().completed_commands == 1 &&
diagnostics().coalesced_commands == 2 &&
diagnostics().dropped_commands == 0 &&
diagnostics().max_latency_us == last_frame(target).submitted_us - first,
"coalescing hid the initial wait or counted redundant commands as loss");
}
} // namespace
uint64_t time_us_64() { return now_us; }
uint32_t time_us_32() { return static_cast<uint32_t>(now_us); }
void btstack_run_loop_set_data_source_handler(
btstack_data_source_t* source,
void (*handler)(btstack_data_source_t*, btstack_data_source_callback_type_t)) {
source->handler = handler;
}
void btstack_run_loop_enable_data_source_callbacks(btstack_data_source_t* source,
uint16_t callbacks) {
source->callbacks |= callbacks;
}
void btstack_run_loop_add_data_source(btstack_data_source_t* source) {
require(std::find(sources.begin(), sources.end(), source) == sources.end(),
"data source was registered twice");
sources.push_back(source);
}
void btstack_run_loop_poll_data_sources_from_irq() { poll_requested = true; }
void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer,
void (*handler)(btstack_timer_source_t*)) {
timer->handler = handler;
}
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms) {
// Pico runloop adds a tick; deadlines use its millisecond clock, not a busy
// callback loop at the current instant when the owner requests timeout zero.
timer->due_us = (now_us / 1000 + uint64_t{timeout_ms} + 1) * 1000;
}
void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
require(std::find(timers.begin(), timers.end(), timer) == timers.end(),
"timer added while already scheduled");
timers.push_back(timer);
}
bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer) {
const auto found = std::find(timers.begin(), timers.end(), timer);
if (found == timers.end()) return false;
timers.erase(found);
return true;
}
uint8_t l2cap_request_can_send_now_event(uint16_t cid) {
if (writable && !credit_event_only)
switch_native_output_on_can_send_now(radio_devices[cid], cid);
else if (std::find(permission_requests.begin(), permission_requests.end(), cid) ==
permission_requests.end())
permission_requests.push_back(cid);
return 0;
}
bool uni_hid_parser_switch_native_info(uni_hid_device_t* target, uint8_t* type,
uint8_t* firmware_hi, uint8_t* firmware_lo) {
if (!target || !target->info_ready) return false;
if (!target->conn.interrupt_cid) target->conn.interrupt_cid = next_cid++;
radio_devices[target->conn.interrupt_cid] = target;
if (type) *type = target->controller_type;
if (firmware_hi) *firmware_hi = 5;
if (firmware_lo) *firmware_lo = 1;
return true;
}
bool uni_hid_parser_switch_native_acquire(uni_hid_device_t* target) {
if (!target->connected || !target->info_ready || !target->acquire_allowed) return false;
require(!target->native_owned, "parser acquired twice without release");
target->native_owned = true;
++target->acquisitions;
return true;
}
bool uni_hid_parser_switch_native_send(uni_hid_device_t* target, const uint8_t rumble[8]) {
require(target && target->connected && target->native_owned,
"native send reached disconnected or unowned parser");
require(!credit_event_only || in_credit_event,
"native sender polled outside the notified credit window");
bool accepted = writable;
if (!send_results.empty()) {
accepted = send_results.front();
send_results.pop_front();
}
if (!accepted) return false;
WireFrame frame{target, now_us, {}, physical[target].decode(rumble)};
std::memcpy(frame.bytes.data(), rumble, frame.bytes.size());
wire.push_back(frame);
return true;
}
void uni_hid_parser_switch_native_release(uni_hid_device_t* target) {
require(target && target->connected && target->native_owned,
"parser released while disconnected or already unowned");
target->native_owned = false;
++target->releases;
}
int main(int argc, char** argv) {
require(argc == 2, "one regression scenario is required");
scenario = argv[1];
switch_native_output_prepare();
if (std::strcmp(scenario, "approval") == 0) test_approval();
else if (std::strcmp(scenario, "model-gate") == 0) test_model_gate();
else if (std::strcmp(scenario, "revocation") == 0) test_revocation(false);
else if (std::strcmp(scenario, "revocation-expired") == 0) test_revocation(true);
else if (std::strcmp(scenario, "generation") == 0) test_generation();
else if (std::strcmp(scenario, "overflow") == 0) test_overflow();
else if (std::strcmp(scenario, "retry") == 0) test_retry();
else if (std::strcmp(scenario, "partial-replacement") == 0) test_partial_replacement();
else if (std::strcmp(scenario, "stalled") == 0) test_stalled_schedule(false);
else if (std::strcmp(scenario, "stalled-partial") == 0) test_stalled_schedule(true);
else if (std::strcmp(scenario, "feedback-resume") == 0) test_feedback_resume();
else if (std::strcmp(scenario, "feedback-outlives-host") == 0) test_feedback_outlives_host();
else if (std::strcmp(scenario, "feedback-congestion") == 0) test_feedback_congestion();
else if (std::strcmp(scenario, "stateful") == 0) test_stateful();
else if (std::strcmp(scenario, "credit-driven") == 0) test_credit_driven_delivery();
else if (std::strcmp(scenario, "held-state") == 0) test_held_state_coalescing();
else if (std::strcmp(scenario, "pending-hold") == 0) test_pending_hold_preserves_initial_latency();
else require(false, "unknown regression scenario");
return 0;
}

View file

@ -0,0 +1,40 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
typedef uint8_t bd_addr_t[6];
typedef uint16_t hci_con_handle_t;
typedef enum { GAP_CONNECTION_INVALID, GAP_CONNECTION_ACL, GAP_CONNECTION_SCO, GAP_CONNECTION_LE } gap_connection_type_t;
// Scheduling metadata lives in the test scheduler, not in parser memory.
// Keeping only the callback/context also avoids host pointer inflation of the
// firmware's fixed 256-byte parser allocation. Production ABI is built by CI.
typedef struct btstack_timer_source {
void (*process)(struct btstack_timer_source* timer);
void* context;
} btstack_timer_source_t;
#define ERROR_CODE_SUCCESS 0
#define ERROR_CODE_COMMAND_DISALLOWED 0x0c
#define BTSTACK_ACL_BUFFERS_FULL 0x57
#define HID_MESSAGE_TYPE_DATA 0x0a
#define HID_REPORT_TYPE_OUTPUT 0x02
#define btstack_min(a, b) ((a) < (b) ? (a) : (b))
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t ms);
void btstack_run_loop_add_timer(btstack_timer_source_t* timer);
bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer);
void btstack_run_loop_set_timer_context(btstack_timer_source_t* timer, void* context);
void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*));
void* btstack_run_loop_get_timer_context(btstack_timer_source_t* timer);
int l2cap_send(uint16_t cid, uint8_t* data, uint16_t len);
int l2cap_can_send_packet_now(uint16_t cid);
uint8_t l2cap_request_can_send_now_event(uint16_t cid);
gap_connection_type_t gap_get_connection_type(hci_con_handle_t handle);
void printf_hexdump(const void* data, int len);
const char* bd_addr_to_str(const bd_addr_t addr);
static inline int bd_addr_cmp(const bd_addr_t a, const bd_addr_t b) { return memcmp(a, b, 6); }
static inline void bd_addr_copy(bd_addr_t dst, const bd_addr_t src) { memcpy(dst, src, 6); }

View file

@ -0,0 +1,378 @@
#include <assert.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "bt/uni_bt_service.h"
#include "parser/uni_hid_parser_switch.h"
#include "platform/uni_platform.h"
#include "uni_hid_device.h"
// Link the actual parser, generic send queue, connection and circular buffer.
// Only radio, platform notifications and run-loop scheduling are substituted.
static const uint8_t neutral[8] = {0, 1, 0x40, 0x40, 0, 1, 0x40, 0x40};
static const uint8_t first_word[8] = {0, 0x81, 0x40, 0x60, 0, 1, 0x40, 0x40};
// Three compressed substeps, distinct from the absolute baseline above.
static const uint8_t compressed[8] = {0x18, 0x63, 0x8c, 0xf1, 0, 1, 0x40, 0x40};
static bool credit = true;
static bool fail_submission;
static unsigned sent_count;
static unsigned requests;
static struct { uint16_t cid, len; uint8_t bytes[128]; } sent[256];
static uint32_t now_ms;
static struct { btstack_timer_source_t* timer; uint32_t deadline; bool active; } timers[32];
static unsigned timer_index(btstack_timer_source_t* timer) {
for (unsigned i = 0; i < 32; ++i) {
if (timers[i].timer == timer) return i;
if (!timers[i].timer) { timers[i].timer = timer; return i; }
}
assert(!"timer capacity exceeded");
return 0;
}
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t ms) {
timers[timer_index(timer)].deadline = now_ms + ms;
}
void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
timers[timer_index(timer)].active = true;
}
bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer) {
for (unsigned i = 0; i < 32; ++i) {
if (timers[i].timer == timer) {
bool active = timers[i].active;
timers[i].active = false;
return active;
}
}
return false;
}
void btstack_run_loop_set_timer_context(btstack_timer_source_t* timer, void* context) { timer->context = context; }
void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)) { timer->process = handler; }
void* btstack_run_loop_get_timer_context(btstack_timer_source_t* timer) { return timer->context; }
static void advance(uint32_t ms) {
const uint32_t end = now_ms + ms;
for (unsigned callbacks = 0; callbacks < 256; ++callbacks) {
unsigned next = 32;
for (unsigned i = 0; i < 32; ++i)
if (timers[i].active && timers[i].deadline <= end &&
(next == 32 || timers[i].deadline < timers[next].deadline)) next = i;
if (next == 32) { now_ms = end; return; }
now_ms = timers[next].deadline;
timers[next].active = false;
timers[next].timer->process(timers[next].timer);
}
assert(!"unbounded timer callback loop");
}
int l2cap_can_send_packet_now(uint16_t cid) { (void)cid; return credit; }
int l2cap_send(uint16_t cid, uint8_t* data, uint16_t len) {
if (!credit || fail_submission) return BTSTACK_ACL_BUFFERS_FULL;
assert(sent_count < 256 && len <= 128);
sent[sent_count].cid = cid;
sent[sent_count].len = len;
memcpy(sent[sent_count++].bytes, data, len);
return ERROR_CODE_SUCCESS;
}
uint8_t l2cap_request_can_send_now_event(uint16_t cid) { (void)cid; ++requests; return 0; }
gap_connection_type_t gap_get_connection_type(hci_con_handle_t handle) { (void)handle; return GAP_CONNECTION_ACL; }
void printf_hexdump(const void* data, int len) { (void)data; (void)len; }
const char* bd_addr_to_str(const bd_addr_t addr) { (void)addr; return "native-test"; }
void uni_log(const char* fmt, ...) { (void)fmt; }
void uni_bt_bredr_disconnect(uni_hid_device_t* d) { (void)d; }
void uni_bt_le_disconnect(uni_hid_device_t* d) { (void)d; }
void uni_bt_service_on_device_ready(const uni_hid_device_t* d) { (void)d; }
void uni_bt_service_on_device_connected(const uni_hid_device_t* d) { (void)d; }
void uni_bt_service_on_device_disconnected(const uni_hid_device_t* d) { (void)d; }
uint8_t uni_hid_parser_hat_to_dpad(uint8_t hat) { (void)hat; return 0; }
static uni_error_t ready(uni_hid_device_t* d) { (void)d; return UNI_ERROR_SUCCESS; }
static void connected(uni_hid_device_t* d) { (void)d; }
static struct uni_platform platform = {
.on_device_ready = ready,
.on_device_connected = connected,
.on_device_disconnected = connected,
};
struct uni_platform* uni_get_platform(void) { return &platform; }
static void reset(void) {
memset(timers, 0, sizeof(timers));
sent_count = requests = now_ms = 0;
credit = true;
fail_submission = false;
}
static void reply(uni_hid_device_t* d, uint8_t cmd, uint8_t type, uint8_t ack, uint16_t len) {
uint8_t report[49] = {0x21};
report[13] = ack;
report[14] = cmd;
if (cmd == 2) {
report[15] = 5;
report[16] = 7;
report[17] = type;
}
// Calibration replies intentionally have zero length, leaving the parser's
// normal fallback calibration intact; these tests exercise only output.
uni_hid_parser_switch_parse_input_report(d, report, len);
}
static void begin_device(uni_hid_device_t* d, uint16_t cid) {
uni_hid_device_init(d);
d->conn.connected = true;
d->conn.interrupt_cid = cid;
d->conn.handle = cid;
d->report_parser.setup = uni_hid_parser_switch_setup;
uni_hid_parser_switch_setup(d);
}
static void finish_device(uni_hid_device_t* d, uint8_t type, uint8_t ack, uint16_t info_len) {
reply(d, 2, type, ack, info_len);
for (unsigned step = 0; step < 10 && d->conn.state != UNI_BT_CONN_STATE_DEVICE_READY; ++step) {
assert(sent_count && sent[sent_count - 1].len >= 12);
reply(d, sent[sent_count - 1].bytes[11], type, 0x80, 49);
}
assert(d->conn.state == UNI_BT_CONN_STATE_DEVICE_READY);
}
static void init_device(uni_hid_device_t* d, uint16_t cid) {
begin_device(d, cid);
finish_device(d, 3, 0x80, 18);
}
static void expect_rumble(unsigned index, uint16_t cid, const uint8_t word[8]) {
assert(index < sent_count && sent[index].cid == cid);
assert(sent[index].len == 11);
assert(sent[index].bytes[0] == 0xa2 && sent[index].bytes[1] == 0x10);
assert(memcmp(&sent[index].bytes[3], word, 8) == 0);
}
static void expect_led(unsigned index, uint8_t leds, const uint8_t word[8]) {
assert(index < sent_count && sent[index].len == 13);
assert(sent[index].bytes[0] == 0xa2 && sent[index].bytes[1] == 1);
assert(sent[index].bytes[11] == 0x30 && sent[index].bytes[12] == leds);
assert(memcmp(&sent[index].bytes[3], word, 8) == 0);
}
static void identity_and_per_device_counter(void) {
reset();
uni_hid_device_t a, b;
init_device(&a, 0x40);
unsigned b_first = sent_count;
init_device(&b, 0x41);
assert(sent[0].bytes[2] == 0 && sent[b_first].bytes[2] == 0);
uint8_t type = 0, hi = 0, lo = 0;
assert(uni_hid_parser_switch_native_info(&a, &type, &hi, &lo));
assert(type == 3 && hi == 5 && lo == 7);
assert(uni_hid_parser_switch_native_acquire(&a));
assert(uni_hid_parser_switch_native_acquire(&b));
for (unsigned i = 0; i < 20; ++i) {
unsigned index = sent_count;
assert(uni_hid_parser_switch_native_send(&a, first_word));
expect_rumble(index, 0x40, first_word);
if (i == 3) uni_hid_parser_switch_set_player_leds(&a, 4);
assert(uni_hid_parser_switch_native_send(&b, neutral));
}
unsigned led_index = sent_count;
uni_hid_parser_switch_set_player_leds(&a, 2);
uni_hid_parser_switch_set_player_leds(&b, 8);
expect_led(led_index, 2, first_word);
expect_led(led_index + 1, 8, neutral);
unsigned expected[2] = {0, 0};
for (unsigned i = 0; i < sent_count; ++i) {
unsigned device = sent[i].cid - 0x40;
assert(device < 2 && sent[i].bytes[2] == (expected[device]++ & 15));
}
uni_hid_device_disconnect(&a);
assert(!uni_hid_parser_switch_native_info(&a, NULL, NULL, NULL));
assert(!uni_hid_parser_switch_native_send(&a, neutral));
}
static void congestion_and_queued_leds(void) {
reset();
uni_hid_device_t d;
init_device(&d, 0x40);
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, first_word));
unsigned baseline = sent_count;
credit = false;
assert(!uni_hid_parser_switch_native_send(&d, compressed));
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
fail_submission = true;
assert(!uni_hid_parser_switch_native_send(&d, compressed));
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
fail_submission = false;
uni_hid_parser_switch_set_player_leds(&d, 1);
expect_led(baseline, 1, first_word);
assert(sent[baseline].bytes[2] == ((sent[baseline - 1].bytes[2] + 1) & 15));
credit = false;
uni_hid_parser_switch_set_player_leds(&d, 2);
uni_hid_parser_switch_set_player_leds(&d, 8);
assert(!uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
assert(uni_hid_parser_switch_native_send(&d, compressed));
baseline = sent_count;
uni_hid_device_send_queued_reports(&d);
uni_hid_device_send_queued_reports(&d);
expect_led(baseline, 8, compressed);
expect_led(baseline + 1, 8, compressed);
assert(sent[baseline].bytes[2] == ((sent[baseline - 1].bytes[2] + 1) & 15));
assert(sent[baseline + 1].bytes[2] == ((sent[baseline].bytes[2] + 1) & 15));
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
}
static void compatibility_and_ownership_timers(void) {
reset();
uni_hid_device_t d;
init_device(&d, 0x40);
unsigned baseline = sent_count;
uni_hid_parser_switch_play_dual_rumble(&d, 0, 125, 160, 200);
uint8_t conventional[8];
memcpy(conventional, &sent[baseline].bytes[3], 8);
advance(39);
assert(sent_count == baseline + 1);
advance(1);
expect_rumble(baseline + 1, 0x40, conventional);
advance(40);
expect_rumble(baseline + 2, 0x40, conventional);
// Acquire retires both refresh and duration; they cannot stop native audio.
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, first_word));
baseline = sent_count;
uni_hid_parser_switch_play_dual_rumble(&d, 0, 1, 255, 255);
uni_hid_parser_switch_play_dual_rumble(&d, 1, 1, 255, 255);
uni_hid_parser_switch_play_dual_rumble(&d, 0, 0, 0, 0);
uint8_t competing[11] = {0xa2, 0x10, 0};
memcpy(&competing[3], neutral, 8);
uni_hid_device_send_intr_report(&d, competing, sizeof(competing));
credit = false;
uni_hid_device_send_intr_report(&d, competing, sizeof(competing));
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
advance(500);
assert(sent_count == baseline);
uni_hid_parser_switch_native_release(&d);
expect_rumble(baseline, 0x40, neutral);
assert(!uni_hid_parser_switch_native_send(&d, first_word));
uni_hid_parser_switch_play_dual_rumble(&d, 100, 100, 33, 44);
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, compressed));
baseline = sent_count;
advance(300);
assert(sent_count == baseline);
uni_hid_parser_switch_native_release(&d);
uni_hid_parser_switch_play_dual_rumble(&d, 0, 20, 33, 44);
baseline = sent_count;
advance(20);
expect_rumble(baseline, 0x40, neutral);
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, first_word));
credit = false;
uni_hid_parser_switch_native_release(&d);
assert(!uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
baseline = sent_count;
uni_hid_device_send_queued_reports(&d);
expect_rumble(baseline, 0x40, neutral);
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, first_word));
credit = false;
uni_hid_parser_switch_native_release(&d);
// Reacquiring must retire the delayed neutral, not stop the new owner.
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
assert(uni_hid_parser_switch_native_send(&d, compressed));
assert(uni_hid_parser_switch_native_send(&d, neutral));
baseline = sent_count;
credit = false;
uni_hid_parser_switch_native_release(&d);
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
uni_hid_parser_switch_play_dual_rumble(&d, 0, 100, 33, 44);
assert(sent_count == baseline + 1);
}
static void queue_retirement_disconnect_and_reuse(void) {
reset();
uni_hid_device_t d;
init_device(&d, 0x40);
// Walk the ring near its end before interleaving stale rumble and LEDs.
for (unsigned i = 0; i < 30; ++i) {
credit = false;
uni_hid_parser_switch_set_player_leds(&d, 1);
credit = true;
uni_hid_device_send_queued_reports(&d);
}
credit = false;
uni_hid_parser_switch_play_dual_rumble(&d, 0, 200, 200, 200);
uni_hid_parser_switch_set_player_leds(&d, 2);
uni_hid_parser_switch_play_dual_rumble(&d, 0, 0, 0, 0);
uni_hid_parser_switch_set_player_leds(&d, 4);
assert(uni_hid_parser_switch_native_acquire(&d));
credit = true;
assert(uni_hid_parser_switch_native_send(&d, compressed));
unsigned baseline = sent_count;
uni_hid_device_send_queued_reports(&d);
uni_hid_device_send_queued_reports(&d);
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
expect_led(baseline, 4, compressed);
expect_led(baseline + 1, 4, compressed);
uni_hid_parser_switch_native_release(&d);
uni_hid_parser_switch_play_dual_rumble(&d, 100, 100, 200, 200);
btstack_timer_source_t stale[32];
unsigned stale_count = 0;
for (unsigned i = 0; i < 32; ++i)
if (timers[i].active) stale[stale_count++] = *timers[i].timer;
uni_hid_device_disconnect(&d);
uni_hid_device_delete(&d);
init_device(&d, 0x42);
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, first_word));
baseline = sent_count;
for (unsigned i = 0; i < stale_count; ++i) stale[i].process(&stale[i]);
advance(300);
assert(sent_count == baseline);
// The other timers also retire on delete even without a preceding disconnect.
uni_hid_parser_switch_native_release(&d);
uni_hid_parser_switch_play_dual_rumble(&d, 0, 100, 30, 40);
uni_hid_device_delete(&d);
baseline = sent_count;
advance(200);
assert(sent_count == baseline);
}
static void identity_requires_real_reply(void) {
reset();
uni_hid_device_t d;
begin_device(&d, 0x40);
assert(!uni_hid_parser_switch_native_info(&d, NULL, NULL, NULL));
finish_device(&d, 3, 0x80, 17); // Truncated firmware/type tuple.
assert(!uni_hid_parser_switch_native_acquire(&d));
uni_hid_device_delete(&d);
begin_device(&d, 0x40);
finish_device(&d, 3, 0, 18); // Negative acknowledgement is not evidence.
assert(!uni_hid_parser_switch_native_info(&d, NULL, NULL, NULL));
uni_hid_device_delete(&d);
begin_device(&d, 0x40);
finish_device(&d, 0x0b, 0x80, 18);
uint8_t type;
assert(uni_hid_parser_switch_native_info(&d, &type, NULL, NULL) && type == 0x0b);
assert(!uni_hid_parser_switch_native_acquire(&d));
uni_hid_device_delete(&d);
for (uint8_t original_type = 1; original_type <= 2; ++original_type) {
begin_device(&d, 0x40);
finish_device(&d, original_type, 0x80, 18);
assert(uni_hid_parser_switch_native_info(&d, &type, NULL, NULL));
assert(type == original_type && uni_hid_parser_switch_native_acquire(&d));
uni_hid_device_delete(&d);
}
assert(!uni_hid_parser_switch_native_acquire(NULL));
}
int main(void) {
identity_and_per_device_counter();
congestion_and_queued_leds();
compatibility_and_ownership_timers();
queue_retirement_disconnect_and_reuse();
identity_requires_real_reply();
puts("Switch parser native wire/queue/LED/ownership/timer checks passed");
return 0;
}

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import json
import struct
import zlib
from dataclasses import replace
from pathlib import Path
import pytest
@ -45,7 +46,11 @@ class FakeDevice:
self.configuration = struct.pack(
"<HB5x", 60, config_manager.REQUESTED_MODE_AUTO
)
self.configuration_schema = 2
self.configuration_generation = 3
self.native_rumble_diagnostics = b"".join(
struct.pack("<4B19I", slot, 0, 0, 0, *([0] * 19)) for slot in range(4)
)
self.active_mode = config_manager.ACTIVE_MODE_SWITCH_PROBE
self.capabilities = (
config_manager.CAPABILITY_INPUT
@ -306,11 +311,17 @@ class FakeDevice:
request,
struct.pack("<7I4B", 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1),
)
if request == config_manager.OP_NATIVE_SWITCH_RUMBLE:
return make_response(
request,
self.native_rumble_diagnostics,
schema=config_manager.NATIVE_SWITCH_RUMBLE_SCHEMA_VERSION,
)
if request == config_manager.OP_CONFIGURATION_READ:
return make_response(
request,
self.configuration,
schema=config_manager.CONFIGURATION_SCHEMA_VERSION,
schema=self.configuration_schema,
generation=self.configuration_generation,
)
if request == config_manager.OP_TRANSACTION_STATUS:
@ -327,13 +338,10 @@ class FakeDevice:
else config_manager.STATUS_OK
)
if self.transaction_status == config_manager.STATUS_OK:
pairing_window = struct.unpack_from(
"<H", self.configuration
)[0]
self.configuration = struct.pack(
"<HB5x",
pairing_window,
self.pending_requested_mode,
self.configuration = (
self.configuration[:2]
+ bytes((self.pending_requested_mode,))
+ self.configuration[3:]
)
self.configuration_generation += 1
self.pending_requested_mode = None
@ -419,7 +427,7 @@ class FakeDevice:
if request == config_manager.OP_CONFIGURATION_BEGIN:
(
self.transaction_id,
_schema,
self.configuration_schema,
self.transaction_expected_size,
self.transaction_expected_crc,
) = struct.unpack("<IHHI", payload)
@ -446,6 +454,8 @@ class FakeDevice:
self.configuration = struct.pack(
"<HB5x", 60, config_manager.REQUESTED_MODE_AUTO
)
if self.configuration_schema == config_manager.CONFIGURATION_SCHEMA_VERSION:
self.configuration += bytes(config_manager.CONFIGURATION_SIZE - 8)
self.configuration_generation += 1
self.transaction_payload = bytearray(self.configuration)
self.transaction_expected_size = len(self.configuration)
@ -685,6 +695,284 @@ def test_response_validation() -> None:
config_manager.parse_response(response, config_manager.OP_INFO)
def native_rumble_identity(
address: bytes = bytes.fromhex("102030405060"), product_id: int = 0x2009
) -> config_manager.ControllerIdentity:
return config_manager.ControllerIdentity(
True, config_manager.TRANSPORT_CLASSIC, 0, address, 0x057E, product_id
)
def native_rumble_configuration(
identities: tuple[config_manager.ControllerIdentity, ...] = (),
) -> bytes:
return (
struct.pack("<HBB4x", 90, config_manager.REQUESTED_MODE_XINPUT, len(identities))
+ b"".join(identity.to_bytes() for identity in identities)
+ bytes((16 - len(identities)) * 14)
)
@pytest.mark.parametrize(
("schema", "payload", "mode"),
(
(1, struct.pack("<H2x", 75), config_manager.REQUESTED_MODE_AUTO),
(2, struct.pack("<HB5x", 75, 3), config_manager.REQUESTED_MODE_DINPUT),
),
)
def test_legacy_configuration_has_no_native_rumble_approval(
schema: int, payload: bytes, mode: int
) -> None:
device = FakeDevice()
device.configuration_schema = schema
device.configuration = payload
configuration = config_manager.read_configuration(device)
assert configuration.pairing_window_seconds == 75
assert configuration.requested_mode == mode
assert configuration.native_switch_controllers == ()
with pytest.raises(config_manager.ConfigManagerError):
config_manager.set_native_switch_rumble_approval(
device, native_rumble_identity(), True, 1.0
)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.write_configuration(
device,
replace(
configuration, native_switch_controllers=(native_rumble_identity(),)
),
1.0,
)
assert not device.out_requests
def test_native_rumble_configuration_canonical_wire_round_trip() -> None:
device = FakeDevice()
identities = tuple(
native_rumble_identity(
bytes((index, 2, 3, 4, 5, 6)), (0x2009, 0x2006, 0x2007)[index % 3]
)
for index in range(16)
)
config_manager.write_configuration(
device,
config_manager.AdapterConfiguration(
90, 0, 0, config_manager.REQUESTED_MODE_XINPUT, tuple(reversed(identities))
),
1.0,
)
assert device.configuration_schema == 3
assert device.configuration == native_rumble_configuration(identities)
stored = config_manager.read_configuration(device)
assert stored.native_switch_controllers == identities
assert stored.crc == zlib.crc32(device.configuration) & 0xFFFFFFFF
assert stored.pairing_window_seconds == 90
assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT
@pytest.mark.parametrize(
("offset", "value"),
(
(3, 17), # Capacity overflow.
(4, 1), # Header reserved byte.
(8, 0), # Unstable non-global identity.
(9, config_manager.TRANSPORT_BLE),
(11, 1), # Identity reserved byte.
(18, 0), # Different vendor.
(20, 0), # Unqualified product.
(22, 1), # Unused identity slot.
),
)
def test_native_rumble_configuration_rejects_malformed_approvals(
offset: int, value: int
) -> None:
device = FakeDevice()
device.configuration_schema = 3
payload = bytearray(native_rumble_configuration((native_rumble_identity(),)))
payload[offset] = value
device.configuration = bytes(payload)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_configuration(device)
@pytest.mark.parametrize("malformation", ("duplicate", "unsorted", "global", "short"))
def test_native_rumble_configuration_rejects_invalid_lists(malformation: str) -> None:
device = FakeDevice()
device.configuration_schema = 3
first = native_rumble_identity()
second = native_rumble_identity(bytes.fromhex("A1A2A3A4A5A6"))
identities = {
"duplicate": (first, first),
"unsorted": (second, first),
"global": (config_manager.ControllerIdentity.global_fallback(),),
"short": (first,),
}[malformation]
device.configuration = native_rumble_configuration(identities)
if malformation == "short":
device.configuration = device.configuration[:-1]
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_configuration(device)
@pytest.mark.parametrize(
"malformation", ("duplicate", "overflow", "global", "ble", "vendor", "product")
)
def test_native_rumble_write_rejects_invalid_approvals_before_transaction(
malformation: str,
) -> None:
device = FakeDevice()
identity = native_rumble_identity()
identities = {
"duplicate": (identity, identity),
"overflow": tuple(
native_rumble_identity(bytes((index, 2, 3, 4, 5, 6))) for index in range(17)
),
"global": (config_manager.ControllerIdentity.global_fallback(),),
"ble": (replace(identity, transport=config_manager.TRANSPORT_BLE),),
"vendor": (replace(identity, vendor_id=0x045E),),
"product": (replace(identity, product_id=0x2019),),
}[malformation]
with pytest.raises(config_manager.ConfigManagerError):
config_manager.write_configuration(
device,
config_manager.AdapterConfiguration(
90, 0, 0, native_switch_controllers=identities
),
1.0,
)
assert not device.out_requests
def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
device = FakeDevice()
device.configuration_schema = 3
device.configuration = native_rumble_configuration()
first = native_rumble_identity()
second = native_rumble_identity(bytes.fromhex("A1A2A3A4A5A6"))
for identity in (first, second):
device.profile_identities.append(identity)
device.active_profiles[identity.to_bytes()] = 3
previous_profiles = dict(device.profiles)
previous_active = dict(device.active_profiles)
previous_pairings = list(device.records)
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
assert config_manager.main(["config", "native-rumble", "list"]) == 0
assert first.address_text in capsys.readouterr().out
assert config_manager.read_configuration(device).native_switch_controllers == ()
assert (
config_manager.main(["config", "native-rumble", "approve", "--identity", "2"])
== 2
)
assert not device.out_requests
capsys.readouterr()
assert (
config_manager.main(
["config", "native-rumble", "approve", "--identity", "2", "--yes"]
)
== 0
)
capsys.readouterr()
approved = config_manager.read_configuration(device)
assert approved.native_switch_controllers == (first,)
assert approved.pairing_window_seconds == 90
assert approved.requested_mode == config_manager.REQUESTED_MODE_XINPUT
assert device.profiles == previous_profiles
assert device.active_profiles == previous_active
assert device.records == previous_pairings
assert config_manager.main(["config", "native-rumble", "list"]) == 0
output = capsys.readouterr().out
assert f"Approved identity 2: {first.address_text}" in output
assert f"Approved identity 3: {second.address_text}" not in output
assert (
config_manager.main(["config", "set", "--pairing-window-seconds", "120"]) == 0
)
preserved = config_manager.read_configuration(device)
assert preserved.pairing_window_seconds == 120
assert preserved.requested_mode == config_manager.REQUESTED_MODE_XINPUT
assert preserved.native_switch_controllers == (first,)
config_manager.set_mode(device, config_manager.REQUESTED_MODE_DINPUT, 1.0)
preserved = config_manager.read_configuration(device)
assert preserved.requested_mode == config_manager.REQUESTED_MODE_DINPUT
assert preserved.native_switch_controllers == (first,)
assert (
config_manager.main(["config", "native-rumble", "revoke", "--identity", "2"])
== 0
)
revoked = config_manager.read_configuration(device)
assert revoked.native_switch_controllers == ()
assert revoked.requested_mode == config_manager.REQUESTED_MODE_DINPUT
assert revoked.pairing_window_seconds == 120
@pytest.mark.parametrize("identity_index", ("0", "1", "99"))
def test_native_rumble_cli_rejects_unqualified_or_missing_identity(
monkeypatch: pytest.MonkeyPatch, identity_index: str
) -> None:
device = FakeDevice()
device.configuration_schema = 3
device.configuration = native_rumble_configuration()
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
assert (
config_manager.main(
[
"config",
"native-rumble",
"approve",
"--identity",
identity_index,
"--yes",
]
)
== 1
)
assert not device.out_requests
def test_native_rumble_cli_can_revoke_forgotten_identity(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
first = native_rumble_identity()
second = native_rumble_identity(bytes.fromhex("A1A2A3A4A5A6"))
device.configuration_schema = 3
device.configuration = native_rumble_configuration((first, second))
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
assert (
config_manager.main(["config", "native-rumble", "revoke", "--approval", "2"])
== 1
)
assert not device.out_requests
assert (
config_manager.main(["config", "native-rumble", "revoke", "--approval", "0"])
== 0
)
assert config_manager.read_configuration(device).native_switch_controllers == (
second,
)
assert config_manager.OP_PROFILE_LIST not in device.requests
@pytest.mark.parametrize(("offset", "value"), ((0, 1), (4, 32)))
def test_native_rumble_diagnostics_rejects_malformed_rows(
offset: int, value: int
) -> None:
device = FakeDevice()
payload = bytearray(device.native_rumble_diagnostics)
payload[offset] = value
device.native_rumble_diagnostics = bytes(payload)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_native_switch_rumble(device)
def test_native_rumble_diagnostics_rejects_truncated_snapshot() -> None:
device = FakeDevice()
device.native_rumble_diagnostics = device.native_rumble_diagnostics[:-1]
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_native_switch_rumble(device)
def test_configuration_transaction_and_reset() -> None:
device = FakeDevice()
before = config_manager.read_configuration(device)
@ -704,14 +992,12 @@ def test_configuration_transaction_and_reset() -> None:
stored = config_manager.read_configuration(device)
assert stored.pairing_window_seconds == 90
assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT
assert device.configuration == struct.pack(
"<HB5x", 90, config_manager.REQUESTED_MODE_XINPUT
)
reset = config_manager.reset_configuration(device, 1.0)
assert reset.stored_generation == 5
reset_configuration = config_manager.read_configuration(device)
assert reset_configuration.pairing_window_seconds == 60
assert reset_configuration.requested_mode == config_manager.REQUESTED_MODE_AUTO
assert reset_configuration.native_switch_controllers == ()
def test_configuration_transaction_ids_stay_in_host_range(

View file

@ -20,17 +20,45 @@ def test_configuration_service_native(tmp_path: Path) -> None:
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "configuration_service_test.cpp"),
str(root / "src" / "firmware" / "configuration" / "adapter_configuration.cpp"),
str(root / "src" / "firmware" / "configuration" / "configuration_service.cpp"),
str(root / "src" / "firmware" / "configuration" / "configuration_storage.cpp"),
str(root / "src" / "firmware" / "configuration" / "configuration_transaction.cpp"),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "adapter_configuration.cpp"
),
str(root / "src" / "firmware" / "core" / "controller_identity.cpp"),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "configuration_service.cpp"
),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "configuration_storage.cpp"
),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "configuration_transaction.cpp"
),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)
subprocess.run(
[str(executable), "abandoned-receive"], check=True, cwd=root
)
for scenario in (
"lifecycle",
"v2-migration",
"native-approvals",
"abandoned-receive",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)

View file

@ -19,9 +19,28 @@ def test_configuration_storage_native(tmp_path: Path) -> None:
"-pedantic",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "configuration_storage_test.cpp"),
str(root / "src" / "firmware" / "configuration" / "adapter_configuration.cpp"),
str(root / "src" / "firmware" / "configuration" / "configuration_storage.cpp"),
str(root / "src" / "firmware" / "configuration" / "configuration_transaction.cpp"),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "adapter_configuration.cpp"
),
str(root / "src" / "firmware" / "core" / "controller_identity.cpp"),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "configuration_storage.cpp"
),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "configuration_transaction.cpp"
),
"-o",
str(executable),
],

View file

@ -29,3 +29,33 @@ def test_switch_haptics_native(tmp_path: Path) -> None:
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)
def test_switch_native_haptics_encoder(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
firmware = root / "src" / "firmware"
executable = tmp_path / "switch_native_haptics_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{firmware}",
str(firmware / "usb" / "switch" / "switch_haptics.cpp"),
str(firmware / "usb" / "switch" / "switch_native_haptics.cpp"),
str(firmware / "core" / "controller_identity.cpp"),
str(firmware / "profile" / "controller_profile.cpp"),
str(firmware / "profile" / "controller_profile_transform.cpp"),
str(root / "tests" / "switch_native_haptics_test.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -0,0 +1,58 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def test_switch_native_output_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
firmware = root / "src" / "firmware"
executable = tmp_path / "switch_native_output_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root / 'tests' / 'switch_native_output_native_stubs'}",
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
f"-I{firmware}",
str(firmware / "input" / "switch_native_output.cpp"),
str(firmware / "usb" / "switch" / "switch_native_haptics.cpp"),
str(firmware / "usb" / "switch" / "switch_haptics.cpp"),
str(firmware / "configuration" / "adapter_configuration.cpp"),
str(firmware / "core" / "controller_identity.cpp"),
str(root / "tests" / "switch_native_output_test.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
# A fresh process isolates firmware owner globals without exposing test-only
# production reset APIs. The fake runloop drives the real registered handlers.
for scenario in (
"approval",
"model-gate",
"revocation",
"revocation-expired",
"generation",
"overflow",
"retry",
"partial-replacement",
"stalled",
"stalled-partial",
"feedback-resume",
"feedback-outlives-host",
"feedback-congestion",
"stateful",
"credit-driven",
"held-state",
"pending-hold",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)

View file

@ -0,0 +1,51 @@
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
from prepare_bluepad32 import prepare_bluepad32
def test_switch_parser_native_wire_and_lifecycle(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("cc") or shutil.which("gcc")
assert compiler is not None, "a host C compiler is required"
prepared = prepare_bluepad32(
root / "external" / "bluepad32",
root / "patches" / "bluepad32-sdl3-imu.patch",
tmp_path / "bluepad32-src",
)
component = prepared / "src" / "components" / "bluepad32"
executable = tmp_path / "switch_parser_native_test"
subprocess.run(
[
compiler,
"-std=gnu11",
"-O1",
"-Wall",
"-Wextra",
"-ffunction-sections",
"-fdata-sections",
"-DENABLE_BLE",
"-DENABLE_CLASSIC",
f"-I{root / 'tests' / 'switch_parser_native_stubs'}",
f"-I{root / 'bluepad32_config'}",
f"-I{component / 'include'}",
str(root / "tests" / "switch_parser_native_test.c"),
str(component / "parser" / "uni_hid_parser_switch.c"),
str(component / "uni_hid_device.c"),
str(component / "uni_circular_buffer.c"),
str(component / "bt" / "uni_bt_conn.c"),
str(component / "controller" / "uni_gamepad.c"),
"-Wl,--gc-sections",
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -244,11 +244,20 @@ void test_input_report_mapping() {
void test_rumble_report() {
const uint8_t packet[8] = {0x00, 0x08, 0x00, 0xa5, 0x5a, 0x00, 0x00, 0x00};
ControllerRumbleOutput output{};
output.raw_valid = true;
output.raw_unmodified = true;
output.raw[0] = 0x80;
output.hd.actuators[0].sample_count = 1;
output.hd.actuators[0].samples[0].low_amplitude_q15 = 1000;
expect(XInput::parse_rumble_report(packet, sizeof(packet), &output),
"valid rumble report rejected");
expect(output.low_frequency_magnitude == 0xa5 &&
output.high_frequency_magnitude == 0x5a,
"rumble magnitudes mapped incorrectly");
expect(!output.raw_valid && !output.raw_unmodified &&
output.hd.actuators[0].sample_count == 0 &&
output.hd.actuators[1].sample_count == 0,
"XInput inherited Nintendo commands from reused output storage");
expect(!XInput::parse_rumble_report(packet, 4, &output),
"truncated rumble report accepted");
uint8_t wrong_type[8]{};