diff --git a/CMakeLists.txt b/CMakeLists.txt index d17fe79..61a69d9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,11 +52,9 @@ endif() if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") set(SWITCH_PICO_NATIVE_DEFAULT ON) set(SWITCH_PICO_CLOCK_DEFAULT 300) - set(SWITCH_PICO_PACKET_FRAMES_DEFAULT 32) else() set(SWITCH_PICO_NATIVE_DEFAULT OFF) set(SWITCH_PICO_CLOCK_DEFAULT 150) - set(SWITCH_PICO_PACKET_FRAMES_DEFAULT 64) endif() option(SWITCH_PICO_HAPTICS_EXPERIMENT "Enable native DualSense haptics and transport diagnostics" ${SWITCH_PICO_NATIVE_DEFAULT}) @@ -64,8 +62,15 @@ option(SWITCH_PICO_HAPTICS_EXPERIMENT_RAM "Execute the native haptics hot path from SRAM" ON) option(SWITCH_PICO_HD_RUMBLE "Auto-arm the first eligible DualSense native haptics stream" ${SWITCH_PICO_NATIVE_DEFAULT}) +if(SWITCH_PICO_HD_RUMBLE) + set(SWITCH_PICO_HAPTICS_EXPERIMENT ON) +endif() if(SWITCH2_PROBE_HUB) - set(SWITCH_PICO_CLOCK_DEFAULT 240) + if(SWITCH_PICO_HAPTICS_EXPERIMENT) + set(SWITCH_PICO_CLOCK_DEFAULT 300) + else() + set(SWITCH_PICO_CLOCK_DEFAULT 240) + endif() endif() set(SWITCH_PICO_SYS_CLOCK_MHZ "${SWITCH_PICO_CLOCK_DEFAULT}" CACHE STRING "Pico 2 W CPU clock: 300 MHz default, 150 stock or 400 opt-in") @@ -73,8 +78,8 @@ set_property(CACHE SWITCH_PICO_SYS_CLOCK_MHZ PROPERTY STRINGS 150 240 300 400) if(NOT SWITCH_PICO_SYS_CLOCK_MHZ MATCHES "^(150|240|300|400)$") message(FATAL_ERROR "SWITCH_PICO_SYS_CLOCK_MHZ must be 150, 240, 300, or 400") endif() -if(SWITCH2_PROBE_HUB AND NOT SWITCH_PICO_SYS_CLOCK_MHZ STREQUAL "240") - message(FATAL_ERROR "Native SIO hub requires SWITCH_PICO_SYS_CLOCK_MHZ=240") +if(SWITCH2_PROBE_HUB AND NOT SWITCH_PICO_SYS_CLOCK_MHZ MATCHES "^(240|300)$") + message(FATAL_ERROR "Native SIO hub requires SWITCH_PICO_SYS_CLOCK_MHZ=240 or 300") endif() set(SWITCH_PICO_OVERCLOCK_MV "1300" CACHE STRING "Experimental core voltage: 1300 mV, or explicit 1400 mV at 400 MHz") @@ -95,17 +100,13 @@ option(SWITCH_PICO_HCI_CREDIT_BUFFER if(SWITCH_PICO_HCI_CREDIT_BUFFER AND NOT SWITCH_PICO_HCI_CREDIT_BATCH) message(FATAL_ERROR "The dedicated HCI credit buffer requires credit batching") endif() -if(SWITCH_PICO_HD_RUMBLE) - set(SWITCH_PICO_HAPTICS_EXPERIMENT ON) +set(SWITCH_PICO_HD_PACKET_FRAMES "32" CACHE STRING + "Native stereo frames per packet: only the qualified 32-frame format is supported") +set_property(CACHE SWITCH_PICO_HD_PACKET_FRAMES PROPERTY STRINGS 32) +if(NOT SWITCH_PICO_HD_PACKET_FRAMES STREQUAL "32") + message(FATAL_ERROR "Native haptics requires 32-frame packets; the non-working 64-frame format is unsupported") endif() -set(SWITCH_PICO_HD_PACKET_FRAMES "${SWITCH_PICO_PACKET_FRAMES_DEFAULT}" CACHE STRING - "Native stereo frames per packet: 32 default, 64 unqualified experiment") -set_property(CACHE SWITCH_PICO_HD_PACKET_FRAMES PROPERTY STRINGS 32 64) -if(NOT SWITCH_PICO_HD_PACKET_FRAMES MATCHES "^(32|64)$") - message(FATAL_ERROR "SWITCH_PICO_HD_PACKET_FRAMES must be 32 or 64") -endif() -if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32" - AND SWITCH_PICO_HAPTICS_EXPERIMENT AND SWITCH_PICO_HD_PACKET_FRAMES STREQUAL "32") +if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32" AND SWITCH_PICO_HAPTICS_EXPERIMENT) if(NOT SWITCH_PICO_CYW43_PACKET_READ OR NOT SWITCH_PICO_HCI_CREDIT_BATCH OR SWITCH_PICO_SYS_CLOCK_MHZ LESS 300) message(FATAL_ERROR "32-frame native transport requires packet reads, credit batching and at least 300 MHz") diff --git a/HAPTICS_EXPERIMENT.md b/HAPTICS_EXPERIMENT.md index ef6ebb4..69ec7c5 100644 --- a/HAPTICS_EXPERIMENT.md +++ b/HAPTICS_EXPERIMENT.md @@ -4,6 +4,25 @@ Native Nintendo HD-rumble decoding and stateful XInput motor strengths feed the DualSense Bluetooth PCM backend. Standard AIO/XInput builds enable the qualified 300 MHz/1.3 V transport by default and auto-arm the first eligible DualSense that becomes ready, in any slot. One native stream is selected at a time; other controllers retain compatibility rumble. The deterministic transport fixture remains available. Console gameplay was user-tested; precise actuator-onset latency and perceptual equivalence to Nintendo hardware are not claimed. +Native Joy-Con USB `GAMEPAD`/`DUALSENSE` translation was introduced in 0.103, +which regressed Switch input and was rolled back. The 0.105 candidate corrects +the measured 300 MHz USB sampling offset and prevents unpaired Classic discovery +from competing with HD startup. The 0.106 candidate additionally buffers native-hub +UART stdout and releases USB IRQs around child-reset callbacks, addressing a +captured foreground-reply delay. PC protocol and PCM results do not establish +Switch-side controller recognition or physical input/actuator latency. +The user confirmed initial game response on 0.106, then loss of response after +setting the controller down. Bluetooth reports continued while USB input and +address-routing progress stopped. Sustained Switch input remains unqualified; +0.107 adds observer-progress diagnostics and 0.108 records root-response +observations, not a recovery fix. Both retain enabled 32-frame HD. On the +installed 0.108 image the DualSense stream auto-started in gameplay mode; +6,686 packets had been sent with no skips or send failures at the PC snapshot. +That counter check issued no motor commands and does not qualify physical rumble. +The user subsequently reported no freeze and satisfactory rumble on a Switch +trial and accepted 0.108 for now. This does not establish the disconnect's root +cause, long-term reliability, or physical latency. + The user observed 1–2 seconds of gameplay-to-haptics delay in OMP session `01a06fa9-cdc7-72de-ac0e-7de08c355f06`. Both a DS5Dongle-style 0x39 stream and a short 0x32 stream failed after continuous silence, latest-state replacement and can-send callbacks were tried. Do not repeat those changes as newly discovered fixes or attribute the observed delay to profile feedback. Recovered source and retained object identify three concrete issues: @@ -34,22 +53,22 @@ and 96, with the opposite channel zero and no compatibility-selector reports during the tones. The user confirmed strong, distinct sides and clean stops at peak 96/127. The gain curve was not increased to obtain this result. -The compact 64-frame/buffer-16 candidate felt worse despite zero skipped packets. -It is retained only as an explicit, physically unqualified experiment. That -comparison changed the control header and buffer field as well as frame count; -it does not establish that batching alone caused the difference. The accepted +The user reports that **64-frame transport does not work**. Its generator and +build selection have been removed; it is not a fallback or a candidate to retry. +Earlier compact-format comparisons changed the control header and buffer field +as well as frame count, so they do not isolate batching as the cause. The accepted 32-frame trial had ten skipped slots across its strong-pulse run and no send -failures, so mixed-radio and long-duration qualification remain outstanding. -Earlier results below are historical transport measurements, not approval of -the current or rejected formats' physical fidelity. +failures; mixed-radio and long-duration qualification remain outstanding. +Earlier results below are historical measurements, not approval of a rejected +format's physical fidelity. ## 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 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 is state-only: sequence/tag byte 0x10, sized state block 0x90/63, and valid flag0 0x80 to write AudioControl with default route/MicSelect. Other state validity flags stay clear: no volume, preamp, mute, trigger or LED change. It carries no PCM. Default subsequent controls are `{0x91,7,0xfe,0,0,0,0,0xff,counter}`, followed by `{0x92,64}` and one 64-byte PCM block (32 stereo frames). The data counter begins at zero after initialization and advances by one. The 0xff field is a reference parameter, not an established millisecond duration. Explicit 64-frame mode retains compact controls `{0x91,3,0x62,16,counter}`, two blocks under 0xd2, and a counter advancing by two; it is not the accepted default. Padding and Bluetooth CRC remain deterministic. No speaker/microphone stream, USB audio endpoint, Opus or resampler is added. -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 default deterministic fixture is 576 reports over 6.144 seconds: 96 priming slots, four cycles of left 100 Hz / silence / right 200 Hz / silence (24 reports = 256 ms per phase), then 96 trailing-silence reports. The state-only initialization occupies the first priming slot and counts as one report, with zero PCM frames. Explicit 64-frame mode preserves the same timeline with 288 total reports, 48 priming/trailing slots and 12 reports per phase. Peak remains 32/127, not full-strength rumble or a calibrated physical-force percentage. Gameplay has no one-second priming pattern and uses timestamped Switch commands. Stop restores compatibility output; disconnect cancels without stale-pointer use. +3. Report 0x32 plus A2 remains a 143-byte L2CAP SDU. The first report is state-only: sequence/tag byte 0x10, sized state block 0x90/63, and valid flag0 0x80 to write AudioControl with default route/MicSelect. Other state validity flags stay clear: no volume, preamp, mute, trigger or LED change. It carries no PCM. Subsequent controls are `{0x91,7,0xfe,0,0,0,0,0xff,counter}`, followed by `{0x92,64}` and one 64-byte PCM block (**32 stereo frames**, not 64 frames). The data counter begins at zero after initialization and advances by one. The 0xff field is a reference parameter, not an established millisecond duration. Padding and Bluetooth CRC remain deterministic. No speaker/microphone stream, USB audio endpoint, Opus or resampler is added. +4. At 3 kHz, 32 stereo frames require 93.75 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 is 576 reports over 6.144 seconds: 96 priming slots, four cycles of left 100 Hz / silence / right 200 Hz / silence (24 reports = 256 ms per phase), then 96 trailing-silence reports. The state-only initialization occupies the first priming slot and counts as one report, with zero PCM frames. Peak remains 32/127, not full-strength rumble or a calibrated physical-force percentage. Gameplay has no one-second priming pattern and uses timestamped host commands. Stop restores compatibility output; disconnect cancels without stale-pointer use. 6. No historical PCM FIFO. Generate only the current due block when transmission is permitted; bounded control mailbox across cores. Record packet counts, skipped blocks, failed sends, synchronous callbacks, generation cost, send gaps, lateness, request wait and first-tone timestamps. HCI submission is not physical actuator onset. 7. Host `haptics-experiment start`, `gameplay`, `status`, `stop`, and `profile` retain USB management framing. AIO builds enable these operations; explicitly disabled/UART builds do not. Operation 0x40 uses schema 5 and transport profiling uses schema 3. Both fixture and gameplay diagnostics report the actual configured frame count; host metadata derives packet counts and timing from it. Update firmware and host tools together. 8. Regression coverage must include synchronous callback delivery, rational clock and late wakeups, reference packet interpretation, finite completion/stop, disconnect/reconnect and compatibility restoration. Native probes cannot prove controller acceptance or physical latency. @@ -71,11 +90,11 @@ cmake --build build-hd-rumble Load `build-hd-rumble/switch-pico.elf` or `.uf2`, or use the standard `build.py --aio` entry point and `firmware/switch-pico-aio.uf2`. Both use the optimized native path. Reconnect a DualSense with PS if needed. Manual selection uses `haptics-experiment gameplay --slot N` after stopping any active run; CLI slots are zero-based, so the second controller is `--slot 1`. Manual arming does not persist across power cycles. `stop` disarms the native stream and restores compatibility output. A new eligible connection may auto-arm; there is no periodic re-arm that defeats an explicit stop. -The decoder preserves each actuator's one-to-three ordered substeps and frequency indices. Amplitudes become linear Q0.15 values via precomputed lookups; compatibility magnitudes retain their previous mapping. Profile strong/weak scales apply to the low/high bands of both actuators without discarding substeps. +The original Switch decoder preserves each actuator's one-to-three ordered substeps and frequency indices. Amplitudes become linear Q0.15 values via precomputed lookups; compatibility magnitudes retain their previous mapping. Profile strong/weak scales apply to the low/high bands of both actuators without discarding substeps. The synthesizer has independent left/right low/high phase accumulators. Frequencies are `40 * 2^(index/32)` and `80 * 2^(index/32)` Hz. Each Switch command occupies an 8 ms window, split into 24/12/8 PCM samples per substep for counts 1/2/3. New reports supersede unplayed old substeps; identical compressed words hold final state rather than replaying deltas. Each Switch-updated side expires after 50 ms, matching the existing conservative timeout policy. -Standard gameplay uses 10.667 ms causal lookback. The explicitly selected, unqualified 64-frame experiment uses 21.333 ms. Fixed 16-entry cross-core and synthesis histories contain commands, not PCM. Overflow is counted and obsolete sample intervals are not replayed. XInput holds use a distinct persistent command: strong/low magnitude drives the left 160 Hz band, weak/high magnitude drives the right 320 Hz band, until a new command or zero stop. They do not fake refreshes to evade the 50 ms Switch watchdog. Retained XInput state is seeded once per native run, including manual re-arming after compatibility output. +Gameplay uses 10.667 ms causal lookback. Fixed 16-entry cross-core and synthesis histories contain commands, not PCM. Overflow is counted and obsolete sample intervals are not replayed. XInput holds use a distinct persistent command: strong/low magnitude drives the left 160 Hz band, weak/high magnitude drives the right 320 Hz band, until a new command or zero stop. They do not fake refreshes to evade the 50 ms Switch watchdog. Retained XInput state is seeded once per native run, including manual re-arming after compatibility output. Native gameplay uses balanced **2x low/high gain after profile scaling**, followed by a gentle **0.8-power curve** on the combined amplitude. This lifts quiet and medium effects while retaining their low/high ratio. The curve is a 257-entry lookup with integer interpolation, not per-sample floating-point math. Combined weights are capped at 65535 to avoid overflow and clipping. Zero remains zero. The amplitude curve does not alter carrier frequencies or local-confirmation gain; packet timing follows the transport configuration above. This response replaced the initial 1.5x and low-band-only experiments after user comparison. @@ -83,6 +102,48 @@ Local confirmation remains a transient overlay and resumes the current host stat The gameplay stream continues with silence while idle. It stops on disconnect, explicit stop, or a stalled send-permission watchdog. Existing LED feedback can drain without switching the controller out of native haptics. Continuous idle streaming trades power for avoiding repeated audio-mode startup. +### Native Joy-Con USB input + +Native Output Report `0x01` preserves both 10-bit frequency codes and both +10-bit amplitudes in each five-byte sample. It does not quantize frequencies +through the original Switch's seven-bit representation. The frequency model is +`10 * 2^((code - 1) / 96)` Hz; active bands accept codes 1–670, and a zero-amplitude +band may retain any ten-bit code. Raw amplitudes 0–1023 are normalized to Q0.15 +after low/strong and high/weak profile gains on both actuators. + +Each native substep occupies **16 PCM frames (5.333 ms)**, rather than dividing +the compatibility scheduler's 12 ms approximation or the original Switch's +8 ms window. The final sample holds only until 50 ms after original USB receipt. +Count-zero HOLD and malformed reports do not refresh that deadline. + +Instances A-R/A-L/B-R/B-L map to their assigned source's right/left actuators. +Only the selected DualSense uses PCM; Wii and other/unselected controllers keep +bounded conventional output. Pending, draining and restoring HD ownership never +permits compatibility fallback. Cancellation and mailbox admission share an +atomic backend-to-haptics lock boundary; no driver or timer callback runs there. +Source epochs discard retired host work without restarting a still-selected +physical connection or disturbing another pair. + +Built-in cues use side-local PCM overlays. Cue completion requires a successful +PCM block intersecting the actual shortened cue interval—not mailbox acceptance, +a state-only setup packet or a late silent packet. Already submitted lookback +samples cannot be recalled. Local/profile feedback remains higher priority. +The internal PCM-coverage timestamp does not change USB diagnostic schema 5. + +Native-hub HD builds require 300 MHz, packet-level reads and HCI-credit batching. +The software USB observer compiles for 25 cycles/bit with default phase **12**; +240 MHz compatibility builds retain 20 cycles/bit and phase 4. The offset is +relative to a software edge timestamp: it cannot be scaled as a bit-period +fraction. Phase 5 in 0.103 failed child controls even with HD stopped; phase 12 +passed the loaded comparison without changing the receiver instruction path. +Clock mismatch fails detached, and attach-last startup remains intact. + +Closed-window Classic discovery now skips unpaired peers before paging. +Stored peers and explicit pairing remain supported; incoming and BLE paths are +unchanged. This avoids the measured startup competition without longer +send-permission timeouts, extra ACL credits, 64-frame packets or automatic retries. +See `README.md` for the comparison and qualification limits. + ## Building and running Use the repository's provisioned Pico SDK/toolchain environment. These CMake commands only build; they do not flash the adapter: @@ -116,7 +177,7 @@ USB vendor operation 0x40: OUT `{action, slot}` (0=stop, 1=finite fixture, 2=gam - Seventeen little-endian u32 fields: run_id, connection_generation, start_us, generated_packets, sent_packets, skipped_packets, send_failures, can_send_requests, synchronous_callbacks, max_generate_us, max_send_gap_us, max_lateness_us, max_request_wait_us, first_tone_due_us, first_tone_sent_us, last_sent_us, elapsed_us. - Four u8 fields: state, slot, last_error, reserved (zero). -- Byte 72: mode (0=fixture, 1=gameplay); byte 73: actual stereo frames per packet (32 or 64); byte 74: whether the last successfully submitted PCM packet was nonzero (0/1); byte 75: zero reserved. Nonzero PCM is firmware output evidence, not measured actuator motion. +- Byte 72: mode (0=fixture, 1=gameplay); byte 73: actual stereo frames per packet (32 in current firmware; historical images may report 64); byte 74: whether the last successfully submitted PCM packet was nonzero (0/1); byte 75: zero reserved. Host decoding of historical metadata does not enable 64-frame output. Nonzero PCM is firmware output evidence, not measured actuator motion. - Little-endian u32 at 76: `host_updates`; at 80: `dropped_updates`. - State: idle=0, pending=1, running=2, completed=3, stopped=4, disconnected=5, unsupported=6, error=7. Disabled build reports unsupported. - Microsecond timestamps are low 32 bits of Pico uptime; use unsigned modular differences for this bounded experiment. Host receipt time is not a hardware onset measurement. @@ -156,7 +217,7 @@ An explicit `SWITCH_PICO_SYS_CLOCK_MHZ=400` plus `SWITCH_PICO_OVERCLOCK_MV=1400` ## Verification and acceptance -Build normal and opt-in firmware, run focused regressions, verify time-critical symbols and retained wake configuration. Exercise the actual USB CLI and connected controller. Capture live counters during the finite run. The current two-block format requires 46.875 reports/s and nominal HID+A2 traffic of 6,703.125 bytes/s, before L2CAP/HCI/radio overhead. Acceptance is 288/288 submissions with zero skipped slots and failures, repeated runs, bounded stop and continuing controller input. +Build affected firmware, run focused regressions, verify time-critical symbols and retain wake configuration. Hardware tests require authorization: then exercise the actual USB CLI and controller and capture live counters. The 32-frame format requires 93.75 reports/s and nominal HID+A2 traffic of 13,406.25 bytes/s before L2CAP/HCI/radio overhead. Cadence acceptance is 576/576 submissions with zero skipped slots and failures, repeated runs, bounded stop and continuing controller input; historical results below do not supersede that criterion. Physical acceptance requires correlating first-tone scheduling/sending with actuator onset using an accelerometer/contact microphone or a synchronized observation. Record controller model/firmware, packet gaps and CPU generation time. A successful `l2cap_send` is not an acknowledgement of playback. Only a measured low-latency result permits integration with Nintendo's per-side, per-band timeline. diff --git a/README.md b/README.md index 6bcb4be..57dd166 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,11 @@ Pairing order determines the initial USB slot assignment. Up to four physical Bl With no active controller, the Pico runs Bluepad32 discovery and autoconnect. After any controller becomes active, active discovery pauses to protect input, motion, and rumble latency; bonded controllers may still initiate incoming reconnects. Pairing keys persist across Pico power cycles, so reconnect a previously paired controller by pressing its normal Home, PS, or Xbox power button. Hold BOOTSEL for the bounded pairing window before pairing a new controller or a controller that requires host-side discovery. Outside that window, BTstack remains non-bondable and rejects new Classic and BLE authentication. +Closed-window Classic discovery only attempts peers with stored link keys. +Unpaired inquiry results are ignored until the explicit pairing window opens, +rather than consuming radio time with connections that cannot authenticate. +Incoming reconnects and BLE discovery/address resolution are unchanged. + Exception: a ready solo Joy-Con 2 keeps a low-duty passive BLE scan running for a remembered opposite half while physical capacity remains and no controller setup is pending. This reconnect does not require BOOTSEL, start Classic inquiry, or enable fresh pairing. Scanning stops when the pair completes; an explicit pairing window restores normal discovery. To clear every stored Classic, BLE and proprietary Switch 2 pairing without a PC, hold BOOTSEL continuously for 10 seconds. The normal pairing window opens after two seconds; continuing to hold until the LED changes to a rapid blink clears remembered controllers, disconnects active controllers, publishes neutral state to every slot, and closes new authentication. Release BOOTSEL, open a new pairing window, and pair controllers again. A persistent-storage failure is reported rather than acknowledging a successful clear. @@ -655,9 +660,10 @@ fault-injection and right, left, Wii, donor-capture, standalone-probe and composite builds also pass. These checks do not establish Switch acceptance of both functions; that requires the console enumeration trial. -Full-controller input splitting and continuous USB HD-rumble forwarding are -not implemented yet. This experiment relays two genuine Joy-Cons, including -their opaque motion/mouse packets and acknowledged built-in vibration cues. +This JOYCON2 relay mode does not split a full controller or translate continuous +USB HD rumble. It relays two genuine Joy-Cons, including opaque motion/mouse +packets and acknowledged built-in cues. GAMEPAD/DUALSENSE mode below provides +full-controller splitting and optional DualSense HD translation. The composite image has now been flashed with both pairing banks and all other persistent storage verified unchanged. Linux enumerates all four interfaces; @@ -886,14 +892,14 @@ not reset the other, including when physical slot indices are reused. The existing IMU target mask is side-local and repeats for each pair. Physical Bluetooth capacity remains four devices: a physical Joy-Con pair uses two links. -GAMEPAD/DUALSENSE builds from 0.101 translate native gameplay vibration into -conventional source-driver rumble, including a Wii Remote used as a GAMEPAD -source. Each virtual R/L half controls only its assigned source's right/weak -or left/strong contribution; mono actuators combine those contributions. -Source profile rumble gains apply. This does not require the optional DualSense -HD-haptics experiment and does not reproduce HD carrier-wave frequencies. -The separate JOYCON2 relay and dedicated Wii-IR builds retain their existing -built-in sample support; this gameplay translation is for full-controller mode. +GAMEPAD/DUALSENSE builds support native gameplay vibration. With HD enabled, +0.108 preserves the native frequency/amplitude timeline for one selected +DualSense, in any physical slot. Other/unselected controllers, including Wii +Remotes, retain the conventional source-driver path introduced in 0.101. +Each virtual R/L half controls only its assigned source's right/left actuator; +mono motors combine their contributions. Profile gains apply in both paths. +HD-disabled builds remain amplitude-only. The separate JOYCON2 relay and +dedicated Wii-IR builds retain their existing built-in sample support. With the four private capture sets described below prepared, build separately: @@ -906,12 +912,32 @@ cmake -S . -B build-switch2-native-two-pair-live \ -DSWITCH2_PROBE_HUB=ON -DSWITCH2_PROBE_PAIR_COUNT=2 \ -DSWITCH2_PROBE_NEUTRAL_INPUT=OFF -DSWITCH2_PROBE_ACK_SETUP04=ON \ -DSWITCH2_PROBE_USB_INIT=ON -DSWITCH2_PROBE_TRACE_NATIVE_INPUT=ON \ + -DSWITCH_PICO_SYS_CLOCK_MHZ=240 -DSWITCH_PICO_HD_PACKET_FRAMES=32 \ -DSWITCH_PICO_HD_RUMBLE=OFF -DSWITCH_PICO_HAPTICS_EXPERIMENT=OFF \ -DSWITCH_PICO_CYW43_PACKET_READ=OFF -DSWITCH_PICO_HCI_CREDIT_BATCH=OFF \ -DSWITCH_PICO_HCI_CREDIT_BUFFER=OFF cmake --build build-switch2-native-two-pair-live --parallel 4 ``` +That command explicitly retains the 240 MHz compatibility configuration. To +build the **0.108 diagnostic HD candidate** using the same private inputs: + +```sh +cmake -S . -B build-switch2-native-two-pair-live \ + -DSWITCH_PICO_SYS_CLOCK_MHZ=300 -DSWITCH_PICO_OVERCLOCK_MV=1300 \ + -DSWITCH_PICO_HD_RUMBLE=ON -DSWITCH_PICO_HAPTICS_EXPERIMENT=ON \ + -DSWITCH_PICO_HD_PACKET_FRAMES=32 \ + -DSWITCH_PICO_CYW43_PACKET_READ=ON -DSWITCH_PICO_HCI_CREDIT_BATCH=ON \ + -DSWITCH_PICO_HCI_CREDIT_BUFFER=ON +cmake --build build-switch2-native-two-pair-live --parallel 4 +``` + +Only 32-frame PCM is supported. The reported non-working 64-frame generator is +removed, not retained as a fallback. The 0.105 sampling/discovery corrections +remain; 0.106 also removes blocking UART stdout from the radio poll and lets +USB interrupts run during child-reset callbacks. The measured PC checks below +do not establish Switch recognition or physical input latency. CMake does not flash. + The output is `build-switch2-native-two-pair-live/switch-pico.uf2` (plus ELF). Full root management and explicit software BOOTSEL remain available. On the Switch, use real controls mapped to L+R to register each paired layout in @@ -1248,12 +1274,12 @@ retry or reset occurred; sustained traffic and gameplay remain unqualified. That 0.100 image did not implement gameplay rumble: HID output reports were logged, while built-in vibration samples used a separate cue path. -**0.101 gameplay-rumble candidate:** native Output Report `0x01` now reaches the -existing source-driver scheduler in GAMEPAD/DUALSENSE mode. Interrupt reports -including the ID and SET_REPORT payloads excluding it are normalized without -copying their padding. The decoder requires a complete 16-byte LRA block and -format `01`, preserves one to three samples, and converts the larger of the two -10-bit amplitudes to a conventional 8-bit magnitude. Count-zero HOLD does not +**0.101 gameplay-rumble candidate (compatibility baseline):** native Output +Report `0x01` reached the source-driver scheduler in GAMEPAD/DUALSENSE mode. +Interrupt reports including the ID and SET_REPORT payloads excluding it were +normalized without copying padding. That decoder required a complete 16-byte +LRA block and format `01`, preserved one to three samples, and collapsed the +larger ten-bit amplitude to an eight-bit magnitude. Count-zero HOLD does not change output or refresh its watchdog; an explicit zero-amplitude sample stops only that side. Unknown formats, wrong IDs and truncated frames do not dispatch. @@ -1275,8 +1301,8 @@ expiry and reset/suspend cancellation. It does not qualify physical motor sensation, HD fidelity or console transport timing. The authorized 0.101 flash preserved persistent bytes; the user subsequently reported working rumble, but also second-player latency and failure to enumerate on the first cold connection -to the Switch. HD reproduction is deferred. Picotool and management labels are -synchronized from this candidate onward. +to the Switch. HD reproduction was deferred at that stage. Picotool and +management labels are synchronized from this candidate onward. **0.102 attach-last startup candidate:** the native initializer previously forced the physical D+ pull-up on before configuring the controller/EP0, @@ -1298,13 +1324,201 @@ software attach-before-ready defect; it is not a physical USB timing trace. After the authorized 0.102 flash preserved persistent bytes, the user reported that the cold-start connection now works. That is user qualification, not an instrumented electrical measurement or a long-run reliability claim. +Later testing on 0.102 still found intermittent unrecognized Switch input until +unplug/replug. The user confirmed that UART report/completion counters were +advancing during that failed-recognition state. Those counters do not establish +that the Switch accepted the virtual controller or used its input. -The Pico is running 0.102. Gameplay rumble, USB runtime scheduling and player -routing are unchanged by the startup correction. Second-player latency +The 0.102 startup correction left gameplay rumble, USB runtime scheduling and +player routing unchanged. Second-player latency remains a separate open investigation: a 20-second concurrent PC capture showed roughly 231–232 reports/second across all four children, which does not establish equal physical input-to-display latency on the Switch. +**0.103 HD candidate (rejected; device restored to 0.102):** native `0x01` decoding preserves +both ten-bit frequency/amplitude bands. The selected DualSense receives +32-frame `0x32`/`0x92` PCM, with native 1/96-octave carrier precision and +16 PCM frames per substep (5.333 ms). This does not change original Switch +8 ms decoding or the conventional 12 ms approximation. Low/strong and high/weak +profile gains retain both bands; Wii and unselected devices keep conventional +rumble. One DualSense stream remains selected, not one stream per pair. + +HD ownership includes startup, drain and restoration: rejection never silently +falls back to compatibility. Side stops, the 50 ms receipt watchdog, USB +reset/suspend and source epochs retire affected host work. Built-in cues remain +side-local PCM overlays, and their completion requires actual successful PCM +coverage of the admitted cue interval. Queue admission is not delivery evidence. +Mailbox commit/cancellation are serialized without holding the backend lock +across driver or timer callbacks. + +That candidate used a compiled 300 MHz/25-cycle bit period and phase 5; +240 MHz retained its 20-cycle period and phase 4. Exact clock mismatch +fails detached, and attach-last startup is unchanged. Required packet reads, +credit batching and the 300 MHz HD guard were not weakened to accommodate +64-frame transport; that non-working format was removed. + +Verification: 623 repository tests passed, including clock/startup matrices, +frequency/substep fidelity, timestamp wrap, cue coverage, cancellation races +and source isolation. A host smoke run exercised the production USB callback, +decoder, profile/backend routing and PCM engine with instrumented transports: +DualSense in physical slot 1 beside a Wii, raw and SET_REPORT forms, independent +sides, profile gains, HOLD/watchdog behavior and reset/suspend isolation. +Eleven firmware/probe targets built, including 300 MHz HD and 240 MHz baselines. +The linked Core 1 receiver's direct-call graph remains in SRAM, with 25-cycle +capture increments. That host verification did not actuate physical controllers. + +The authorized 0.103 flash was verified and the native hub re-enumerated on the PC. +Configuration generation 21 / CRC `b58672ac`, all nine pairings and profile +selections were unchanged. Full-flash readback confirmed all 3,239,936 bytes +outside programmed sectors were unchanged. Diagnostics reached initialization +stage 6; measured system/USB clocks were 300,001/48,000 kHz. No controller had +reconnected during the post-flash checks, so HD remained idle. + +The subsequent Switch test failed: the user reported that input was no longer +recognized. On the PC, Bluetooth reports and the idle HD stream progressed, +but native child `GET_CONFIGURATION` requests intermittently failed with I/O +errors before initialization. Root management success did not qualify child USB. +This does not isolate the clock/phase change from the added HD/transport load. + +The preserved 0.102 image was restored and its write verified. Configuration, +all nine pairings and profile selections remained unchanged. The same PC probe +then passed child claiming, descriptor/EP0 isolation, native initialization and +bulk isolation, receiving over 1,800 packets per child. Live two-player +qualification did not pass: only one source was connected, and independent +manual controls were not exercised. Bluetooth input continued after recovery. + +That recovery returned the device to 0.102 with conventional rumble. No manual +motor test was run during the rollback. The later corrections are recorded below; +Switch recognition and second-player latency remain separate qualification work. + +**0.105 USB timing and HD-startup correction:** the first sample offset is +relative to a software timestamp taken after edge detection, not a fixed +fraction of the physical USB bit period. Scaling phase 4 to 5 at 300 MHz was +insufficient. Phase 5 failed 18/160 child controls with HD idle, 10/160 with it +running, and 25/160 after stopping it. Phase 12 passed 2,400 unloaded controls +and 4,000 with a connected DualSense and active 32-frame PCM. The receiver +instructions and addresses were identical in that comparison. The source default +is now phase 12 at 300 MHz; the 240 MHz phase-4 baseline is unchanged. + +A second startup failure exhausted the controller's eight ACL slots while +outgoing discovery attempts paged nearby Pro Controllers, including an unpaired +peer. Input/HCI polling continued, but completion gaps reached 166 ms and the +HD send-permission watchdog expired. A bounded diagnostic cancellation of only +the unpaired pending attempt allowed early auto-start and over 11,000 successful +PCM submissions without that timeout. This was diagnostic code, not the final fix. + +The permanent Bluepad32 patch instead rejects unpaired **Classic inquiry +results** before allocating a device or starting a page while non-bondable. +Stored peers still reconnect; opening the pairing window admits new controllers. +Incoming connections, BLE discovery, ACL capacity and timeout values are unchanged. +A real patched-handler regression fails before this change and passes afterward. +The external Bluepad32 checkout stays pristine. + +Both fixes are included in 0.105 without temporary observation/cancellation hooks. +The complete suite passes 624 tests, and affected firmware/probe builds pass. +Actual HD waveform/stop and automatic-start measurements are retained in the +ignored deployment evidence. UART delivery, USB completion and PC protocol tests +must not be promoted to proof of Switch-side recognition or physical latency. + +On the final 0.105 image, HD started automatically without a management re-arm. +Loaded child-control checks passed 4,000/4,000 requests. A bounded native +80/160/320 Hz test delivered 111 host updates and 284 PCM packets with no skips, +drops or compatibility fallback during that test; zero commands returned PCM +to silence while input continued. Both 60 ms built-in cues and explicit stops +also received their native acknowledgements. + +The user accidentally unplugged/replugged USB between the waveform and cue +checks. Those are separate boot epochs, not uninterrupted-operation evidence. +The later boot again auto-started HD and reached 6,229 sent PCM packets with +four skipped slots and zero send failures over the observed interval. +Configuration generation 21 / CRC `b58672ac`, all nine pairings and profile +selections remained unchanged. Switch-side recognition and physical sensation +still require the user's console/controller observation. + +**0.106 foreground-response correction:** the user still observed unresponsive +Switch input on 0.105. One preserved state stopped partway through child +enumeration. A recorder opened before the next replug captured root port-status +and child identity reads whose replies were not armed for **34.395 ms and +33.720 ms**. The last observed host IN poll was 4.734 ms after the root SETUP; +Core 0 was in `RADIO_POLL` throughout those polling records. + +Bluepad32's `uni_logv` used `vfprintf(stdout)` and the Pico UART's blocking +output callback, bypassing the native hub's bounded logger. Native-hub startup +now redirects UART stdout into that same ordered queue before Bluetooth starts. +The SDK's UART setup and stdin callbacks remain intact. Output and flush do not +wait for UART space; overflow and unsafe-context stdio are counted as dropped +diagnostic bytes. The UART drain remains foreground-only and nonblocking. + +A separate register-model regression demonstrated that a sibling SETUP could +prevent the next root status-change poll while a port-reset callback held USB +IRQs disabled. The acknowledged port action is now claimed atomically, then +its immutable request is executed with IRQs restored. A later bus reset still +wins. The regression fails before this change and passes afterward for two +and four children; this proves the hazard, not sole causation of the console stall. + +0.106 passed 624 tests and ten affected firmware/probe builds. It was flashed +with configuration generation 21 / CRC `b58672ac`, all nine pairings and profile +selections preserved. Cold PC enumeration captured child string-reply +preparation at 50.1/100.7 us; these are not identical requests to the failed +Switch capture. A 12-second root/child stress check passed 11,880 requests while +Bluetooth input and automatically armed 32-frame HD continued; the observed HD +run had no skipped packets or send failures. The user subsequently confirmed +initial Switch game response, then loss of response after setting the controller +down. The preserved trace stops USB input/address-routing progress near 113.2 s +while Bluetooth reports, USB SOF and Core 0 continue. A root `CLEAR_FEATURE` +for endpoint `0x8f` completed immediately before the final child polls; all EP0 +states were idle afterward. This is a separate unresolved transport failure, +not evidence that controller inactivity caused it or that the endpoint clear +was defective. **0.106 is not qualified for sustained Switch input.** + +**0.107 diagnostic follow-up:** the four-minute PC-only stream check on 0.106 +received 57,292 reports without USB errors, but did not reproduce the console +stop. Linux rejected the attempted root endpoint-clear request before it +appeared in the firmware trace; that operation was not exercised on the PC. +The root hub driver was not detached to bypass the restriction. + +Trace-enabled 0.107 adds Core 1 capture-return and discarded-header counters, +the last discarded raw SYNC/PID word, and routing enable/fault/table state. +The counters update after capture or rejection, outside token sample deadlines; +non-trace builds do not perform these counter updates. Compare their progress +with hardware SOF and the existing address-routing counters. A sampled +`a596a666` is a SOF header and `96a5a666` a NAK header, not a controller-input ACK. +These observations help distinguish a quiet router from an inactive sampler; +they are not a raw USB analyzer or a recovery mechanism. + +The diagnostic image was flashed with configuration, pairings and profile +selections preserved. Its 20-second concurrent PC smoke received 18,900 child +reports while completing 18,824 root/child controls without USB errors; observer +counters and Bluetooth reports also advanced. The full suite passed 624 tests, +then 17 focused tests passed after trace-only counter gating. All eleven +affected firmware/probe builds passed, including the standalone RAM observer. +This is additional diagnostic coverage, **not a fix or Switch qualification**. +The user also reproduced the stop on 0.107. Afterward Core 1 continued sampling +SOF headers with routing enabled and no observer fault. Both 0.106/0.107 captures +place the root endpoint clear about 24 ms after the last observed root IN. +The final root handshake remains unmeasured; no cause or recovery fix is claimed. + +**0.108 targeted root-response recorder:** existing-hardware tracing now retains +the first sampled non-token header following a successfully selected root-IN +candidate, its observation clock, and any subsequent drain-qualified SE0-to-J +transition. A copy taken before root SETUP survives the recovery control's own +response. New, rejected or unmapped tokens end attribution; idle qualification +does not invent an EOP. These are header/transition observations, not endpoint +decoding, a full packet validation, or an independent wire analyzer. + +Trace/plain receiver tests cover attribution, pre-SETUP preservation, missing +EOP and counter rollover. The full suite passed 624 tests and all eleven +affected builds passed. On the installed image, PC NAK observations survived +the next control request; a concurrent 20-second check passed 19,167 HID reports +and 21,152 controls. HD remained enabled at 300 MHz with 32-frame packets and +automatically ran on slot 0: the measured snapshot had 6,686 packets sent, +zero skips and zero send failures. No motor command was sent by these checks; +they do not qualify physical rumble. Configuration, nine pairings and profile +selections were preserved. The user subsequently reported no freeze and +satisfactory rumble on a Switch trial, and accepted this image for now. +Instrumentation can change timing; the disconnect's root cause and long-term +reliability remain unqualified. This is not a demonstrated recovery fix. + **Neutral two-pair transport experiment (0.91):** the standalone probe can expose four native children, ordered **A-R, A-L, B-R, B-L** on hub ports 1–4. This is an explicit USB transport experiment, not multi-source GAMEPAD mode. Bluetooth, diff --git a/build.py b/build.py index 13cb11f..07682b6 100755 --- a/build.py +++ b/build.py @@ -412,7 +412,7 @@ def build( f"-DSWITCH_PICO_HD_RUMBLE={native}", f"-DSWITCH_PICO_SYS_CLOCK_MHZ={300 if native == 'ON' else 150}", "-DSWITCH_PICO_OVERCLOCK_MV=1300", - f"-DSWITCH_PICO_HD_PACKET_FRAMES={32 if native == 'ON' else 64}", + "-DSWITCH_PICO_HD_PACKET_FRAMES=32", f"-DSWITCH_PICO_CYW43_PACKET_READ={native}", f"-DSWITCH_PICO_HCI_CREDIT_BATCH={native}", ] diff --git a/patches/bluepad32-sdl3-imu.patch b/patches/bluepad32-sdl3-imu.patch index 101c1dd..a4439ac 100644 --- a/patches/bluepad32-sdl3-imu.patch +++ b/patches/bluepad32-sdl3-imu.patch @@ -125,6 +125,24 @@ diff --git a/src/components/bluepad32/bt/uni_bt_bredr.c b/src/components/bluepad uni_hid_device_disconnect(device); uni_hid_device_delete(device); /* 'device' is destroyed, don't use */ +@@ -562,6 +567,17 @@ void uni_bt_bredr_on_gap_inquiry_result(uint16_t channel, const uint8_t* packet, uin + logi(", name '%s'", name_buffer); + } + logi("\n"); + ++ // Reconnect discovery is not a pairing request. Avoid paging nearby ++ // unpaired devices outside the explicit bondable window: their connection ++ // attempts compete with live controller input and native PCM for airtime. ++ if (!gap_get_bondable_mode()) { ++ link_key_t key; ++ link_key_type_t type; ++ if (!gap_get_link_key_for_bd_addr(addr, key, &type)) { ++ return; ++ } ++ } ++ + supported = uni_hid_device_on_device_discovered(addr, name_buffer, cod, rssi) == UNI_ERROR_SUCCESS; + if (supported) { diff --git a/src/components/bluepad32/bt/uni_bt_le.c b/src/components/bluepad32/bt/uni_bt_le.c --- a/src/components/bluepad32/bt/uni_bt_le.c +++ b/src/components/bluepad32/bt/uni_bt_le.c diff --git a/src/firmware/core/native_haptics.h b/src/firmware/core/native_haptics.h new file mode 100644 index 0000000..1dc3216 --- /dev/null +++ b/src/firmware/core/native_haptics.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +// Native Switch 2 LRA samples: two independent frequency/amplitude bands. +// Frequency codes use Hz = 10 * 2^((code - 1) / 96); amplitude codes are +// unsigned linear 10-bit values. Keep the wire precision until the output +// backend selects PCM synthesis or conventional-motor approximation. +typedef struct { + uint16_t low_frequency_code; + uint16_t high_frequency_code; + uint16_t low_amplitude; + uint16_t high_amplitude; +} NativeHapticsSample; + +typedef struct { + uint8_t sample_count; // 0: no update, including no watchdog refresh; max 3. + NativeHapticsSample samples[3]; +} NativeHapticsActuatorFrame; + +typedef struct { + NativeHapticsActuatorFrame actuators[2]; // Physical left, right. +} NativeHapticsFrame; + +// Existing conventional-motor approximation; do not use this for HD timing. +#define NATIVE_HAPTICS_COMPAT_FRAME_US 12000u +// Native sample spacing at 3 kHz. Existing acoustic characterization measured +// 5.27 ms +/-0.16 ms; the native sender uses ceil(count * 16 / 3) ms guards. +#define NATIVE_HAPTICS_SAMPLE_PCM_FRAMES 16u +#define NATIVE_HAPTICS_WATCHDOG_US 50000u diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 6277e0a..8d41dd2 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -301,6 +301,13 @@ struct NativeGamepadCue { bool active = false; bool consumed = false; bool in_flight = false; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + uint32_t hd_run_id = 0; + uint32_t hd_packet_baseline = 0; + uint32_t hd_submitted_us = 0; + uint32_t hd_expires_us = 0; + bool hd_pending = false; +#endif }; struct NativeGamepadMotorOutput { @@ -329,8 +336,8 @@ constexpr NativeGamepadCuePattern kNativeGamepadCuePatterns[8] = { constexpr uint32_t kNativeGamepadCueDeadlineMs = 2000; // One host block spans 12 ms; the last magnitude holds only until the 50 ms // receipt watchdog. ERM compatibility ignores carrier frequencies, not timing. -constexpr uint32_t kNativeGameplayFrameMs = 12; -constexpr uint32_t kNativeGameplayWatchdogMs = 50; +constexpr uint32_t kNativeGameplayFrameMs = NATIVE_HAPTICS_COMPAT_FRAME_US / 1000; +constexpr uint32_t kNativeGameplayWatchdogMs = NATIVE_HAPTICS_WATCHDOG_US / 1000; #endif @@ -513,6 +520,16 @@ uint32_t g_native_slot_epoch[kSlotCount]{}; uint64_t g_next_native_token = 1; uni_hid_device_t* g_native_pending_devices[kSlotCount]{}; NativeGamepadReportIngress g_native_reports[kSlotCount]{}; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +// Backend-lock protected attachment mirror. Generation changes are reconciled +// on the BT core; Core 0 may only cancel through the haptics mailbox. +struct NativeHapticsAttachment { + uni_hid_device_t* device = nullptr; + uint32_t generation = 0; + uint8_t cancel_mask = 0; +}; +NativeHapticsAttachment g_native_haptics[kSlotCount]{}; +#endif bool native_device_allowed(const uni_hid_device_t* device) { if (device == nullptr || !uni_hid_device_is_gamepad(device)) return false; @@ -585,6 +602,9 @@ void cancel_native_cue_locked(NativeGamepadCue& cue) { ++g_native_output_revision[&cue - g_native_cues]; cue.active = false; cue.result = -1; + #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + cue.hd_pending = false; + #endif // The slot's last motor output remains owned until the timer replaces it. } @@ -596,6 +616,9 @@ bool native_feedback_owns(const BackendSlot& slot, uint32_t now_ms) { } void cancel_native_gameplay_locked(const BackendSlot& slot) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + g_native_haptics[&slot - g_slots].cancel_mask |= 3; +#endif for (NativeGamepadCue& cue : g_native_cues) if (cue.rumble_count != 0 && cue.slot == &slot - g_slots) cancel_native_cue_locked(cue); @@ -680,6 +703,9 @@ void refresh_native_source_locked(uint8_t reselected_pair = 0xff) { for (uint8_t index = 0; index < kSlotCount; ++index) { if ((retired_slots & (1u << index)) == 0) continue; BackendSlot& slot = g_slots[index]; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + g_native_haptics[index].cancel_mask |= 3; +#endif g_macro_capture.disconnect(index, slot.connection_generation, time_us_32()); ++slot.connection_generation; ++slot.state_generation; @@ -698,6 +724,9 @@ void refresh_native_source_locked(uint8_t reselected_pair = 0xff) { } void retire_native_slot(uint8_t index) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + g_native_haptics[index].cancel_mask |= 3; +#endif ++g_native_slot_epoch[index]; for (NativeGamepadBinding& binding : g_native_bindings) { if (binding.slot != index) continue; @@ -721,6 +750,99 @@ bool native_cue_current(uint8_t pair, const NativeGamepadCue& cue) { g_slots[cue.slot].active && cue.connection_generation == g_slots[cue.slot].connection_generation; } + +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +void flush_native_haptics_cancellations() { + for (uint8_t index = 0; index < kSlotCount; ++index) { + state_lock_enter(); + NativeHapticsAttachment& attached = g_native_haptics[index]; + const uint32_t generation = attached.generation; + const uint8_t mask = attached.cancel_mask; + attached.cancel_mask = 0; + // Only callback-free mailbox operations may nest backend -> haptics. + // Apply the fence before releasing state: another flusher must not + // return while this old mask can still erase a newer admission. + if (mask != 0) + haptics_experiment_cancel_native(index, generation, mask); + state_lock_exit(); + } +} + +// BT context only. Attachment/scheduler operations stay outside backend state. +void sync_native_haptics_attachments() { + flush_native_haptics_cancellations(); + for (uint8_t index = 0; index < kSlotCount; ++index) { + state_lock_enter(); + const BackendSlot& slot = g_slots[index]; + const NativeHapticsAttachment previous = g_native_haptics[index]; + uni_hid_device_t* device = slot.active && slot.companion == nullptr + ? slot.device : nullptr; + const uint32_t generation = slot.connection_generation; + const bool changed = previous.device != device || + previous.generation != generation; + state_lock_exit(); + if (!changed) continue; + haptics_experiment_cancel_native(index, previous.generation, 3); + if (device != nullptr && !uni_hid_parser_switch2_is_ble_device(device)) + haptics_experiment_attach(index, generation, device); + state_lock_enter(); + g_native_haptics[index].device = device; + g_native_haptics[index].generation = generation; + state_lock_exit(); + } +} + +bool native_haptics_selected(const uni_hid_device_t* device) { + uint8_t index = kSlotCount; + uint32_t generation = 0; + uint32_t attached_generation = 0; + state_lock_enter(); + for (uint8_t candidate = 0; candidate < kSlotCount; ++candidate) { + if (device != nullptr && g_slots[candidate].device == device) { + index = candidate; + generation = g_slots[candidate].connection_generation; + attached_generation = g_native_haptics[candidate].generation; + break; + } + } + state_lock_exit(); + return index < kSlotCount && + (haptics_experiment_native_selected(index, generation) || + haptics_experiment_native_selected(index, attached_generation)); +} + +void complete_native_hd_cues() { + HapticsExperimentDiagnostics status{}; + haptics_experiment_snapshot(&status); + state_lock_enter(); + for (uint8_t instance = 0; instance < kNativeChildCount; ++instance) { + NativeGamepadCue& cue = g_native_cues[instance]; + if (!cue.hd_pending) continue; + if (!native_cue_current(instance / 2, cue) || + cue.hd_run_id != status.run_id || cue.slot != status.slot || + cue.connection_generation != status.connection_generation) { + cancel_native_cue_locked(cue); + } else if (status.sent_packets > cue.hd_packet_baseline && + status.sent_packets > 1 && + static_cast(status.last_pcm_end_us - cue.hd_submitted_us) >= 334) { + // Quantize conservatively: the last emitted sample precedes the + // interval end by ceil(1000/3) us; rounded packet starts must not + // claim an already expired cue at a one-microsecond boundary. + constexpr uint32_t packet_us = 32u * 1000u / 3u; + if (static_cast(status.last_pcm_end_us - packet_us - cue.hd_expires_us) >= 0) { + // A late packet past the whole cue is not a cue dispatch. + cancel_native_cue_locked(cue); + continue; + } + // The first report only enables audio; only a subsequent successful + // PCM write can complete a cue. Timeline admission is not dispatch. + cue.result = 1; + cue.hd_pending = false; + } + } + state_lock_exit(); +} +#endif #endif // These fields are only read or written by the BTstack execution context. @@ -2806,6 +2928,10 @@ void process_configuration_timer(btstack_timer_source_t* timer) { void dispatch_rumble(uni_hid_device_t* device, uint16_t duration_ms, uint8_t weak, uint8_t strong) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (haptics_experiment_feedback(device, strong, weak, duration_ms) || + haptics_experiment_owns(device)) return; +#endif #if SWITCH2_BRIDGE_FULL_INPUT if (device->controller_type == CONTROLLER_TYPE_PS5Controller && device->report_parser.parse_input_report == uni_hid_parser_ds5_parse_input_report) { @@ -2818,11 +2944,6 @@ void dispatch_rumble(uni_hid_device_t* device, uint16_t duration_ms, #endif #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; - } #endif device->report_parser.play_dual_rumble(device, 0, duration_ms, weak, strong); } @@ -3000,7 +3121,11 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms, if (cue.in_flight) return true; if (cue.result != 0 && !cue.active) continue; command->token[side] = cue.token; - pending |= cue.result == 0; + pending |= cue.result == 0 +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + && !cue.hd_pending +#endif + ; if (cue.rumble_count != 0) { const uint32_t elapsed = now_ms - cue.requested_ms; const uint32_t phase_ms = kNativeGameplayFrameMs / cue.rumble_count; @@ -3069,10 +3194,16 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms, return true; } -bool submit_native_rumble(uni_hid_device_t* device, uint16_t duration, +bool submit_native_rumble(uni_hid_device_t* device, uint64_t received_us, uint16_t duration, uint8_t right, uint8_t left) { if (device == nullptr || device->report_parser.play_dual_rumble == nullptr) return false; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (haptics_experiment_native_feedback(device, received_us, left, right, duration)) return true; + if (native_haptics_selected(device) || haptics_experiment_owns(device)) return false; +#else + (void)received_us; +#endif if (device->controller_type == CONTROLLER_TYPE_PS5Controller && device->report_parser.parse_input_report == uni_hid_parser_ds5_parse_input_report) { return uni_hid_parser_ds5_bridge_rumble(device, duration, right, left); @@ -3090,6 +3221,13 @@ void dispatch_native_cues(const NativeGamepadCueDispatch& command) { BackendSlot& slot = g_slots[command.slot]; const bool paired = command.companion != nullptr; bool submitted[2]{}; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + const bool hd_overlay = !paired && haptics_experiment_gameplay_owns(command.device); + HapticsExperimentDiagnostics hd_status{}; + if (hd_overlay) haptics_experiment_snapshot(&hd_status); + uint32_t hd_submitted_us = 0; + uint32_t hd_expires_us = 0; +#endif uint32_t dispatch_ms = btstack_run_loop_get_time_ms(); for (uint8_t target = 0; target < (paired ? 2 : 1); ++target) { state_lock_enter(); @@ -3122,8 +3260,15 @@ void dispatch_native_cues(const NativeGamepadCueDispatch& command) { ? command.companion : command.device; const uint8_t right = command.magnitude[paired ? target : 0]; const uint8_t left = command.magnitude[paired ? target : 1]; + const uint64_t submitted_us = time_us_64(); + const uint16_t submitted_duration = (right | left) == 0 ? 0 : duration; if (!submit_native_rumble( - device, (right | left) == 0 ? 0 : duration, right, left)) continue; + device, submitted_us, submitted_duration, right, left)) continue; + #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + hd_submitted_us = static_cast(submitted_us); + hd_expires_us = hd_submitted_us + + (submitted_duration == 0 ? kNativeGamepadCueDeadlineMs : submitted_duration) * 1000u; + #endif __atomic_add_fetch(&g_rumble_dispatches, 1, __ATOMIC_RELAXED); if (paired) submitted[target] = true; else submitted[0] = submitted[1] = true; @@ -3157,6 +3302,16 @@ void dispatch_native_cues(const NativeGamepadCueDispatch& command) { cancel_native_cue_locked(cue); } else if (submitted[side] && cue.result == 0) { cue.result = 1; // Accepted source submission, never a native ACK. + #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (hd_overlay) { + cue.result = 0; + cue.hd_pending = true; + cue.hd_run_id = hd_status.run_id; + cue.hd_packet_baseline = hd_status.sent_packets; + cue.hd_submitted_us = hd_submitted_us; + cue.hd_expires_us = hd_expires_us; + } + #endif if (cue.rumble_count == 0) { cue.started_ms = command.prepared_ms; cue.active = cue.sample_id != 0; @@ -3303,8 +3458,14 @@ void process_rumble_timer(btstack_timer_source_t* timer) { const bool xinput_host_mode = host_rumble_duration_ms() == kXInputHostRumbleDurationMs; #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +#if SWITCH2_BRIDGE_FULL_INPUT + sync_native_haptics_attachments(); +#endif if (xinput_host_mode) seed_native_host_rumble(); haptics_experiment_poll(); + #if SWITCH2_BRIDGE_FULL_INPUT + complete_native_hd_cues(); + #endif #endif for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { @@ -3328,6 +3489,20 @@ void process_rumble_timer(btstack_timer_source_t* timer) { #endif #if SWITCH2_BRIDGE_FULL_INPUT NativeGamepadCueDispatch native_dispatch{}; +#endif +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + state_lock_enter(); + uni_hid_device_t* haptics_device = g_slots[slot_index].device; + const uint32_t haptics_generation = g_slots[slot_index].connection_generation; + state_lock_exit(); + const bool haptics_owned = haptics_experiment_owns(haptics_device); + const bool haptics_gameplay = haptics_experiment_gameplay_owns(haptics_device); +#if SWITCH2_BRIDGE_FULL_INPUT + const bool haptics_selected = haptics_experiment_native_selected( + slot_index, haptics_generation); +#else + (void)haptics_generation; +#endif #endif state_lock_enter(); @@ -3344,8 +3519,16 @@ void process_rumble_timer(btstack_timer_source_t* timer) { slot.retained_host_rumble = {}; } #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT - if (haptics_experiment_owns(slot.device) && - !haptics_experiment_gameplay_owns(slot.device)) { +#if SWITCH2_BRIDGE_FULL_INPUT + if (haptics_selected) { + // No stale compatibility gameplay may become an HD local overlay. + // Native samples enter the PCM mailbox directly, not this scheduler. + for (NativeGamepadCue& cue : g_native_cues) + if (cue.slot == slot_index && cue.rumble_count != 0) + cancel_native_cue_locked(cue); + } +#endif + if (haptics_owned && !haptics_gameplay) { // Fixture/startup/restoration exclusively own output. Preserve // stateful XInput requests until compatibility restoration ends. if (!xinput_host_mode) slot.rumble_pending = false; @@ -3478,8 +3661,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) { && !native_owns_rumble #endif #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT - && !(xinput_host_mode && - haptics_experiment_gameplay_owns(slot.device)) + && !(xinput_host_mode && haptics_gameplay) #endif ) { envelope = slot.pending_rumble; @@ -4118,7 +4300,11 @@ void platform_on_device_disconnected(uni_hid_device_t* device) { // DS4/PSMove and other finite-rumble drivers keep timers in parser_data. // Retire those timers before Bluepad32 reuses that memory. Call the real // driver directly: a feedback scheduler must not defer this local teardown. - if (device->report_parser.play_dual_rumble != nullptr) + if (device->report_parser.play_dual_rumble != nullptr +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + && !native_haptics_selected(device) && !haptics_experiment_owns(device) +#endif + ) device->report_parser.play_dual_rumble(device, 0, 0, 0, 0); #endif #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE @@ -4234,6 +4420,9 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { lighting_generation = current.connection_generation; connection_identity = current.identity; state_lock_exit(); +#if SWITCH2_BRIDGE_FULL_INPUT && defined(SWITCH_PICO_HAPTICS_EXPERIMENT) + sync_native_haptics_attachments(); +#endif if (became_active) { apply_radio_connection_policy(); #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE @@ -4243,10 +4432,12 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { } #endif #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +#if !SWITCH2_BRIDGE_FULL_INPUT if (!paired && !uni_hid_parser_switch2_is_ble_device(device)) { haptics_experiment_attach( static_cast(slot_index), lighting_generation, device); } +#endif #ifdef SWITCH_PICO_HD_RUMBLE if (connection_identity.vendor_id == 0x054c && (connection_identity.product_id == 0x0ce6 || @@ -4906,6 +5097,9 @@ void bluepad32_input_backend_select_native_source( binding.reservation = {}; refresh_native_source_locked(pair_index); state_lock_exit(); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + flush_native_haptics_cancellations(); +#endif } void bluepad32_input_backend_native_snapshot( @@ -4919,12 +5113,18 @@ void bluepad32_input_backend_native_snapshot( } bool bluepad32_input_backend_native_rumble_submit( - uint8_t instance, const uint8_t* magnitudes, uint8_t count) { - if (!g_initialized || instance >= kNativeChildCount || magnitudes == nullptr || - count == 0 || count > 3) return false; + uint8_t instance, const NativeHapticsActuatorFrame* frame) { + if (!g_initialized || instance >= kNativeChildCount || frame == nullptr || + frame->sample_count == 0 || frame->sample_count > 3) return false; + NativeHapticsActuatorFrame samples = *frame; + for (uint8_t sample = 0; sample < samples.sample_count; ++sample) { + const NativeHapticsSample& value = samples.samples[sample]; + if (value.low_amplitude > 1023 || value.high_amplitude > 1023 || + value.low_frequency_code > 1023 || value.high_frequency_code > 1023) + return false; + } + const uint64_t received_us = time_us_64(); const uint32_t received_ms = btstack_run_loop_get_time_ms(); - uint8_t samples[3]{}; - memcpy(samples, magnitudes, count); state_lock_enter(); const NativeGamepadBinding& binding = g_native_bindings[instance / 2]; const uint8_t index = binding.slot; @@ -4941,20 +5141,49 @@ bool bluepad32_input_backend_native_rumble_submit( slot.pre_hotkey_button_mask, slot.state, slot.accelerometer, slot.nunchuk_accelerometer}; const uint32_t revision = g_native_output_revision[instance]; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + const uint32_t attached_generation = g_native_haptics[index].generation; +#endif state_lock_exit(); - // Profile resolution may call services: never hold the backend lock over - // it. Full-scale input obtains the existing weak/strong gain once per block. + // A logical source epoch can move on Core 0 before its BT-core attachment + // refresh. Preserve HD ownership across that interval; reject, never fall + // back to a compatibility report while the selected attachment catches up. +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + const bool hd_selected = haptics_experiment_native_selected(index, attached_generation) || + haptics_experiment_native_selected(index, snapshot.connection_generation); + if (hd_selected && attached_generation != snapshot.connection_generation) return false; +#endif const ControllerRumbleOutput gains = controller_profile_runtime_scale_host_rumble( index, snapshot, ControllerRumbleOutput{UINT8_MAX, UINT8_MAX}); + uint8_t magnitudes[3]{}; const uint8_t gain = (instance & 1u) == 0 ? gains.high_frequency_magnitude : gains.low_frequency_magnitude; - for (uint8_t sample = 0; sample < count; ++sample) - samples[sample] = controller_profile_scale_rumble_magnitude(samples[sample], gain); + for (uint8_t sample = 0; sample < samples.sample_count; ++sample) { + NativeHapticsSample& value = samples.samples[sample]; + #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (hd_selected) { + if ((value.low_amplitude != 0 && + (value.low_frequency_code == 0 || value.low_frequency_code > 670)) || + (value.high_amplitude != 0 && + (value.high_frequency_code == 0 || value.high_frequency_code > 670))) + return false; + value.low_amplitude = static_cast( + (uint32_t{value.low_amplitude} * gains.low_frequency_magnitude + 127u) / 255u); + value.high_amplitude = static_cast( + (uint32_t{value.high_amplitude} * gains.high_frequency_magnitude + 127u) / 255u); + continue; + } + #endif + const uint16_t peak = value.low_amplitude > value.high_amplitude + ? value.low_amplitude : value.high_amplitude; + magnitudes[sample] = controller_profile_scale_rumble_magnitude( + static_cast((uint32_t{peak} * 255u + 511u) / 1023u), gain); + } state_lock_enter(); const uint32_t now_ms = btstack_run_loop_get_time_ms(); - const bool accepted = binding.slot == index && slot.active && + bool accepted = binding.slot == index && slot.active && binding.generation == snapshot.connection_generation && slot.connection_generation == snapshot.connection_generation && g_native_output_revision[instance] == revision && g_next_native_token != 0 && @@ -4965,17 +5194,35 @@ bool bluepad32_input_backend_native_rumble_submit( ++g_native_output_revision[instance]; NativeGamepadCue& cue = g_native_cues[instance]; cue = {}; - cue.token = g_next_native_token++; - cue.slot = index; - cue.connection_generation = snapshot.connection_generation; - cue.requested_ms = received_ms; - cue.rumble_count = count; - memcpy(cue.rumble_magnitude, samples, count); - cue.result = 0; - cue.active = true; - __atomic_add_fetch(&g_host_rumble_requests, 1, __ATOMIC_RELAXED); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (!hd_selected) +#endif + { + cue.token = g_next_native_token++; + cue.slot = index; + cue.connection_generation = snapshot.connection_generation; + cue.requested_ms = received_ms; + cue.rumble_count = samples.sample_count; + memcpy(cue.rumble_magnitude, magnitudes, samples.sample_count); + cue.result = 0; + cue.active = true; + } + #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (hd_selected) { + NativeHapticsFrame stereo{}; + stereo.actuators[(instance & 1u) == 0 ? 1 : 0] = samples; + // Commit this bounded, callback-free mailbox copy under the same + // state lock as the revision check. Cancellation cannot cross it. + accepted = haptics_experiment_submit_native( + index, snapshot.connection_generation, received_us, stereo); + } + #endif } state_lock_exit(); +#ifndef SWITCH_PICO_HAPTICS_EXPERIMENT + (void)received_us; +#endif + if (accepted) __atomic_add_fetch(&g_host_rumble_requests, 1, __ATOMIC_RELAXED); return accepted; } @@ -4984,7 +5231,16 @@ void bluepad32_input_backend_native_rumble_cancel(uint8_t instance) { state_lock_enter(); NativeGamepadCue& cue = g_native_cues[instance]; if (cue.rumble_count != 0) cancel_native_cue_locked(cue); + ++g_native_output_revision[instance]; + #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + const uint8_t index = g_native_bindings[instance / 2].slot; + if (index < kSlotCount) + g_native_haptics[index].cancel_mask |= (instance & 1u) == 0 ? 2 : 1; + #endif state_lock_exit(); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + flush_native_haptics_cancellations(); +#endif } bool bluepad32_input_backend_native_sample_request( @@ -5001,6 +5257,9 @@ bool bluepad32_input_backend_native_sample_request( native_rumble_capable(g_slots[index], instance & 1u); if (accepted) { ++g_native_output_revision[instance]; + #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + g_native_haptics[index].cancel_mask |= (instance & 1u) == 0 ? 2 : 1; + #endif cue = {}; cue.token = g_next_native_token++; cue.slot = index; @@ -5011,6 +5270,9 @@ bool bluepad32_input_backend_native_sample_request( *token = cue.token; } state_lock_exit(); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + flush_native_haptics_cancellations(); +#endif return accepted; } diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index 3557fb0..aeb2234 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -4,6 +4,7 @@ #include "core/controller_color.h" #include "core/controller_identity.h" +#include "core/native_haptics.h" #include "profile/controller_profile.h" #include "core/controller_state.h" #include "input/controller_macro_capture.h" @@ -155,13 +156,15 @@ bool bluepad32_input_backend_native_sample_request( int bluepad32_input_backend_native_sample_result(uint8_t instance, uint64_t token); void bluepad32_input_backend_native_sample_cancel(uint8_t instance); // Gameplay blocks replace older gameplay/cues only on the addressed side. -// Samples divide a 12 ms frame; the last holds up to a 50 ms receipt watchdog. -// Magnitudes use that source profile's weak/right or strong/left host gain. +// Conventional samples divide 12 ms; PCM retains native ~5.333 ms/sample. +// The last host sample holds only to its original 50 ms receipt watchdog. +// HD preserves both frequency bands and applies their strong/low and weak/high +// profile gains. Conventional motors retain the per-child peak approximation. // Submission copies 1..3 samples; false means invalid/unbound/unsupported, -// higher-priority local feedback, or a source change during profile resolution. +// higher-priority feedback, a retired source, or a selected HD stream not accepting. // Cancellation retires gameplay only; callers resetting a child also cancel its cue. bool bluepad32_input_backend_native_rumble_submit( - uint8_t instance, const uint8_t* magnitudes, uint8_t count); + uint8_t instance, const NativeHapticsActuatorFrame* frame); void bluepad32_input_backend_native_rumble_cancel(uint8_t instance); #endif diff --git a/src/firmware/input/haptics_experiment.cpp b/src/firmware/input/haptics_experiment.cpp index e03458c..7d8c0a0 100644 --- a/src/firmware/input/haptics_experiment.cpp +++ b/src/firmware/input/haptics_experiment.cpp @@ -19,7 +19,7 @@ namespace { constexpr uint32_t kPacketFrames = SWITCH_PICO_HD_PACKET_FRAMES; -static_assert(kPacketFrames == 32 || kPacketFrames == 64); +static_assert(kPacketFrames == 32, "HD output requires the qualified 32-frame packet format"); constexpr uint32_t kPacketDenominator = 3; constexpr uint32_t kPackets = 18432 / kPacketFrames; constexpr uint32_t kPrimingPackets = 3072 / kPacketFrames; @@ -60,12 +60,19 @@ struct Command { uint8_t mode = 0; }; +enum class HostKind : uint8_t { kSwitch, kNative, kRumble, kFeedback }; + struct HostUpdate { uint64_t received_us = 0; - SwitchHapticsFrame frame{}; - bool rumble = false; + union { + SwitchHapticsFrame frame; + NativeHapticsFrame native_frame; + }; + HostKind kind = HostKind::kSwitch; + uint32_t duration_us = 0; uint8_t low = 0; uint8_t high = 0; + HostUpdate() : frame{} {} }; constexpr uint8_t kHostQueueCapacity = 16; @@ -84,6 +91,9 @@ uint8_t g_host_head = 0; uint8_t g_host_count = 0; uint32_t g_host_updates = 0; uint32_t g_host_drops = 0; +uint8_t g_native_cancel = 0; +bool g_have_host_timestamp = false; +uint64_t g_last_host_us = 0; // Written on core 1 under the lock; core 0 only reads identities for requests. Attachment g_attachments[4]; @@ -135,34 +145,53 @@ uint32_t packet_numerator_us() { void drain_host_updates() { // Bound work even if USB keeps publishing while the BT core drains. - for (uint8_t index = 0; index < kHostQueueCapacity; ++index) { + for (uint8_t index = 0; index <= kHostQueueCapacity; ++index) { HostUpdate update; critical_section_enter_blocking(&g_lock); - if (g_host_count == 0) { - critical_section_exit(&g_lock); - break; + const uint8_t cancel = g_native_cancel; + g_native_cancel = 0; + const bool available = g_host_count != 0 && index < kHostQueueCapacity; + if (available) { + update = g_host_queue[g_host_head]; + g_host_head = (g_host_head + 1) % kHostQueueCapacity; + --g_host_count; } - update = g_host_queue[g_host_head]; - g_host_head = (g_host_head + 1) % kHostQueueCapacity; - --g_host_count; critical_section_exit(&g_lock); - if (update.rumble) { - g_synth.push_rumble(update.low, update.high, update.received_us); - } else { - g_synth.push(update.frame, update.received_us); + if (cancel) g_synth.cancel_native(cancel); + if (!available) break; + switch (update.kind) { + case HostKind::kRumble: + g_synth.push_rumble(update.low, update.high, update.received_us); + break; + case HostKind::kNative: + g_synth.push_native(update.native_frame, update.received_us); + break; + case HostKind::kFeedback: + g_synth.feedback(update.received_us, update.duration_us, update.low, update.high); + break; + case HostKind::kSwitch: + g_synth.push(update.frame, update.received_us); + break; } } } +bool selected_locked(uint8_t slot, uint32_t generation) { + return g_busy && g_snapshot.slot == slot && + g_snapshot.connection_generation == generation && + g_attachments[slot].device != nullptr && + g_attachments[slot].generation == generation; +} + bool submit_host_update(uint8_t slot, uint32_t generation, const HostUpdate& update) { if (!g_prepared || slot >= 4) return false; critical_section_enter_blocking(&g_lock); - const bool accepted = g_accept_host && g_busy && g_snapshot.mode == 1 && - g_snapshot.slot == slot && - g_snapshot.connection_generation == generation && - g_attachments[slot].device != nullptr && - g_attachments[slot].generation == generation; + const bool host = update.kind != HostKind::kFeedback; + const bool ordered = !host || !g_have_host_timestamp || + update.received_us - g_last_host_us <= INT64_MAX; + const bool accepted = g_accept_host && g_snapshot.mode == 1 && + selected_locked(slot, generation) && ordered; if (accepted) { if (g_host_count == kHostQueueCapacity) { g_host_head = (g_host_head + 1) % kHostQueueCapacity; @@ -172,7 +201,11 @@ bool submit_host_update(uint8_t slot, uint32_t generation, const uint8_t index = (g_host_head + g_host_count) % kHostQueueCapacity; g_host_queue[index] = update; ++g_host_count; - if (g_host_updates != UINT32_MAX) ++g_host_updates; + if (host) { + g_have_host_timestamp = true; + g_last_host_us = update.received_us; + if (g_host_updates != UINT32_MAX) ++g_host_updates; + } } critical_section_exit(&g_lock); return accepted; @@ -454,7 +487,7 @@ bool HAPTICS_HOT(generate_packet)(uint8_t* report, uint32_t packet, report[5] = 0x80; // AllowAudioControl; default route/MicSelect. sample_offset = 68; frames = 0; // State-only setup, not an audio sample interval. - } else if (kPacketFrames == 32) { + } else { // Preserve the physically qualified full-control, single-block format. frames = 32; report[3] = 0x91; @@ -465,23 +498,12 @@ bool HAPTICS_HOT(generate_packet)(uint8_t* report, uint32_t packet, report[12] = 0x92; report[13] = 64; sample_offset = 14; - } else { - // Retained only as an explicit, physically unqualified experiment. - frames = 64; - report[3] = 0x91; - report[4] = 3; - report[5] = 0x62; - report[6] = 16; - report[7] = static_cast(g_diagnostics.sent_packets * (frames / 32)); - report[8] = 0xd2; - report[9] = 64; - sample_offset = 10; } bool tone = false; if (gameplay() && !silence) { drain_host_updates(); - // One report of causal lookback preserves every 8 ms USB command, - // including substeps arriving between Bluetooth sends. + // One report of causal lookback retains fixed 16-frame native samples + // and legacy 8 ms substeps, without a PCM backlog. if (packet != 0 && g_diagnostics.sent_packets != 0) { const uint64_t first_sample = uint64_t{packet - 1} * frames; g_synth.render(first_sample, frames, report + sample_offset); @@ -723,6 +745,8 @@ bool haptics_experiment_request(uint8_t action, uint8_t slot) { g_accept_host = action == 2; g_host_head = 0; g_host_count = 0; + g_native_cancel = 0; + g_have_host_timestamp = false; g_host_updates = 0; g_host_drops = 0; g_snapshot_waiting = false; @@ -747,17 +771,81 @@ bool haptics_experiment_request(uint8_t action, uint8_t slot) { bool haptics_experiment_submit(uint8_t slot, uint32_t generation, uint64_t received_us, const SwitchHapticsFrame& frame) { - if (frame.actuators[0].sample_count > 3 || frame.actuators[1].sample_count > 3 || - (frame.actuators[0].sample_count == 0 && frame.actuators[1].sample_count == 0)) { + if (frame.actuators[0].sample_count == 0 && frame.actuators[1].sample_count == 0) { return false; } - return submit_host_update(slot, generation, {received_us, frame}); + for (const auto& actuator : frame.actuators) { + if (actuator.sample_count > 3) return false; + for (unsigned index = 0; index < actuator.sample_count; ++index) { + const auto& sample = actuator.samples[index]; + if (sample.low_frequency_index > 127 || sample.high_frequency_index > 127 || + sample.low_amplitude_q15 > 32768 || sample.high_amplitude_q15 > 32768) { + return false; + } + } + } + HostUpdate update; + update.received_us = received_us; + update.frame = frame; + return submit_host_update(slot, generation, update); +} + +bool haptics_experiment_submit_native(uint8_t slot, uint32_t generation, + uint64_t received_us, + const NativeHapticsFrame& frame) { + if (!SwitchHdRumbleSynth::valid_native(frame) || + (!frame.actuators[0].sample_count && !frame.actuators[1].sample_count)) { + return false; + } + HostUpdate update; + update.received_us = received_us; + update.kind = HostKind::kNative; + update.native_frame = frame; + return submit_host_update(slot, generation, update); +} + +bool haptics_experiment_native_selected(uint8_t slot, uint32_t generation) { + if (!g_prepared || slot >= 4) return false; + critical_section_enter_blocking(&g_lock); + const bool selected = selected_locked(slot, generation); + critical_section_exit(&g_lock); + return selected; +} + +void haptics_experiment_cancel_native(uint8_t slot, uint32_t generation, + uint8_t side_mask) { + side_mask &= 3; + if (!g_prepared || slot >= 4 || !side_mask) return; + critical_section_enter_blocking(&g_lock); + if (selected_locked(slot, generation)) { + g_native_cancel |= side_mask; + uint8_t kept = 0; + for (uint8_t index = 0; index < g_host_count; ++index) { + HostUpdate& update = g_host_queue[(g_host_head + index) % kHostQueueCapacity]; + if (update.kind == HostKind::kNative) { + for (unsigned side = 0; side < 2; ++side) { + if (side_mask & (1u << side)) update.native_frame.actuators[side].sample_count = 0; + } + if (!update.native_frame.actuators[0].sample_count && + !update.native_frame.actuators[1].sample_count) continue; + } + if (kept != index) g_host_queue[(g_host_head + kept) % kHostQueueCapacity] = update; + ++kept; + } + g_host_count = kept; + } + critical_section_exit(&g_lock); } bool haptics_experiment_submit_rumble(uint8_t slot, uint32_t generation, uint64_t received_us, uint8_t low, uint8_t high) { - return submit_host_update(slot, generation, {received_us, {}, true, low, high}); + HostUpdate update; + update.received_us = received_us; + update.kind = HostKind::kRumble; + update.low = low; + update.high = high; + return submit_host_update(slot, generation, update); } void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output) { @@ -785,14 +873,35 @@ void haptics_experiment_attach(uint8_t slot, uint32_t generation, return; } const uint16_t cid = device->conn.interrupt_cid; - if (g_phase != Phase::kIdle && - (g_diagnostics.slot == slot || g_connection.device == device) && - (g_diagnostics.slot != slot || g_connection.device != device || - g_connection.generation != generation || g_connection.cid != cid)) { + const bool migrate_live = g_phase != Phase::kIdle && gameplay() && + g_diagnostics.slot == slot && + g_connection.device == device && g_connection.cid == cid && + g_connection.generation != generation; + if (migrate_live) { + // A source epoch is not a Bluetooth reconnect. Cancel only admission + // tied to the retired generation; keep oscillator/other-side state. + native_output_scheduler_cancel(device); + g_send_requested = false; + g_connection.generation = generation; + g_diagnostics.connection_generation = generation; + } else if (g_phase != Phase::kIdle && + (g_diagnostics.slot == slot || g_connection.device == device) && + (g_diagnostics.slot != slot || g_connection.device != device || + g_connection.generation != generation || g_connection.cid != cid)) { finish(HapticsExperimentState::kDisconnected, kConnection); } // A reused instance must not remain selectable through an old slot. critical_section_enter_blocking(&g_lock); + const bool migrate_pending = g_busy && g_snapshot.mode == 1 && + g_snapshot.slot == slot && + g_attachments[slot].device == device && + g_attachments[slot].cid == cid; + if (migrate_pending || migrate_live) { + g_snapshot.connection_generation = generation; + if (g_command.pending && g_command.slot == slot) { + g_command.connection.generation = generation; + } + } for (Attachment& attached : g_attachments) { if (attached.device == device) { attached = {}; @@ -800,6 +909,9 @@ void haptics_experiment_attach(uint8_t slot, uint32_t generation, } g_attachments[slot] = {device, generation, cid}; critical_section_exit(&g_lock); + if (migrate_live && (g_phase == Phase::kPattern || g_phase == Phase::kDrain)) { + request_send(); + } } void haptics_experiment_detach(uni_hid_device_t* device) { @@ -872,9 +984,31 @@ bool haptics_experiment_gameplay_owns(const uni_hid_device_t* device) { bool haptics_experiment_feedback(uni_hid_device_t* device, uint8_t low, uint8_t high, uint16_t duration_ms) { + if (device == nullptr) return false; + critical_section_enter_blocking(&g_lock); + const uint8_t slot = g_snapshot.slot; + const uint32_t generation = g_snapshot.connection_generation; + const bool selected = slot < 4 && selected_locked(slot, generation) && + g_attachments[slot].device == device; + critical_section_exit(&g_lock); + if (!selected) return false; + HostUpdate update; + update.received_us = time_us_64(); + update.kind = HostKind::kFeedback; + update.duration_us = uint32_t{duration_ms} * 1000; + update.low = low; + update.high = high; + // Pending gameplay cues retain their original lifetime. Fixture, drain, + // and restoration are exclusive: consume the cue without compatibility. + submit_host_update(slot, generation, update); + return true; +} + +bool haptics_experiment_native_feedback(uni_hid_device_t* device, uint64_t received_us, + uint8_t left, uint8_t right, uint16_t duration_ms) { if (!haptics_experiment_gameplay_owns(device)) return false; drain_host_updates(); - g_synth.feedback(time_us_64(), uint32_t{duration_ms} * 1000, low, high); + g_synth.feedback_native(received_us, uint32_t{duration_ms} * 1000, left, right); return true; } @@ -1011,6 +1145,11 @@ bool HAPTICS_HOT(on_output_grant)(uni_hid_device_t* device, uint16_t cid, uint32 update_max(&g_diagnostics.max_send_gap_us, static_cast(submit_us) - g_diagnostics.last_sent_us); } + if (gameplay() && !stopping && g_diagnostics.sent_packets != 0 && g_next_packet != 0) { + // Packet n renders [(n-1)*32, n*32). A causal-lookback packet sent + // after cue admission may still precede the cue on this timeline. + g_diagnostics.last_pcm_end_us = static_cast(packet_due(g_next_packet)); + } ++g_diagnostics.sent_packets; g_diagnostics.last_sent_us = static_cast(submit_us); g_last_was_silence = !tone; diff --git a/src/firmware/input/haptics_experiment.h b/src/firmware/input/haptics_experiment.h index e954d02..2d36944 100644 --- a/src/firmware/input/haptics_experiment.h +++ b/src/firmware/input/haptics_experiment.h @@ -2,6 +2,8 @@ #include +#include "core/native_haptics.h" + #ifndef SWITCH_PICO_HD_PACKET_FRAMES #define SWITCH_PICO_HD_PACKET_FRAMES 32 #endif @@ -38,6 +40,9 @@ struct HapticsExperimentDiagnostics { uint32_t first_tone_due_us = 0; uint32_t first_tone_sent_us = 0; uint32_t last_sent_us = 0; + // Absolute (wrapping uint32) timeline end of the last successful gameplay + // PCM block, not send wall time. Setup/failed-send/drain never advance it. + uint32_t last_pcm_end_us = 0; uint32_t elapsed_us = 0; HapticsExperimentState state = HapticsExperimentState::kIdle; uint8_t slot = 0xff; @@ -57,12 +62,29 @@ void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output); bool haptics_experiment_submit(uint8_t slot, uint32_t generation, uint64_t received_us, const SwitchHapticsFrame& frame); +// Full-precision native frames. Validation and generation/ownership checks are +// atomic with mailbox admission; false never means permission to use rumble. +// This and cancel_native only touch a bounded mailbox: no driver, timer or +// backend callbacks. Backend state may enclose them (backend -> haptics lock); +// the haptics lock must never acquire backend state. +bool haptics_experiment_submit_native(uint8_t slot, uint32_t generation, + uint64_t received_us, + const NativeHapticsFrame& frame); +// Core 0 safe, including Pending/Drain/Restore and fixture exclusivity. +bool haptics_experiment_native_selected(uint8_t slot, uint32_t generation); +// Bits 0/1 select left/right. Erase pending native work immediately; the BT +// core consumes the silence fence before its next generated PCM block. Already +// submitted packets cannot be recalled. Feedback and the other side survive. +void haptics_experiment_cancel_native(uint8_t slot, uint32_t generation, + uint8_t side_mask); // Stateful, already profile-scaled XInput strengths, including explicit zero. bool haptics_experiment_submit_rumble(uint8_t slot, uint32_t generation, uint64_t received_us, uint8_t low, uint8_t high); // Core 1 / BTstack only. Poll consumes management requests, not PCM cadence. +// Reattaching the same slot/device/CID migrates a logical source generation +// without restarting PCM; cancel retired native sides before that migration. void haptics_experiment_attach(uint8_t slot, uint32_t generation, uni_hid_device_t* device); void haptics_experiment_detach(uni_hid_device_t* device); @@ -71,5 +93,9 @@ bool haptics_experiment_owns(const uni_hid_device_t* device); bool haptics_experiment_gameplay_owns(const uni_hid_device_t* device); bool haptics_experiment_feedback(uni_hid_device_t* device, uint8_t low, uint8_t high, uint16_t duration_ms); +// BT-context side-isolated cue. Preserve its receipt timestamp and reduced +// duration for PCM coverage accounting; admission alone is not delivery. +bool haptics_experiment_native_feedback(uni_hid_device_t* device, uint64_t received_us, + uint8_t left, uint8_t right, uint16_t duration_ms); // Preserve fixture/drain exclusivity on unsolicited or control-CID events. bool haptics_experiment_blocks_generic(const uni_hid_device_t* device); diff --git a/src/firmware/input/switch_hd_rumble_synth.cpp b/src/firmware/input/switch_hd_rumble_synth.cpp index 0cb6ef0..db5c63c 100644 --- a/src/firmware/input/switch_hd_rumble_synth.cpp +++ b/src/firmware/input/switch_hd_rumble_synth.cpp @@ -7,8 +7,7 @@ namespace { -constexpr int64_t kWatchdogSamples = 150; // 50 ms at 3 kHz. -constexpr unsigned kWindowSamples = 24; // 8 ms, independently split per side. +constexpr int64_t kWatchdogSamples = NATIVE_HAPTICS_WATCHDOG_US * 3 / 1000; // round(40 * 2^(index/32) * 2^32 / 3000), index 0..159. High-band // indices address the same logarithmic table with an offset of 32 (80 Hz base). @@ -35,6 +34,31 @@ constexpr uint32_t kPhaseIncrement[160] = { 1540958977u, 1574701585u, 1609183060u, 1644419580u, 1680427680u, 1717224255u, 1754826570u, 1793252268u, }; +// One 1/96-octave table at 10 Hz, with seven extra fractional bits. Shifting +// octaves before rounding retains the native wire precision without floating +// point, a 670-entry table, or any conversion in the per-sample oscillator. +constexpr uint32_t kNativeIncrementQ7[96] = { + 1832519380u, 1845798570u, 1859173988u, 1872646329u, 1886216296u, 1899884597u, 1913651944u, 1927519055u, + 1941486652u, 1955555465u, 1969726226u, 1983999674u, 1998376554u, 2012857614u, 2027443610u, 2042135302u, + 2056933456u, 2071838844u, 2086852242u, 2101974434u, 2117206207u, 2132548356u, 2148001681u, 2163566986u, + 2179245085u, 2195036793u, 2210942934u, 2226964338u, 2243101840u, 2259356280u, 2275728507u, 2292219374u, + 2308829741u, 2325560473u, 2342412443u, 2359386529u, 2376483616u, 2393704596u, 2411050366u, 2428521831u, + 2446119901u, 2463845495u, 2481699535u, 2499682953u, 2517796686u, 2536041678u, 2554418882u, 2572929254u, + 2591573760u, 2610353372u, 2629269068u, 2648321836u, 2667512668u, 2686842564u, 2706312533u, 2725923589u, + 2745676755u, 2765573061u, 2785613543u, 2805799247u, 2826131225u, 2846610537u, 2867238250u, 2888015441u, + 2908943191u, 2930022592u, 2951254744u, 2972640752u, 2994181733u, 3015878808u, 3037733109u, 3059745775u, + 3081917954u, 3104250802u, 3126745483u, 3149403170u, 3172225044u, 3195212294u, 3218366119u, 3241687727u, + 3265178333u, 3288839161u, 3312671445u, 3336676428u, 3360855361u, 3385209504u, 3409740128u, 3434448510u, + 3459335940u, 3484403714u, 3509653140u, 3535085533u, 3560702220u, 3586504536u, 3612493827u, 3638671446u, +}; + +uint32_t native_increment(uint16_t code) { + if (code == 0 || code > 670) return 0; // Silent, unmeasured band. + const unsigned index = code - 1; + return static_cast( + ((uint64_t{kNativeIncrementQ7[index % 96]} << (index / 96)) + 64) >> 7); +} + // round(127 * 256 * sin(2*pi*index/256)). Linear interpolation retains // sub-byte precision until the final two-band mix; opposite phases are exact // negatives, including rounding, so quantization cannot introduce a DC bias. @@ -124,6 +148,7 @@ void SwitchHdRumbleSynth::reset(uint64_t epoch_us) { } feedback_expires_ = 0; feedback_low_ = feedback_high_ = 0; + separate_feedback_ = false; } void SwitchHdRumbleSynth::count_drop() { @@ -180,12 +205,98 @@ bool SwitchHdRumbleSynth::push(const SwitchHapticsFrame& frame, have_host_ = true; last_host_us_ = received_us; if (has_update) { - command.frame = frame; + for (unsigned side = 0; side < 2; ++side) { + const auto& source = frame.actuators[side]; + auto& target = command.actuators[side]; + target.sample_count = source.sample_count; + for (unsigned index = 0; index < source.sample_count; ++index) { + const auto& sample = source.samples[index]; + target.samples[index] = { + kPhaseIncrement[sample.low_frequency_index], + kPhaseIncrement[sample.high_frequency_index + 32], + sample.low_amplitude_q15, sample.high_amplitude_q15}; + } + } enqueue(command); } return true; } +bool SwitchHdRumbleSynth::valid_native(const NativeHapticsFrame& frame) { + for (const auto& actuator : frame.actuators) { + if (actuator.sample_count > 3) return false; + for (unsigned index = 0; index < actuator.sample_count; ++index) { + const auto& sample = actuator.samples[index]; + if (sample.low_amplitude > 1023 || sample.high_amplitude > 1023 || + sample.low_frequency_code > 1023 || sample.high_frequency_code > 1023 || + (sample.low_amplitude && + (sample.low_frequency_code == 0 || sample.low_frequency_code > 670)) || + (sample.high_amplitude && + (sample.high_frequency_code == 0 || sample.high_frequency_code > 670))) { + return false; + } + } + } + return true; +} + +bool SwitchHdRumbleSynth::push_native(const NativeHapticsFrame& frame, + uint64_t received_us) { + Command command; + if (!valid_native(frame) || + (have_host_ && older(received_us, last_host_us_)) || + !timestamp_sample(received_us, command.sample) || + command.sample + kWatchdogSamples <= 0) { + count_drop(); + return false; + } + bool has_update = false; + command.native = true; + for (unsigned side = 0; side < 2; ++side) { + const auto& source = frame.actuators[side]; + auto& target = command.actuators[side]; + has_update |= source.sample_count != 0; + target.sample_count = source.sample_count; + for (unsigned index = 0; index < source.sample_count; ++index) { + const auto& sample = source.samples[index]; + target.samples[index] = { + native_increment(sample.low_frequency_code), + native_increment(sample.high_frequency_code), + static_cast((uint32_t{sample.low_amplitude} * 32768 + 511) / 1023), + static_cast((uint32_t{sample.high_amplitude} * 32768 + 511) / 1023)}; + } + } + have_host_ = true; + last_host_us_ = received_us; + if (has_update) enqueue(command); + return true; +} + +void SwitchHdRumbleSynth::cancel_native(uint8_t side_mask) { + unsigned kept = 0; + for (unsigned index = 0; index < count_; ++index) { + Command& command = commands_[(head_ + index) % kCapacity]; + if (command.native) { + for (unsigned side = 0; side < 2; ++side) { + if (side_mask & (1u << side)) command.actuators[side].sample_count = 0; + } + if (!command.actuators[0].sample_count && !command.actuators[1].sample_count) continue; + } + if (kept != index) commands_[(head_ + kept) % kCapacity] = command; + ++kept; + } + count_ = static_cast(kept); + for (unsigned side = 0; side < 2; ++side) { + if ((side_mask & (1u << side)) && sides_[side].native) { + // Keep oscillator phase and frequency, but not a future substep. + const Sample current = host_sample(side); + sides_[side].frame = {1, {current}}; + sides_[side].frame.samples[0].low_amplitude_q15 = 0; + sides_[side].frame.samples[0].high_amplitude_q15 = 0; + } + } +} + bool SwitchHdRumbleSynth::push_rumble(uint8_t low_magnitude, uint8_t high_magnitude, uint64_t received_us) { @@ -198,19 +309,29 @@ bool SwitchHdRumbleSynth::push_rumble(uint8_t low_magnitude, have_host_ = true; last_host_us_ = received_us; command.persistent = true; - command.frame.actuators[0].sample_count = 1; - command.frame.actuators[1].sample_count = 1; - command.frame.actuators[0].samples[0].low_amplitude_q15 = + command.actuators[0].sample_count = 1; + command.actuators[1].sample_count = 1; + command.actuators[0].samples[0].low_amplitude_q15 = (static_cast(low_magnitude) * 32768u + 127u) / 255u; - command.frame.actuators[1].samples[0].high_amplitude_q15 = + command.actuators[1].samples[0].high_amplitude_q15 = (static_cast(high_magnitude) * 32768u + 127u) / 255u; enqueue(command); return true; } void SwitchHdRumbleSynth::feedback(uint64_t at_us, uint32_t duration_us, - uint8_t low_magnitude, - uint8_t high_magnitude) { + uint8_t low_magnitude, uint8_t high_magnitude) { + queue_feedback(at_us, duration_us, low_magnitude, high_magnitude, false); +} + +void SwitchHdRumbleSynth::feedback_native(uint64_t at_us, uint32_t duration_us, + uint8_t left, uint8_t right) { + queue_feedback(at_us, duration_us, left, right, true); +} + +void SwitchHdRumbleSynth::queue_feedback(uint64_t at_us, uint32_t duration_us, + uint8_t low_magnitude, + uint8_t high_magnitude, bool separate) { Command command; if ((have_feedback_ && older(at_us, last_feedback_us_)) || !timestamp_sample(at_us, command.sample) || @@ -221,6 +342,7 @@ void SwitchHdRumbleSynth::feedback(uint64_t at_us, uint32_t duration_us, have_feedback_ = true; last_feedback_us_ = at_us; command.is_feedback = true; + command.separate_feedback = separate; command.low = (static_cast(low_magnitude) * 32768u + 127u) / 255u; command.high = (static_cast(high_magnitude) * 32768u + 127u) / 255u; enqueue(command); @@ -256,11 +378,15 @@ void SwitchHdRumbleSynth::apply(const Command& command) { feedback_expires_ = command.expires; feedback_low_ = command.low; feedback_high_ = command.high; + separate_feedback_ = command.separate_feedback; return; } for (unsigned side = 0; side < 2; ++side) { - if (command.frame.actuators[side].sample_count) { - sides_[side].frame = command.frame.actuators[side]; + if (command.actuators[side].sample_count) { + sides_[side].frame = command.actuators[side]; + sides_[side].sample_spacing = command.native + ? NATIVE_HAPTICS_SAMPLE_PCM_FRAMES : 24 / command.actuators[side].sample_count; + sides_[side].native = command.native; sides_[side].start = command.sample; sides_[side].expires = command.sample + kWatchdogSamples; sides_[side].persistent = command.persistent; @@ -279,9 +405,9 @@ void SwitchHdRumbleSynth::apply_due(bool discarded) { } } -const SwitchHapticsSample& SwitchHdRumbleSynth::host_sample(unsigned side) const { +const SwitchHdRumbleSynth::Sample& SwitchHdRumbleSynth::host_sample(unsigned side) const { const Side& state = sides_[side]; - const unsigned spacing = kWindowSamples / state.frame.sample_count; + const unsigned spacing = state.sample_spacing; unsigned index = 0; while (index + 1 < state.frame.sample_count && due(state.start + (index + 1) * spacing, cursor_)) { @@ -300,7 +426,7 @@ uint64_t SwitchHdRumbleSynth::next_boundary(uint64_t limit) const { consider(commands_[head_].sample); } for (const Side& side : sides_) { - const unsigned spacing = kWindowSamples / side.frame.sample_count; + const unsigned spacing = side.sample_spacing; for (unsigned index = 1; index < side.frame.sample_count; ++index) { consider(side.start + index * spacing); } @@ -315,8 +441,8 @@ void SwitchHdRumbleSynth::advance_phases(uint64_t samples) { const uint32_t count = static_cast(samples); for (unsigned side = 0; side < 2; ++side) { const auto& sample = host_sample(side); - phase_[side][0] += kPhaseIncrement[sample.low_frequency_index] * count; - phase_[side][1] += kPhaseIncrement[sample.high_frequency_index + 32] * count; + phase_[side][0] += sample.low_increment * count; + phase_[side][1] += sample.high_increment * count; } } @@ -362,13 +488,15 @@ void SwitchHdRumbleSynth::render(uint64_t first_sample, uint32_t frames, (feedback_low_ || feedback_high_); for (unsigned side = 0; side < 2; ++side) { const auto& sample = host_sample(side); - increment[side][0] = kPhaseIncrement[sample.low_frequency_index]; - increment[side][1] = kPhaseIncrement[sample.high_frequency_index + 32]; + increment[side][0] = sample.low_increment; + increment[side][1] = sample.high_increment; const bool expired = !sides_[side].persistent && due(sides_[side].expires, cursor_); amplitude[side][0] = expired ? 0 : sample.low_amplitude_q15; amplitude[side][1] = expired ? 0 : sample.high_amplitude_q15; - if (!overlay) apply_host_gain(amplitude[side][0], amplitude[side][1]); + const bool side_overlay = overlay && + (!separate_feedback_ || (side == 0 ? feedback_low_ : feedback_high_)); + if (!side_overlay) apply_host_gain(amplitude[side][0], amplitude[side][1]); } uint32_t feedback_low_phase = kPhaseIncrement[64] * static_cast(cursor_); @@ -376,10 +504,19 @@ void SwitchHdRumbleSynth::render(uint64_t first_sample, uint32_t frames, static_cast(cursor_); for (; cursor_ < boundary; ++cursor_) { if (overlay) { - const uint8_t value = mix(feedback_low_phase, feedback_high_phase, - feedback_low_, feedback_high_); - *interleaved_stereo++ = value; - *interleaved_stereo++ = value; + if (separate_feedback_) { + *interleaved_stereo++ = feedback_low_ + ? mix(feedback_low_phase, 0, feedback_low_, 0) + : mix(phase_[0][0], phase_[0][1], amplitude[0][0], amplitude[0][1]); + *interleaved_stereo++ = feedback_high_ + ? mix(0, feedback_high_phase, 0, feedback_high_) + : mix(phase_[1][0], phase_[1][1], amplitude[1][0], amplitude[1][1]); + } else { + const uint8_t value = mix(feedback_low_phase, feedback_high_phase, + feedback_low_, feedback_high_); + *interleaved_stereo++ = value; + *interleaved_stereo++ = value; + } feedback_low_phase += kPhaseIncrement[64]; feedback_high_phase += kPhaseIncrement[96]; } else { diff --git a/src/firmware/input/switch_hd_rumble_synth.h b/src/firmware/input/switch_hd_rumble_synth.h index 369f3fd..ef92dd3 100644 --- a/src/firmware/input/switch_hd_rumble_synth.h +++ b/src/firmware/input/switch_hd_rumble_synth.h @@ -2,6 +2,7 @@ #include +#include "core/native_haptics.h" #include "usb/switch/switch_haptics.h" // Single-core, allocation-free 3 kHz stereo PCM timeline. All times use the same @@ -23,6 +24,16 @@ public: // or extending their original expiry. bool push(const SwitchHapticsFrame& frame, uint64_t received_us); + // Native frequency codes retain 1/96-octave precision; each sample lasts + // 16 PCM frames (~5.333 ms), independent of legacy Switch's 8 ms/count. + static bool valid_native(const NativeHapticsFrame& frame); + bool push_native(const NativeHapticsFrame& frame, uint64_t received_us); + + // Single-core cancellation fence: erase affected queued native updates and + // silence their live state without touching the other side or feedback. + // A later native push is a new effect, even at the same timestamp. + void cancel_native(uint8_t side_mask); + // Profile-scaled conventional host rumble: low drives left at 160 Hz and // high drives right at 320 Hz, with the opposite bands silent. Both sides // persist until replaced or reset; zero stops both. Pre-epoch commands do @@ -44,24 +55,44 @@ public: // Feedback timestamps are chronological independently of both host APIs. void feedback(uint64_t at_us, uint32_t duration_us, uint8_t low_magnitude, uint8_t high_magnitude); + // Side-isolated local cue: left uses 160 Hz, right uses 320 Hz. Unlike the + // conventional feedback mix, a zero side reveals its unchanged host state. + void feedback_native(uint64_t at_us, uint32_t duration_us, + uint8_t left, uint8_t right); uint32_t dropped_updates() const { return dropped_updates_; } private: static constexpr uint8_t kCapacity = 16; + struct Sample { + uint32_t low_increment = 229064922u; // 160 Hz. + uint32_t high_increment = 458129845u; // 320 Hz. + uint16_t low_amplitude_q15 = 0; + uint16_t high_amplitude_q15 = 0; + }; + + struct Actuator { + uint8_t sample_count = 0; + Sample samples[3]{}; + }; + struct Command { int64_t sample = 0; int64_t expires = 0; - SwitchHapticsFrame frame{}; + Actuator actuators[2]{}; uint16_t low = 0; uint16_t high = 0; bool is_feedback = false; bool persistent = false; + bool native = false; + bool separate_feedback = false; }; struct Side { - SwitchHapticsActuatorFrame frame{1, {}}; + Actuator frame{1, {}}; + uint8_t sample_spacing = 24; + bool native = false; bool persistent = false; int64_t start = 0; int64_t expires = 0; @@ -78,9 +109,11 @@ private: void advance_to(uint64_t sample, bool discarded = false); void advance_phases(uint64_t samples); uint64_t next_boundary(uint64_t limit) const; - const SwitchHapticsSample& host_sample(unsigned side) const; + const Sample& host_sample(unsigned side) const; bool timestamp_sample(uint64_t timestamp_us, int64_t& sample) const; void count_drop(); + void queue_feedback(uint64_t at_us, uint32_t duration_us, + uint8_t low, uint8_t high, bool separate); uint64_t epoch_us_ = 0; uint64_t cursor_ = 0; @@ -97,4 +130,5 @@ private: int64_t feedback_expires_ = 0; uint16_t feedback_low_ = 0; uint16_t feedback_high_ = 0; + bool separate_feedback_ = false; }; diff --git a/src/firmware/platform/pico/system_clock.cpp b/src/firmware/platform/pico/system_clock.cpp index 6deaee1..7639c92 100644 --- a/src/firmware/platform/pico/system_clock.cpp +++ b/src/firmware/platform/pico/system_clock.cpp @@ -8,6 +8,10 @@ #include "hardware/structs/qmi.h" #include "hardware/vreg.h" #include "pico/stdlib.h" +#if SWITCH2_PROBE_HUB +// Reject clocks not supported by the compiled SIO receiver contract. +#include "router.h" +#endif namespace { SystemClockStatus g_status{}; @@ -18,10 +22,6 @@ void system_clock_initialize() { SWITCH_PICO_SYS_CLOCK_MHZ == 240 || SWITCH_PICO_SYS_CLOCK_MHZ == 300 || SWITCH_PICO_SYS_CLOCK_MHZ == 400); -#if SWITCH2_PROBE_HUB - static_assert(SWITCH_PICO_SYS_CLOCK_MHZ == 240, - "Native SIO hub requires a 240 MHz system clock"); -#endif // Flash timing was established by boot stage 2. Do not raise clk_sys if // another boot configuration failed to provide the required divider. const uint32_t flash_divider = diff --git a/src/firmware/usb/native_hub/native_hub.c b/src/firmware/usb/native_hub/native_hub.c index ffaa6d5..70a4abe 100644 --- a/src/firmware/usb/native_hub/native_hub.c +++ b/src/firmware/usb/native_hub/native_hub.c @@ -21,8 +21,9 @@ #include "pico/unique_id.h" #ifndef NATIVE_HUB_SAMPLE_PHASE -#define NATIVE_HUB_SAMPLE_PHASE 4u +#define NATIVE_HUB_SAMPLE_PHASE PROBE_ROUTER_DEFAULT_PHASE #endif +_Static_assert(NATIVE_HUB_SAMPLE_PHASE < FS_BIT_CYCLES, "Native SIO sample phase must fit one bit"); #define CHILDREN PROBE_CONTROLLER_COUNT #define DEVICES (CHILDREN + 1u) #define CHANNELS 6u @@ -1069,6 +1070,26 @@ static void setup_request(const event_t* event) { } else supported = false; if (!supported) stall(slot); } +static void complete_port_change(const tusb_control_request_t* request, bool set) { + const unsigned index = request->wIndex - 1; + port_t* p = &ports[index]; + switch (request->wValue) { + case 8: + if (set) { p->status |= POWER | CONNECT; p->change |= C_CONNECT; } + else { p->status = 0; p->change |= C_CONNECT; forget_port(index); } + break; + case 4: + forget_port(index); p->status = (p->status | RESET) & ~(ENABLE | SUSPEND); + p->deadline = time_us_32()+10000u; break; + case 1: p->status &= ~ENABLE; forget_port(index); break; + case 2: + if (set) p->status |= SUSPEND; + else { p->status &= ~SUSPEND; p->change |= C_SUSPEND; } + break; + default: p->change &= ~(1u << (request->wValue-16)); break; + } +} + static void control_complete(uint8_t slot) { control_t* c = &devices[slot].control; c->stage = IDLE; @@ -1103,26 +1124,6 @@ static void control_complete(uint8_t slot) { if (!ep->halted && (channel & 1u)) arm_packet(slot,channel,NULL,0); break; } - case PORT_SET: - case PORT_CLEAR: { - unsigned index = c->request.wIndex - 1; port_t* p = &ports[index]; bool set = c->action == PORT_SET; - switch (c->request.wValue) { - case 8: - if (set) { p->status |= POWER | CONNECT; p->change |= C_CONNECT; } - else { p->status = 0; p->change |= C_CONNECT; forget_port(index); } - break; - case 4: - forget_port(index); p->status = (p->status | RESET) & ~(ENABLE | SUSPEND); - p->deadline = time_us_32()+10000u; break; - case 1: p->status &= ~ENABLE; forget_port(index); break; - case 2: - if (set) p->status |= SUSPEND; - else { p->status &= ~SUSPEND; p->change |= C_SUSPEND; } - break; - default: p->change &= ~(1u << (c->request.wValue-16)); break; - } - break; - } default: break; } } @@ -1182,12 +1183,25 @@ static void __no_inline_not_in_flash_func(transfer_complete)(const event_t* even tud_vendor_control_xfer_cb(slot,CONTROL_STAGE_ACK,&c->request); } else { - // Do not let a reset IRQ revoke ownership between checking it - // and publishing the acknowledged service transaction. uint32_t flags = save_and_disable_interrupts(); - if (event->reset_generation == d->reset_generation) + if (event->reset_generation != d->reset_generation) { + restore_interrupts(flags); + return; + } + if (c->action == PORT_SET || c->action == PORT_CLEAR) { + // Claim this acknowledged action before allowing another + // SETUP/reset IRQ. The immutable request stays valid while + // child reset callbacks read storage and publish routing. + // A later bus reset is queued and processed after this action. + const tusb_control_request_t request = c->request; + const bool set = c->action == PORT_SET; + c->stage = IDLE; + restore_interrupts(flags); + complete_port_change(&request,set); + } else { control_complete(slot); - restore_interrupts(flags); + restore_interrupts(flags); + } } } else if (c->stage == DATA_IN && channel == 0) { // Claim DATA completion before reset can revoke it, as for ACK. @@ -1283,7 +1297,7 @@ void native_hub_startup_guard(void) { } bool native_hub_init(void) { - if (started || clock_get_hz(clk_sys) != 240000000u) return false; + if (started || clock_get_hz(clk_sys) != FS_CLOCK_HZ) return false; bank_lock = spin_lock_instance(spin_lock_claim_unused(true)); memset(devices,0,sizeof(devices)); memset(ports,0,sizeof(ports)); snprintf(root_serial,sizeof(root_serial),"switch-pico-"); @@ -1303,7 +1317,8 @@ bool native_hub_init(void) { usb_hw->sie_ctrl = USB_SIE_CTRL_EP0_INT_1BUF_BITS; usb_hw->inte = USB_INTS_BUFF_STATUS_BITS | USB_INTS_BUS_RESET_BITS | USB_INTS_SETUP_REQ_BITS | USB_INTS_DEV_SUSPEND_BITS | USB_INTS_DEV_RESUME_FROM_HOST_BITS; - probe_router_init(clock_get_hz(clk_sys)); probe_router_set_phase(NATIVE_HUB_SAMPLE_PHASE); + probe_router_init(clock_get_hz(clk_sys)); + if (!probe_router_set_phase(NATIVE_HUB_SAMPLE_PHASE)) return false; multicore_launch_core1(probe_router_core1); uint32_t deadline = time_us_32()+100000; probe_router_stats observer; @@ -1325,9 +1340,9 @@ bool native_hub_init(void) { // even an immediate host reset/SETUP now has an initialized receiver. hw_set_bits(&usb_hw->phy_direct,USB_USBPHY_DIRECT_DP_PULLUP_EN_BITS); #if CHILDREN == 2 - probe_debug_printf("[NATIVE_HUB] stock USB, SIO phase=%u, 240MHz; hub2068 R2066 L2067; isolated EP0/1/2 banks\n",NATIVE_HUB_SAMPLE_PHASE); + probe_debug_printf("[NATIVE_HUB] stock USB, SIO phase=%u, %uMHz; hub2068 R2066 L2067; isolated EP0/1/2 banks\n",NATIVE_HUB_SAMPLE_PHASE,(unsigned)FS_CLOCK_MHZ); #else - probe_debug_printf("[NATIVE_HUB] stock USB, SIO phase=%u, 240MHz; hub2068 children=%u order=AR/AL/BR/BL; isolated EP0/1/2 banks\n",NATIVE_HUB_SAMPLE_PHASE,CHILDREN); + probe_debug_printf("[NATIVE_HUB] stock USB, SIO phase=%u, %uMHz; hub2068 children=%u order=AR/AL/BR/BL; isolated EP0/1/2 banks\n",NATIVE_HUB_SAMPLE_PHASE,(unsigned)FS_CLOCK_MHZ,CHILDREN); #endif return true; } @@ -1652,6 +1667,21 @@ void native_hub_task(void) { " mtime=%08"PRIx32" watchdog=%08"PRIx32" nak_poll=%08"PRIx32"\n", usb_hw->intr,usb_hw->inte,usb_hw->sof_rd,sio_hw->mtime, usb_hw->dev_sm_watchdog,usb_hw->nak_poll); + probe_debug_printf("[HUB_OBSERVER] returns=%"PRIu32" discard=%"PRIu32" header=%08"PRIx32 + " enabled=%"PRIu32" fault=%"PRIu32" generation=%"PRIu32" reader=%"PRIu32 + " gpio=%08"PRIx32" mux=%08"PRIx32"\n", + observer.capture_returns,observer.discarded_headers,observer.last_discarded_header, + observer.enabled,observer.fatal_fault,observer.published_generation,observer.reader_index, + sio_hw->gpio_hi_in,usb_hw->muxing); + probe_debug_printf("[HUB_ROOT_REPLY] in=%"PRIu32" cutoff=%08"PRIx32 + " header=%08"PRIx32" seen=%08"PRIx32" eop=%08"PRIx32 + " before_in=%"PRIu32" before_cutoff=%08"PRIx32 + " before_header=%08"PRIx32" before_seen=%08"PRIx32" before_eop=%08"PRIx32"\n", + observer.root_in_count,observer.root_in_cutoff,observer.root_header, + observer.root_header_cycle,observer.root_eop_cycle, + observer.before_setup_in_count,observer.before_setup_in_cutoff, + observer.before_setup_header,observer.before_setup_header_cycle, + observer.before_setup_eop_cycle); #if CHILDREN == 2 probe_debug_printf("[HUB_PORTS] status=%04x/%04x change=%04x/%04x\n", ports[0].status,ports[1].status,ports[0].change,ports[1].change); diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index 717abcf..e357995 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -192,7 +192,7 @@ size_t encode_transaction(uint8_t* output, size_t output_size) { size_t encode_info(uint8_t* output, size_t output_size) { uint8_t payload[8] = { #if SWITCH2_PROBE_HUB - 0, 102, 0, 2, + 0, 108, 0, 2, kNativeHubActiveMode, USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE | USB_OUTPUT_CAPABILITY_MOTION, diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index 701069a..b53cb4c 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -32,6 +32,7 @@ uni_platform* installed_platform = nullptr; bool observed_status_led_on = false; int observed_status_led_writes = 0; uint32_t now_ms = 0; +uint32_t now_sub_ms_us = 0; struct WiiAccelFixture { uni_hid_device_t* device = nullptr; int32_t acceleration[3]{}; @@ -689,6 +690,8 @@ uint32_t time_us_32() { return now_ms * 1000u; } +uint64_t time_us_64() { return uint64_t{now_ms} * 1000 + now_sub_ms_us; } + void switch2_wake_initialize() { ++switch2_wake_initializations; @@ -711,6 +714,7 @@ void switch2_wake_diagnostics(Switch2WakeDiagnostics*) { #include "core/controller_identity.cpp" namespace { unsigned state_lock_depth = 0; +void (*after_backend_state_unlock)() = nullptr; void tracked_state_lock_enter(critical_section_t* lock) { critical_section_enter_blocking(lock); @@ -721,6 +725,8 @@ void tracked_state_lock_exit(critical_section_t* lock) { require(state_lock_depth != 0, "state lock exit must match an enter"); --state_lock_depth; critical_section_exit(lock); + if (state_lock_depth == 0 && after_backend_state_unlock) + after_backend_state_unlock(); } } // namespace @@ -769,26 +775,34 @@ namespace { std::vector native_timers; std::array last_native_packet{}; uint16_t last_native_cid = 0; +struct NativePacketObservation { + uint64_t sent_us; + uint16_t cid; + std::array data; +}; +std::vector native_packets; } void native_test_add_timer(btstack_timer_source_t* timer) { + require(state_lock_depth == 0, "HD timer scheduling must not hold the backend lock"); btstack_run_loop_remove_timer(timer); timer->due_ms = uint64_t{now_ms} + timer->timeout_ms + 1; native_timers.push_back(timer); } int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) { + require(state_lock_depth == 0, "HD timer cancellation must not hold the backend lock"); const auto found = std::find(native_timers.begin(), native_timers.end(), timer); if (found == native_timers.end()) return 0; native_timers.erase(found); return 1; } -uint64_t time_us_64() { return uint64_t{now_ms} * 1000; } uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t) { return 143; } bool l2cap_can_send_packet_now(uint16_t) { return true; } int hci_number_free_acl_slots_for_handle(uint16_t) { return 8; } uint8_t l2cap_request_can_send_now_event(uint16_t cid) { + require(state_lock_depth == 0, "HD permission callbacks must not hold the backend lock"); for (const auto& slot : g_slots) { if (slot.device != nullptr && slot.device->conn.interrupt_cid == cid) { (void)uni_platform_on_l2cap_can_send_now(slot.device, cid); @@ -800,9 +814,11 @@ uint8_t l2cap_request_can_send_now_event(uint16_t cid) { } uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) { + require(state_lock_depth == 0, "HD packet writes must not hold the backend lock"); require(size == last_native_packet.size(), "native report size changed"); std::copy(data, data + size, last_native_packet.begin()); last_native_cid = cid; + native_packets.push_back({time_us_64(), cid, last_native_packet}); return ERROR_CODE_SUCCESS; } @@ -5351,12 +5367,11 @@ void require_native_channels(bool left, bool right) { require(status.state == HapticsExperimentState::kRunning && status.mode == 1, "stateful host rumble lost native gameplay ownership"); - require(status.packet_frames == SWITCH_PICO_HD_PACKET_FRAMES, - "native gameplay ignored the configured packet frame count"); - const unsigned sample_offset = status.packet_frames == 32 ? 14 : 10; + require(status.packet_frames == 32, + "native gameplay requires the supported 32-frame transport"); + constexpr unsigned sample_offset = 14; require(last_native_packet[3] == 0x91 && - last_native_packet[sample_offset - 2] == - (status.packet_frames == 32 ? 0x92 : 0xd2) && + last_native_packet[sample_offset - 2] == 0x92 && last_native_packet[sample_offset - 1] == 64, "stateful channel inspection requires a native PCM block"); unsigned active[2]{}; diff --git a/tests/bluepad32_classic_discovery_test.c b/tests/bluepad32_classic_discovery_test.c new file mode 100644 index 0000000..8ec708e --- /dev/null +++ b/tests/bluepad32_classic_discovery_test.c @@ -0,0 +1,114 @@ +#include +#include +#include + +#include +#include "bt/uni_bt.h" +#include "bt/uni_bt_bredr.h" +#include "bt/uni_bt_sdp.h" +#include "uni_hid_device.h" + +static const bd_addr_t paired = {1, 2, 3, 4, 5, 6}; +static const bd_addr_t unknown = {7, 8, 9, 10, 11, 12}; +static bool bondable; +static bool allocated; +static unsigned connection_attempts; +static bd_addr_t attempted_address; +static uni_hid_device_t device; + +int gap_get_bondable_mode(void) { return bondable; } +bool gap_get_link_key_for_bd_addr(bd_addr_t address, link_key_t key, link_key_type_t* type) { + if (memcmp(address, paired, sizeof(bd_addr_t)) != 0) return false; + memset(key, 0x55, sizeof(link_key_t)); + *type = COMBINATION_KEY; + return true; +} + +void uni_log(const char* format, ...) { (void)format; } +void btstack_assert_failed(const char* file, uint16_t line) { + (void)file; + (void)line; + assert(false); +} + +uni_error_t uni_hid_device_on_device_discovered(bd_addr_t address, const char* name, + uint16_t cod, uint8_t rssi) { + (void)address; (void)name; (void)cod; (void)rssi; + return UNI_ERROR_SUCCESS; // The platform has room and permits discovery. +} +uni_hid_device_t* uni_hid_device_get_instance_for_address(bd_addr_t address) { + return allocated && memcmp(address, device.conn.btaddr, sizeof(bd_addr_t)) == 0 ? &device : NULL; +} +uni_hid_device_t* uni_hid_device_create(bd_addr_t address) { + assert(!allocated); + allocated = true; + memset(&device, 0, sizeof(device)); + memcpy(device.conn.btaddr, address, sizeof(bd_addr_t)); + return &device; +} +void uni_hid_device_set_cod(uni_hid_device_t* d, uint32_t cod) { d->cod = cod; } +void uni_hid_device_set_name(uni_hid_device_t* d, const char* name) { + assert(strlen(name) < sizeof(d->name)); + strcpy(d->name, name); +} +bool uni_hid_device_has_name(const uni_hid_device_t* d) { return d->name[0] != 0; } +bool uni_hid_device_is_incoming(const uni_hid_device_t* d) { return d->conn.incoming; } +bool uni_hid_device_guess_controller_type_from_name(uni_hid_device_t* d, const char* name) { + (void)d; (void)name; + return false; +} +void uni_hid_device_set_ready(uni_hid_device_t* d) { (void)d; assert(false); } +void uni_bt_sdp_query_start(uni_hid_device_t* d) { (void)d; assert(false); } +void uni_bt_sdp_query_start_hid_descriptor(uni_hid_device_t* d) { (void)d; assert(false); } +int gap_remote_name_request(const bd_addr_t address, uint8_t mode, uint16_t offset) { + (void)address; (void)mode; (void)offset; + assert(false); // The real inquiry event below already contains a name. + return ERROR_CODE_COMMAND_DISALLOWED; +} +void uni_bt_packet_handler(uint8_t type, uint16_t channel, uint8_t* packet, uint16_t size) { + (void)type; (void)channel; (void)packet; (void)size; + assert(false); +} +uint8_t l2cap_create_channel(btstack_packet_handler_t handler, bd_addr_t address, + uint16_t psm, uint16_t mtu, uint16_t* cid) { + (void)handler; (void)mtu; + assert(psm == BLUETOOTH_PSM_HID_CONTROL); + memcpy(attempted_address, address, sizeof(bd_addr_t)); + ++connection_attempts; + *cid = 0x40; + return ERROR_CODE_SUCCESS; +} + +static void discover(const bd_addr_t address) { + // Real GAP_EVENT_INQUIRY_RESULT layout, decoded by BTstack accessors. + static const char name[] = "Test gamepad"; + uint8_t event[27 + sizeof(name) - 1] = {GAP_EVENT_INQUIRY_RESULT, sizeof(event) - 2}; + for (unsigned i = 0; i < sizeof(bd_addr_t); ++i) event[2 + i] = address[5 - i]; + event[8] = 1; + event[9] = 0x08; event[10] = 0x25; // Peripheral/gamepad class. + event[14] = 1; event[15] = 220; + event[25] = 1; event[26] = sizeof(name) - 1; + memcpy(event + 27, name, sizeof(name) - 1); + uni_bt_bredr_on_gap_inquiry_result(0, event, sizeof(event)); +} + +int main(void) { + bondable = false; + discover(unknown); + assert(connection_attempts == 0 && !allocated); + + discover(paired); + assert(connection_attempts == 1 && memcmp(attempted_address, paired, sizeof(bd_addr_t)) == 0); + + allocated = false; + bondable = true; + discover(unknown); + assert(connection_attempts == 2 && memcmp(attempted_address, unknown, sizeof(bd_addr_t)) == 0); + + // Closing the pairing window must reject the same still-unpaired peer. + allocated = false; + bondable = false; + discover(unknown); + assert(connection_attempts == 2 && !allocated); + return 0; +} diff --git a/tests/haptics_experiment_test.cpp b/tests/haptics_experiment_test.cpp index 1b90896..f70acd9 100644 --- a/tests/haptics_experiment_test.cpp +++ b/tests/haptics_experiment_test.cpp @@ -22,12 +22,12 @@ enum class Delivery { kImmediate, kDeferred, kNever }; enum class GenericKind { kCompatibility, kLed }; constexpr uint32_t kPacketFrames = SWITCH_PICO_HD_PACKET_FRAMES; -static_assert(kPacketFrames == 32 || kPacketFrames == 64); +static_assert(kPacketFrames == 32); constexpr uint32_t kPackets = 18432 / kPacketFrames; constexpr uint32_t kPrimingPackets = 3072 / kPacketFrames; constexpr uint32_t kToneEndPacket = 15360 / kPacketFrames; constexpr uint32_t kPhasePackets = 768 / kPacketFrames; -constexpr unsigned kSampleOffset = kPacketFrames == 32 ? 14 : 10; +constexpr unsigned kSampleOffset = 14; struct Pcm { uint64_t at_us; @@ -242,7 +242,7 @@ uint64_t due(uint64_t started, uint32_t packet) { const uint8_t* samples(const Pcm& packet) { const auto& b = packet.bytes; assert(b[3] == 0x91); - assert(b[kSampleOffset - 2] == (kPacketFrames == 32 ? 0x92 : 0xd2)); + assert(b[kSampleOffset - 2] == 0x92); assert(b[kSampleOffset - 1] == 64); return b.data() + kSampleOffset; } @@ -258,14 +258,9 @@ void verify_report(const Pcm& packet, uint32_t sent_index) { return; } assert(b[2] == 0 && b[3] == 0x91); - if (kPacketFrames == 32) { - assert(b[4] == 7 && b[5] == 0xfe); - for (unsigned i = 6; i < 10; ++i) assert(b[i] == 0); - assert(b[10] == 0xff && b[11] == static_cast(sent_index - 1)); - } else { - assert(b[4] == 3 && b[5] == 0x62 && b[6] == 16); - assert(b[7] == static_cast(sent_index * 2)); - } + assert(b[4] == 7 && b[5] == 0xfe); + for (unsigned i = 6; i < 10; ++i) assert(b[i] == 0); + assert(b[10] == 0xff && b[11] == static_cast(sent_index - 1)); samples(packet); for (unsigned i = kSampleOffset + kPacketFrames * 2; i < 139; ++i) { assert(b[i] == 0); @@ -857,6 +852,169 @@ void stateful_rumble_generation_and_overflow() { assert(generic_sent.empty()); } +NativeHapticsFrame native_frame(bool left, bool right) { + NativeHapticsFrame frame{}; + frame.actuators[0] = {1, {{385, 481, static_cast(left ? 1023 : 0), 0}}}; + frame.actuators[1] = {1, {{385, 481, 0, static_cast(right ? 1023 : 0)}}}; + return frame; +} + +void verify_channels(const Pcm& packet, bool left, bool right) { + const auto* block = samples(packet); + unsigned active[2]{}; + for (unsigned frame = 0; frame < kPacketFrames; ++frame) { + active[0] += block[2 * frame] != 0; + active[1] += block[2 * frame + 1] != 0; + } + assert(left ? active[0] > kPacketFrames / 2 : active[0] == 0); + assert(right ? active[1] > kPacketFrames / 2 : active[1] == 0); +} + +void native_samples_through_real_packets() { + reset(); + constexpr uint8_t slot = 3; // Selection is not hard-wired to physical slot zero. + assert(haptics_experiment_request(2, slot)); + assert(haptics_experiment_native_selected(slot, 103)); + assert(!haptics_experiment_native_selected(0, 100)); + const uint64_t started = now_us; + NativeHapticsFrame frame{}; + frame.actuators[0] = {3, {{385, 481, 1023, 0}, {385, 481, 0, 0}, {385, 481, 512, 0}}}; + frame.actuators[1] = {2, {{385, 481, 0, 0}, {385, 481, 0, 1023}}}; + assert(haptics_experiment_submit_native(slot, 103, now_us, frame)); + assert(snapshot().last_pcm_end_us == 0); // Mailbox admission is not PCM delivery. + haptics_experiment_poll(); + verify_report(pcm.front(), 0); + assert(snapshot().last_pcm_end_us == 0); // State-only setup has no sample interval. + run_until(due(started, 2) + 1000); + assert(pcm.size() == 3 && pcm[1].cid == devices[slot].conn.interrupt_cid); + assert(snapshot().last_pcm_end_us == static_cast(due(started, 2))); + verify_report(pcm[1], 1); + const auto* first = samples(pcm[1]); + unsigned active_left = 0, active_right = 0; + for (unsigned n = 0; n < 32; ++n) { + if (n < 16) { + assert(first[2 * n + 1] == 0); + active_left += first[2 * n] != 0; + } else { + assert(first[2 * n] == 0); + active_right += first[2 * n + 1] != 0; + } + } + assert(active_left > 10 && active_right > 10); // Fixed 16-frame native spacing. + verify_channels(pcm[2], true, true); // Third left substep begins at sample 32. + run_until(started + 80000); + verify_silence(pcm.back()); // Original per-side 50 ms watchdog, no held PCM replay. + assert(generic_sent.empty()); + assert(haptics_experiment_request(0, slot)); + assert(haptics_experiment_native_selected(slot, 103)); + assert(!haptics_experiment_submit_native(slot, 103, now_us, frame)); + haptics_experiment_poll(); + verify_silence(pcm.back()); // Drain emits an actual all-zero PCM stop. + assert(haptics_experiment_native_selected(slot, 103)); // Restore still owns output. + run_until(now_us + 10000); + assert(snapshot().state == HapticsExperimentState::kStopped); + assert(!haptics_experiment_native_selected(slot, 103)); + assert(generic_sent.size() == 2); // Only the exclusive compatibility restoration. +} + +void native_cancellation_generation_and_feedback() { + reset(); + constexpr uint8_t slot = 2; + assert(haptics_experiment_request(2, slot)); + const uint64_t started = now_us; + auto both = native_frame(true, true); + assert(haptics_experiment_submit_native(slot, 102, now_us, both)); + haptics_experiment_poll(); + run_until(due(started, 1) + 1000); + verify_channels(pcm.back(), true, true); + + // Remove both already-synthesized history and mailbox work on just left. + assert(haptics_experiment_submit_native(slot, 102, now_us, both)); + haptics_experiment_cancel_native(slot, 102, 1); + haptics_experiment_attach(slot, 202, &devices[slot]); + assert(snapshot().state == HapticsExperimentState::kRunning); + assert(haptics_experiment_native_selected(slot, 202)); + assert(!haptics_experiment_native_selected(slot, 102)); + assert(!haptics_experiment_submit_native(slot, 102, now_us, both)); + haptics_experiment_cancel_native(slot, 102, 2); // Old generation cannot stop right. + run_until(due(started, 2) + 1000); + verify_channels(pcm.back(), false, true); + + assert(haptics_experiment_native_feedback(&devices[slot], now_us, 200, 0, 30)); + haptics_experiment_cancel_native(slot, 202, 3); + run_until(now_us + 22000); + verify_channels(pcm.back(), true, false); // Side cue survives host cancellation. + run_until(now_us + 50000); + verify_silence(pcm.back()); // Canceled right must not resume after the cue. + assert(haptics_experiment_native_feedback(&devices[slot], now_us, 0, 200, 30)); + run_until(now_us + 22000); + verify_channels(pcm.back(), false, true); + assert(generic_sent.empty()); + run_until(now_us + 50000); + verify_silence(pcm.back()); + + auto right = native_frame(false, true); + assert(haptics_experiment_submit_native(slot, 202, now_us, right)); + run_until(now_us + 22000); + verify_channels(pcm.back(), false, true); + haptics_experiment_detach(&devices[slot]); + haptics_experiment_attach(slot, 203, &devices[slot]); + assert(!haptics_experiment_submit_native(slot, 202, now_us, both)); + assert(haptics_experiment_request(2, slot)); + haptics_experiment_poll(); + run_until(now_us + 30000); + verify_silence(pcm.back()); // A real reconnect does not retain a native effect. +} + +void native_mailbox_validation_and_pending_cancel() { + reset(); + assert(haptics_experiment_request(2, 1)); + auto both = native_frame(true, true); + for (unsigned i = 0; i < 16; ++i) { + assert(haptics_experiment_submit_native(1, 101, now_us, both)); + } + auto invalid = both; + invalid.actuators[0].samples[0].low_amplitude = 0; + invalid.actuators[1].samples[0].high_frequency_code = 671; + assert(!haptics_experiment_submit_native(1, 101, now_us + 1000, invalid)); + invalid.actuators[1].samples[0].high_frequency_code = 481; + invalid.actuators[1].samples[0].high_amplitude = 1024; + assert(!haptics_experiment_submit_native(1, 101, now_us + 1000, invalid)); + invalid.actuators[1].sample_count = 4; + assert(!haptics_experiment_submit_native(1, 101, now_us + 1000, invalid)); + assert(haptics_experiment_native_selected(1, 101)); // Rejection never enables fallback. + haptics_experiment_cancel_native(1, 101, 1); + haptics_experiment_attach(1, 201, &devices[1]); // Generation can also migrate Pending. + assert(!haptics_experiment_submit_native(1, 101, now_us, both)); + assert(haptics_experiment_submit_native(1, 201, now_us, native_frame(false, true))); + haptics_experiment_poll(); + run_until(now_us + 22000); + verify_channels(pcm.back(), false, true); + assert(snapshot().host_updates == 17 && snapshot().dropped_updates == 1); + assert(snapshot().connection_generation == 201); + assert(generic_sent.empty()); +} + +void native_pcm_delivery_watermark() { + reset((uint64_t{1} << 32) - 15000); + assert(haptics_experiment_request(2, 0)); + assert(haptics_experiment_submit_native(0, 100, now_us, native_frame(true, false))); + haptics_experiment_poll(); + const uint64_t started = now_us; + assert(snapshot().last_pcm_end_us == 0); + fail_sends = 1; + run_until(due(started, 1) + 1000); + assert(snapshot().send_failures == 1 && snapshot().last_pcm_end_us == 0); + run_until(due(started, 2) + 1000); + assert(snapshot().last_pcm_end_us == static_cast(due(started, 2))); + verify_channels(pcm.back(), true, false); + const uint32_t delivered_end = snapshot().last_pcm_end_us; + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + verify_silence(pcm.back()); + assert(snapshot().last_pcm_end_us == delivered_end); // Urgent drain is not cue evidence. +} + } // namespace // Transport attribution has its own native fixture; this fixture isolates PCM @@ -996,5 +1154,9 @@ int main(int argc, char** argv) { stateful_rumble_prepare_feedback_and_zero(); stateful_rumble_generation_and_overflow(); gameplay_missing_callback_is_bounded(); + native_samples_through_real_packets(); + native_cancellation_generation_and_feedback(); + native_mailbox_validation_and_pending_cancel(); + native_pcm_delivery_watermark(); std::cout << "haptics experiment behavioral regressions passed\n"; } diff --git a/tests/native_gamepad_backend_test.cpp b/tests/native_gamepad_backend_test.cpp index 0eb5436..5e4907e 100644 --- a/tests/native_gamepad_backend_test.cpp +++ b/tests/native_gamepad_backend_test.cpp @@ -8,6 +8,9 @@ extern "C" bool uni_hid_parser_wii_rumble_ready(uni_hid_device_t*); #undef main #undef profile_service_active_profile_snapshot +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +#include "input/switch_hd_rumble_synth.h" +#endif namespace { void (*during_profile_resolution)() = nullptr; bool native_wii_ready = true; @@ -26,6 +29,20 @@ extern "C" bool uni_hid_parser_wii_rumble_ready(uni_hid_device_t*) { } namespace { +// Test data only: construct the raw native frame whose conventional peak is +// the requested old magnitude. Production has no magnitude-only ingress. +bool submit_native_magnitudes(uint8_t instance, const uint8_t* magnitudes, uint8_t count) { + if (magnitudes == nullptr) + return bluepad32_input_backend_native_rumble_submit(instance, nullptr); + NativeHapticsActuatorFrame frame{}; + frame.sample_count = count; + if (count <= 3) + for (uint8_t sample = 0; sample < count; ++sample) + frame.samples[sample] = { + 385, 481, static_cast((uint32_t{magnitudes[sample]} * 1023u + 127u) / 255u), 0}; + return bluepad32_input_backend_native_rumble_submit(instance, &frame); +} + struct SensorFixture { uni_hid_device_t* device = nullptr; uni_native_motion_snapshot_t metadata{}; @@ -628,8 +645,8 @@ void gameplay_timeline() { uint8_t right[] = {40, 80, 120}; const uint8_t left[] = {60, 180}; const uint8_t stop = 0; - require(bluepad32_input_backend_native_rumble_submit(0, right, 3) && - bluepad32_input_backend_native_rumble_submit(1, left, 2), + require(submit_native_magnitudes(0, right, 3) && + submit_native_magnitudes(1, left, 2), "mixed sample counts must be accepted independently"); right[0] = 255; process_rumble_timer(&g_rumble_timer); @@ -643,7 +660,7 @@ void gameplay_timeline() { process_rumble_timer(&g_rumble_timer); require(pad.last_high == 120 && pad.last_low == 180 && pad.last_rumble_duration_ms == 40, "last samples hold only to their original receipt watchdog"); - require(bluepad32_input_backend_native_rumble_submit(0, &stop, 1), + require(submit_native_magnitudes(0, &stop, 1), "explicit zero magnitude must be a valid side stop"); process_rumble_timer(&g_rumble_timer); require(pad.last_high == 0 && pad.last_low == 180 && pad.last_rumble_duration_ms == 40, @@ -659,7 +676,7 @@ void gameplay_timeline() { process_rumble_timer(&g_rumble_timer); const int stopped = pad.rumble_calls; const uint8_t delayed[] = {21, 42, 84}; - require(bluepad32_input_backend_native_rumble_submit(0, delayed, 3), "delayed block must queue"); + require(submit_native_magnitudes(0, delayed, 3), "delayed block must queue"); now_ms = 520; process_rumble_timer(&g_rumble_timer); require(pad.rumble_calls == stopped + 1 && pad.last_high == 84 && @@ -670,7 +687,7 @@ void gameplay_timeline() { require(pad.last_rumble_duration_ms == 0, "explicit cancellation must stop its live hold"); now_ms = UINT32_MAX - 9u; const uint8_t pulse = 99; - require(bluepad32_input_backend_native_rumble_submit(1, &pulse, 1), "pre-wrap request must queue"); + require(submit_native_magnitudes(1, &pulse, 1), "pre-wrap request must queue"); process_rumble_timer(&g_rumble_timer); require(pad.last_low == 99 && pad.last_rumble_duration_ms == 50, "finite host lifetime must remain valid before clock wrap"); @@ -688,11 +705,11 @@ void gameplay_availability() { require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "busy gameplay source must connect"); const uint8_t old[] = {10, 20, 30}; const uint8_t newest[] = {70, 140}; - require(bluepad32_input_backend_native_rumble_submit(0, old, 3), "old block must queue"); + require(submit_native_magnitudes(0, old, 3), "old block must queue"); dualsense_transport_available = false; process_rumble_timer(&g_rumble_timer); now_ms = 5; - require(bluepad32_input_backend_native_rumble_submit(0, newest, 2), "busy source must retain a new block"); + require(submit_native_magnitudes(0, newest, 2), "busy source must retain a new block"); process_rumble_timer(&g_rumble_timer); require(pad.rumble_calls == 0, "driver rejection cannot count as a successful output"); now_ms = 15; @@ -703,10 +720,10 @@ void gameplay_availability() { const uint8_t next = 210; during_dualsense_dispatch = [] { const uint8_t replacement = 33; - require(bluepad32_input_backend_native_rumble_submit(0, &replacement, 1), + require(submit_native_magnitudes(0, &replacement, 1), "gameplay must replace work while an earlier driver call is in flight"); }; - require(bluepad32_input_backend_native_rumble_submit(0, &next, 1), "dispatch race must queue"); + require(submit_native_magnitudes(0, &next, 1), "dispatch race must queue"); process_rumble_timer(&g_rumble_timer); during_dualsense_dispatch = nullptr; process_rumble_timer(&g_rumble_timer); @@ -716,7 +733,7 @@ void gameplay_availability() { process_rumble_timer(&g_rumble_timer); const int stopped = pad.rumble_calls; dualsense_transport_available = false; - require(bluepad32_input_backend_native_rumble_submit(0, old, 3), "stale block must queue"); + require(submit_native_magnitudes(0, old, 3), "stale block must queue"); now_ms += 50; process_rumble_timer(&g_rumble_timer); dualsense_transport_available = true; @@ -732,7 +749,7 @@ void gameplay_priority() { const uint8_t game = 45; uint64_t old_cue, new_cue; require(bluepad32_input_backend_native_sample_request(0, 1, &old_cue) && - bluepad32_input_backend_native_rumble_submit(0, &game, 1), + submit_native_magnitudes(0, &game, 1), "gameplay must replace a pending cue on the same side"); process_rumble_timer(&g_rumble_timer); require(pad.last_high == 45 && @@ -744,14 +761,14 @@ void gameplay_priority() { require(pad.last_high == 96 && pad.last_rumble_duration_ms == 60 && bluepad32_input_backend_native_sample_result(0, new_cue) == 1, "built-in cue completion must retain its driver-dispatch semantics"); - require(bluepad32_input_backend_native_rumble_submit(0, &game, 1) && - bluepad32_input_backend_native_rumble_submit(1, &game, 1), + require(submit_native_magnitudes(0, &game, 1) && + submit_native_magnitudes(1, &game, 1), "fresh gameplay must replace the playing cue"); process_rumble_timer(&g_rumble_timer); const auto source = bridge_snapshot(); bluepad32_input_backend_queue_profile_feedback(source.slot, source.controller.connection_generation, 1, ControllerProfileConfirmationPolicy::kRumble); - require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1), + require(!submit_native_magnitudes(0, &game, 1), "queued higher-priority feedback must not admit gameplay for later replay"); process_rumble_timer(&g_rumble_timer); require(pad.last_high == UINT8_MAX && pad.last_low == UINT8_MAX && @@ -762,7 +779,7 @@ void gameplay_priority() { now_ms = 155; process_rumble_timer(&g_rumble_timer); require(pad.rumble_calls == after_feedback, "profile completion must never resurrect interrupted gameplay"); - require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "fresh post-feedback gameplay must resume"); + require(submit_native_magnitudes(0, &game, 1), "fresh post-feedback gameplay must resume"); process_rumble_timer(&g_rumble_timer); require(bluepad32_input_backend_toggle_motion(source.slot, source.controller.connection_generation), "motion feedback must queue through the existing local-feedback path"); @@ -778,26 +795,26 @@ void gameplay_source_epochs() { auto pad = dualsense(0); require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "epoch source must connect"); const uint8_t game = 170; - require(!bluepad32_input_backend_native_rumble_submit(0, nullptr, 1) && - !bluepad32_input_backend_native_rumble_submit(0, &game, 0) && - !bluepad32_input_backend_native_rumble_submit(0, &game, 4) && - !bluepad32_input_backend_native_rumble_submit(PROBE_CONTROLLER_COUNT, &game, 1), + require(!submit_native_magnitudes(0, nullptr, 1) && + !submit_native_magnitudes(0, &game, 0) && + !submit_native_magnitudes(0, &game, 4) && + !submit_native_magnitudes(PROBE_CONTROLLER_COUNT, &game, 1), "invalid gameplay frames must fail before dispatch"); pad.report_parser.play_dual_rumble = nullptr; - require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1), + require(!submit_native_magnitudes(0, &game, 1), "source without a rumble driver must reject gameplay"); pad.report_parser.play_dual_rumble = observe_dualsense_rumble; - require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "epoch block must queue"); + require(submit_native_magnitudes(0, &game, 1), "epoch block must queue"); during_dualsense_dispatch = [] { bluepad32_input_backend_select_native_source(0, nullptr); }; process_rumble_timer(&g_rumble_timer); during_dualsense_dispatch = nullptr; process_rumble_timer(&g_rumble_timer); require(pad.last_rumble_duration_ms == 0, "same-source reselection must stop an in-flight retired epoch"); - require(bluepad32_input_backend_native_rumble_submit(1, &game, 1), "disconnect block must queue"); + require(submit_native_magnitudes(1, &game, 1), "disconnect block must queue"); process_rumble_timer(&g_rumble_timer); platform_on_device_disconnected(&pad); require(pad.last_rumble_duration_ms == 0 && - !bluepad32_input_backend_native_rumble_submit(0, &game, 1), + !submit_native_magnitudes(0, &game, 1), "disconnect must retire the driver's finite timer and refuse new work"); pad = dualsense(0); platform_on_device_connected(&pad); @@ -806,7 +823,7 @@ void gameplay_source_epochs() { require(pad.rumble_calls == 0, "slot memory reuse cannot inherit old samples or stop obligations"); controller_profile_runtime_reset(); during_profile_resolution = [] { bluepad32_input_backend_select_native_source(0, nullptr); }; - require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1), + require(!submit_native_magnitudes(0, &game, 1), "source generation must be rechecked after unlocked profile callbacks"); during_profile_resolution = nullptr; process_rumble_timer(&g_rumble_timer); @@ -825,8 +842,10 @@ void gameplay_profile_gain() { require(runtime_profile_storage.set(identity, 0, profile) == ProfileStorageResult::kOk, "profile must configure independent host motor gains"); const uint8_t maximum = 255; - require(bluepad32_input_backend_native_rumble_submit(0, &maximum, 1) && - bluepad32_input_backend_native_rumble_submit(1, &maximum, 1), "scaled gameplay must queue"); + const NativeHapticsActuatorFrame conventional{1, {{1023, 0, 512, 1023}}}; + require(bluepad32_input_backend_native_rumble_submit(0, &conventional) && + bluepad32_input_backend_native_rumble_submit(1, &conventional), + "conventional motors must ignore carrier codes they do not render"); process_rumble_timer(&g_rumble_timer); require(pad.last_high == 128 && pad.last_low == 64, "right/weak and left/strong magnitudes must use their matching persisted profile gains"); @@ -834,8 +853,8 @@ void gameplay_profile_gain() { profile.strong_rumble_scale = 0; require(runtime_profile_storage.set(identity, 0, profile) == ProfileStorageResult::kOk, "profile mute must update its generation"); - require(bluepad32_input_backend_native_rumble_submit(0, &maximum, 1) && - bluepad32_input_backend_native_rumble_submit(1, &maximum, 1), "muted gameplay must queue"); + require(submit_native_magnitudes(0, &maximum, 1) && + submit_native_magnitudes(1, &maximum, 1), "muted gameplay must queue"); process_rumble_timer(&g_rumble_timer); require(pad.last_high == 0 && pad.last_low == 0 && pad.last_rumble_duration_ms == 0, "muting a profile must stop live host output rather than retaining unscaled samples"); @@ -854,7 +873,7 @@ void gameplay_two_pairs() { platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "both gameplay pairs must connect"); const uint8_t values[] = {31, 62, 93, 124}; for (uint8_t instance = 0; instance < 4; ++instance) - require(bluepad32_input_backend_native_rumble_submit(instance, values + instance, 1), + require(submit_native_magnitudes(instance, values + instance, 1), "each gameplay child must bind its own pair"); process_rumble_timer(&g_rumble_timer); require(first.last_high == 31 && first.last_low == 62 && @@ -886,11 +905,11 @@ void gameplay_paired_revision() { uni_hid_device_t* pad, uint16_t delay, uint16_t duration, uint8_t weak, uint8_t strong) { play_rumble(pad, delay, duration, weak, strong); const uint8_t fresh = 150; - require(bluepad32_input_backend_native_rumble_submit(1, &fresh, 1), + require(submit_native_magnitudes(1, &fresh, 1), "right dispatch may accept a newer left revision"); }; const uint8_t game = 75; - require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "paired gameplay must queue"); + require(submit_native_magnitudes(0, &game, 1), "paired gameplay must queue"); const int previous_left = left.rumble_calls; process_rumble_timer(&g_rumble_timer); require(right.last_high == 75 && right.last_low == 75 && left.rumble_calls == previous_left, @@ -900,7 +919,7 @@ void gameplay_paired_revision() { require(left.last_high == 150 && left.last_low == 150, "the newer paired-side revision must remain pending until its real driver dispatch"); const uint8_t stop = 0; - require(bluepad32_input_backend_native_rumble_submit(0, &stop, 1), "paired right stop must queue"); + require(submit_native_magnitudes(0, &stop, 1), "paired right stop must queue"); process_rumble_timer(&g_rumble_timer); require(right.last_rumble_duration_ms == 0 && left.last_high == 150 && left.last_rumble_duration_ms == 50, "paired stop must preserve only the live sibling contribution"); @@ -912,8 +931,8 @@ void gameplay_wii() { remote.report_parser.play_dual_rumble = observe_mono_rumble; require(platform_on_device_ready(&remote) == UNI_ERROR_SUCCESS, "Wii GAMEPAD source must connect"); const uint8_t right = 70, left = 140; - require(bluepad32_input_backend_native_rumble_submit(0, &right, 1) && - bluepad32_input_backend_native_rumble_submit(1, &left, 1), "Wii contributions must queue"); + require(submit_native_magnitudes(0, &right, 1) && + submit_native_magnitudes(1, &left, 1), "Wii contributions must queue"); native_wii_ready = false; process_rumble_timer(&g_rumble_timer); require(remote.rumble_calls == 0, "Wii topology setup must not consume pending output"); @@ -1316,11 +1335,321 @@ void stable_ble_reservation() { "resolving a new BLE connection address must recover the original pair reservation without reviving cached controls"); } +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT +uni_hid_device_t hd_dualsense(int index) { + auto pad = dualsense(index); + pad.conn.connected = true; + pad.conn.interrupt_cid = static_cast(0x80 + 2 * index); + return pad; +} + +NativeHapticsActuatorFrame native_waveform(uint8_t count = 3) { + return {count, {{387, 484, 870, 321}, {411, 509, 439, 731}, {433, 538, 680, 511}}}; +} + +NativeHapticsActuatorFrame scale_native_fixture( + NativeHapticsActuatorFrame frame, uint8_t low, uint8_t high) { + for (uint8_t sample = 0; sample < frame.sample_count; ++sample) { + frame.samples[sample].low_amplitude = static_cast( + (uint32_t{frame.samples[sample].low_amplitude} * low + 127u) / 255u); + frame.samples[sample].high_amplitude = static_cast( + (uint32_t{frame.samples[sample].high_amplitude} * high + 127u) / 255u); + } + return frame; +} + +void compare_native_packets(SwitchHdRumbleSynth& reference, size_t& cursor, uint16_t cid) { + while (cursor < native_packets.size()) { + const auto& packet = native_packets[cursor++]; + require(packet.cid == cid, "PCM must target the selected physical interrupt CID"); + if (packet.data[3] != 0x91) continue; // Audio setup has no PCM interval. + require(packet.data[12] == 0x92 && packet.data[13] == 64, + "native HD must retain the guarded 32-frame packet format"); + uint8_t expected[64]{}; + reference.render(uint64_t{packet.data[11]} * 32, 32, expected); + require(memcmp(expected, packet.data.data() + 14, sizeof(expected)) == 0, + "PCM must preserve native timing, both full-precision bands, profile gains and side identity"); + } +} + +void hd_second_pair(bool first_is_wii) { + start_pairing_backend(); + initialize_runtime_profile_storage(); + auto first = first_is_wii ? wii_device(0) : device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto selected = hd_dualsense(2); + selected.outgoing_buffer.queued = 1; + require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS && + platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS, + "mixed controllers must bind A and B while HD selects physical slot one"); + const uint32_t generation = g_slots[1].connection_generation; + require(haptics_experiment_native_selected(1, generation), + "auto-arm pending ownership must include a nonzero physical slot"); + const auto identity = identity_for_device(&selected); + auto profile = controller_profile_default(identity, 0); + profile.strong_rumble_scale = 64; + profile.weak_rumble_scale = 128; + require(runtime_profile_storage.set(identity, 0, profile) == ProfileStorageResult::kOk, + "HD profile gains must be persisted before host ingress"); + auto right = native_waveform(); + const auto left = native_waveform(2); + const auto scaled_right = scale_native_fixture(right, 64, 128); + now_ms = 100; + now_sub_ms_us = 137; + const uint64_t right_us = time_us_64(); + during_profile_resolution = [] { + during_profile_resolution = nullptr; + now_ms += 7; + }; + require(bluepad32_input_backend_native_rumble_submit(2, &right), + "pending selected HD must admit a full native frame before stream startup"); + right.samples[0] = {}; // Submission must own its copy. + const uint64_t left_us = time_us_64(); + require(bluepad32_input_backend_native_rumble_submit(3, &left), + "B left must independently update physical actuator zero"); + const uint8_t conventional = 73; + require(submit_native_magnitudes(0, &conventional, 1), + "non-HD first pair must retain conventional output"); + process_rumble_timer(&g_rumble_timer); + const int compatibility_calls = selected.rumble_calls; + require(first.last_high == conventional && selected.last_high == 0 && selected.last_low == 0, + "only unsupported first-pair gameplay may use compatibility reports"); + selected.outgoing_buffer.queued = 0; + advance_native_backend(25); + HapticsExperimentDiagnostics status{}; + haptics_experiment_snapshot(&status); + require(status.state == HapticsExperimentState::kRunning && status.slot == 1 && + status.host_updates == 2 && selected.rumble_calls == compatibility_calls, + "pending native frames must reach the real selected PCM queue without double writes"); + SwitchHdRumbleSynth reference; + reference.reset(status.start_us); + NativeHapticsFrame stereo{}; + stereo.actuators[1] = scaled_right; + require(reference.push_native(stereo, right_us), "reference right timeline must accept original receipt"); + stereo = {}; + stereo.actuators[0] = scale_native_fixture(left, 64, 128); + require(reference.push_native(stereo, left_us), "reference left timeline must accept independent receipt"); + size_t cursor = 0; + compare_native_packets(reference, cursor, selected.conn.interrupt_cid); + bluepad32_input_backend_native_rumble_cancel(0); + advance_native_backend(5); + compare_native_packets(reference, cursor, selected.conn.interrupt_cid); + bluepad32_input_backend_native_rumble_cancel(2); + reference.cancel_native(2); + advance_native_backend(10); + compare_native_packets(reference, cursor, selected.conn.interrupt_cid); + advance_native_backend(40); + compare_native_packets(reference, cursor, selected.conn.interrupt_cid); + require_native_channels(false, false); + require(selected.rumble_calls == compatibility_calls, + "watchdog and side cancellation must remain PCM-only"); + auto invalid = native_waveform(1); + invalid.samples[0].low_frequency_code = 671; + require(!bluepad32_input_backend_native_rumble_submit(2, &invalid), + "malformed active native carriers cannot mutate the selected stream"); + require(haptics_experiment_request(0, 1), "selected HD stream must accept stop"); + require(!bluepad32_input_backend_native_rumble_submit(2, &left), + "selected stopping HD must reject rather than queue compatibility fallback"); + process_rumble_timer(&g_rumble_timer); + require(haptics_experiment_native_selected(1, generation) && + !bluepad32_input_backend_native_rumble_submit(3, &left) && + selected.last_high == 0 && selected.last_low == 0, + "drain/restoration must reject host fallback while allowing the compatibility zero stop"); + advance_native_backend(30); + require(selected.last_high == 0 && selected.last_low == 0, + "compatibility restoration cannot replay rejected native host work"); +} + +void hd_unselected_dualsense() { + start_pairing_backend(); + auto selected = hd_dualsense(0); + auto unselected = hd_dualsense(1); + require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS && + platform_on_device_ready(&unselected) == UNI_ERROR_SUCCESS, + "two DualSenses must retain the existing single selected HD policy"); + advance_native_backend(30); + const uint8_t magnitude = 93; + require(submit_native_magnitudes(2, &magnitude, 1), + "unselected DualSense must accept conventional native gameplay"); + process_rumble_timer(&g_rumble_timer); + require(unselected.rumble_calls == 1 && unselected.last_high == magnitude && + last_native_cid == selected.conn.interrupt_cid, + "later DualSense must not steal HD or lose bounded compatibility"); + require_native_channels(false, false); +} + +void hd_cues() { + start_pairing_backend(); + auto first = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto selected = hd_dualsense(1); + require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS && + platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS, + "HD cue source must occupy the second pair"); + advance_native_backend(30); + const int compatibility_calls = selected.rumble_calls; + uint64_t cue; + require(bluepad32_input_backend_native_sample_request(2, 1, &cue), + "right built-in cue must enter its bounded scheduler"); + process_rumble_timer(&g_rumble_timer); + require(bluepad32_input_backend_native_sample_result(2, cue) == 0, + "overlay admission must not masquerade as source-driver completion"); + advance_native_backend(20); + require_native_channels(false, true); + require(bluepad32_input_backend_native_sample_result(2, cue) == 1, + "successful later PCM packet submission must complete the built-in cue"); + const auto left = native_waveform(1); + require(bluepad32_input_backend_native_rumble_submit(3, &left), + "left host waveform must coexist with a right local cue"); + advance_native_backend(15); + require_native_channels(true, true); + bluepad32_input_backend_native_sample_cancel(2); + process_rumble_timer(&g_rumble_timer); + advance_native_backend(25); // Drain the 32-frame lookback and the next send interval. + require_native_channels(true, false); + const uint32_t generation = g_slots[1].connection_generation; + bluepad32_input_backend_queue_profile_feedback( + 1, generation, 1, ControllerProfileConfirmationPolicy::kRumble); + advance_native_backend(20); + require_native_channels(true, true); + bluepad32_input_backend_native_rumble_cancel(2); + bluepad32_input_backend_native_rumble_cancel(3); + advance_native_backend(10); + require_native_channels(true, true); + advance_native_backend(250); // Finish both 75 ms confirmation pulses and their gap. + require_native_channels(false, false); + require(bluepad32_input_backend_native_sample_request(2, 3, &cue), + "short cue must enter the same real PCM scheduler"); + process_rumble_timer(&g_rumble_timer); + now_ms += 100; // No transport callback covers the first 25 ms cue pulse. + advance_native_backend(12); + require(bluepad32_input_backend_native_sample_result(2, cue) == -1, + "a successful late packet beyond the admitted pulse must not falsely acknowledge that cue"); + require(selected.rumble_calls == compatibility_calls, + "local cues, profile feedback and USB side cancellation must not emit compatibility writes"); +} + +void hd_reselection() { + start_pairing_backend(); + auto first = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto selected = hd_dualsense(1); + require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS && + platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS, + "HD epoch source must occupy the second pair"); + advance_native_backend(30); + auto wave = native_waveform(1); + require(bluepad32_input_backend_native_rumble_submit(2, &wave) && + bluepad32_input_backend_native_rumble_submit(3, &wave), + "both selected actuator timelines must start"); + advance_native_backend(15); + HapticsExperimentDiagnostics original{}; + haptics_experiment_snapshot(&original); + bluepad32_input_backend_select_native_source(0, first.conn.btaddr); + advance_native_backend(10); + require_native_channels(true, true); + require(haptics_experiment_native_selected(1, original.connection_generation), + "first-pair source epochs must not invalidate selected second-pair HD"); + bluepad32_input_backend_select_native_source(1, selected.conn.btaddr); + require(!bluepad32_input_backend_native_rumble_submit(2, &wave), + "Core0 epoch migration must reject until BT attachment sync, not fall back"); + advance_native_backend(20); + HapticsExperimentDiagnostics current{}; + haptics_experiment_snapshot(¤t); + require(current.run_id == original.run_id && + current.connection_generation != original.connection_generation && + haptics_experiment_native_selected(1, current.connection_generation), + "logical reselection must synchronize generation without losing selected HD"); + require_native_channels(false, false); + NativeHapticsFrame stereo{}; + stereo.actuators[1] = wave; + require(!haptics_experiment_submit_native(1, original.connection_generation, time_us_64(), stereo) && + bluepad32_input_backend_native_rumble_submit(2, &wave), + "old attachment generation must reject while fresh host work resumes"); + platform_on_device_disconnected(&selected); + auto replacement = hd_dualsense(2); + memcpy(replacement.conn.btaddr, selected.conn.btaddr, 6); + require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS, + "replacement selected identity must reconnect at another physical index"); + advance_native_backend(30); + require_native_channels(false, false); + require(!haptics_experiment_submit_native(1, current.connection_generation, time_us_64(), stereo), + "replacement must never inherit old-generation PCM work"); +} + +void hd_delayed_cue() { + start_pairing_backend(); + auto first = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto selected = hd_dualsense(1); + require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS && + platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS, + "delayed cue must use the selected second-pair PCM stream"); + advance_native_backend(30); + now_ms = 43; + uint64_t token; + require(bluepad32_input_backend_native_sample_request(2, 3, &token), + "short native cue must be admitted"); + NativeGamepadCueDispatch command{}; + state_lock_enter(); + const bool prepared = prepare_native_cues(1, now_ms, false, false, &command); + state_lock_exit(); + require(prepared, "short cue must prepare for dispatch"); + now_ms += 4; + dispatch_native_cues(command); + // The 25 ms pulse prepared at 43 ms actually runs [47,68), not [47,72). + // Skip transport until the successful PCM block covers [69,79.666) ms. + now_ms = 80; + advance_native_backend(1); + process_rumble_timer(&g_rumble_timer); + require_native_channels(false, false); + require(bluepad32_input_backend_native_sample_result(2, token) == -1, + "a packet after the shortened pulse must not acknowledge an undelivered cue"); +} + +void hd_admission_cancel() { + start_pairing_backend(); + auto first = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto selected = hd_dualsense(1); + require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS && + platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS, + "cancellation race must use the selected second-pair PCM stream"); + advance_native_backend(30); + const auto wave = native_waveform(1); + require(bluepad32_input_backend_native_rumble_submit(3, &wave), + "unrelated left waveform must start before the race"); + advance_native_backend(15); + after_backend_state_unlock = [] { + static unsigned unlocks = 0; + if (++unlocks == 2) { + // Retire the right side immediately after the final backend check. + after_backend_state_unlock = nullptr; + bluepad32_input_backend_native_rumble_cancel(2); + } + }; + require(bluepad32_input_backend_native_rumble_submit(2, &wave), + "the waveform can be accepted before its concurrent cancellation"); + advance_native_backend(25); + require_native_channels(true, false); + bluepad32_input_backend_native_rumble_cancel(3); + bluepad32_input_backend_native_rumble_cancel(2); + require(bluepad32_input_backend_native_rumble_submit(2, &wave), + "a fresh waveform after cancellation returns must remain admissible"); + advance_native_backend(25); + require_native_channels(false, true); +} +#endif + } // namespace int main(int argc, char** argv) { require(argc == 2, "scenario required"); const std::string scenario = argv[1]; +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (scenario == "hd-second-pair-wii") { hd_second_pair(true); return 0; } + if (scenario == "hd-second-pair-other") { hd_second_pair(false); return 0; } + if (scenario == "hd-unselected-dualsense") { hd_unselected_dualsense(); return 0; } + if (scenario == "hd-cues") { hd_cues(); return 0; } + if (scenario == "hd-reselection") { hd_reselection(); return 0; } + if (scenario == "hd-delayed-cue") { hd_delayed_cue(); return 0; } + if (scenario == "hd-admission-cancel") { hd_admission_cancel(); return 0; } +#endif if (scenario == "source-isolation") source_isolation(); else if (scenario == "cue-lifetime") cue_lifetime(); else if (scenario == "cue-races") cue_races(); diff --git a/tests/native_hub_log_test.c b/tests/native_hub_log_test.c index 9e0154b..cb54412 100644 --- a/tests/native_hub_log_test.c +++ b/tests/native_hub_log_test.c @@ -25,6 +25,7 @@ static unsigned completions, missed_tokens; static uint32_t phase; static char serial_bytes[2 * LOG_CAPACITY]; static size_t serial_size; +static bool uart_backpressure; void native_test_service_interrupt(void) { if (pending_completion && !native_test_interrupt_mask) { @@ -47,9 +48,21 @@ uint32_t native_hub_trace_phase(uint32_t next) { return previous; } -bool uart_is_writable(void* uart) { (void)uart; return serial_size < sizeof(serial_bytes); } +bool uart_is_writable(void* uart) { (void)uart; return !uart_backpressure && serial_size < sizeof(serial_bytes); } void uart_putc_raw(void* uart, char value) { (void)uart; serial_bytes[serial_size++] = value; } +static void blocking_stdio_out(const char* bytes, int length) { + assert(!uart_backpressure && "stdio waited for UART instead of queuing"); + for (int i = 0; i < length; ++i) uart_putc_raw(uart0,bytes[i]); +} +static void blocking_stdio_flush(void) { + assert(!uart_backpressure && "stdio flush waited for UART"); +} +stdio_driver_t stdio_uart = { + .out_chars = blocking_stdio_out, + .out_flush = blocking_stdio_flush, +}; + int main(int argc, char** argv) { if (argc == 2) { if (strcmp(argv[1], "core") == 0) test_core = 1; @@ -99,6 +112,43 @@ int main(int argc, char** argv) { drain_log(); assert(serial_size == strlen("caller owns mask")); assert(memcmp(serial_bytes, "caller owns mask", serial_size) == 0); + + // Pico libc/Bluepad32 output must share the existing ordered queue. UART + // backpressure cannot stall radio polling or prevent USB IRQ completion. + buffer_uart_stdio(); + serial_size = 0; + log_read = log_written = LOG_CAPACITY - 5; + missed_tokens = 0; + uart_backpressure = true; + stdio_uart.out_chars("radio first\n",12); + assert(probe_debug_printf("probe\n") == 6); + stdio_uart.out_chars("radio last\n",11); + if (stdio_uart.out_flush) stdio_uart.out_flush(); + drain_log(); + assert(serial_size == 0 && !pending_completion && missed_tokens == 0); + uart_backpressure = false; + drain_log(); + const char expected[] = "radio first\nprobe\nradio last\n"; + assert(serial_size == sizeof(expected)-1); + assert(memcmp(serial_bytes,expected,serial_size) == 0); + + // Reject a whole stdout chunk when full; queued bytes remain intact. + inject_completion = false; + serial_size = 0; log_read = 0; log_written = LOG_CAPACITY; + memset(log_bytes,'s',sizeof(log_bytes)); + uint32_t dropped = log_dropped; + stdio_uart.out_chars("overflow",8); + drain_log(); + assert(serial_size == LOG_CAPACITY && log_dropped == dropped+8); + for (size_t i = 0; i < serial_size; ++i) assert(serial_bytes[i] == 's'); + + // Panic/IRQ stdio must not recurse into assertions or corrupt this + // foreground-only ring. Count those discarded bytes instead. + serial_size = 0; dropped = log_dropped; + test_core = 1; stdio_uart.out_chars("core",4); test_core = 0; + test_exception = 16; stdio_uart.out_chars("irq",3); test_exception = 0; + drain_log(); + assert(serial_size == 0 && log_dropped == dropped+7); puts("native logging preserved USB progress, message order and caller IRQ state"); return 0; } diff --git a/tests/native_hub_router_test.c b/tests/native_hub_router_test.c index a9b50e3..f0aedfa 100644 --- a/tests/native_hub_router_test.c +++ b/tests/native_hub_router_test.c @@ -1,10 +1,11 @@ #include "hardware_stub.h" +#include "router.h" #include #include -// The real router tables and token-header decision run on the host. Only the -// clock/pad registers and the SIE bank-selection receiver are modeled here; -// the timing loop is compiled but never run against a simulated USB wire. +// The real router tables, deadline sampler and token-header decision run on +// the host. Scripted register reads exercise timing boundaries, not physical +// pad latency, instruction timing or USB signal integrity. #define PICO_RP2350 1 #undef SIO_GPIO_HI_IN_USB_DP_BITS #undef SIO_GPIO_HI_IN_USB_DM_BITS @@ -15,11 +16,34 @@ #define __wfe() ((void)0) #define __dsb() ((void)0) #define __isb() ((void)0) -static struct { +typedef struct { volatile uint32_t mtime, mtimeh, mtimecmp, mtimecmph, mtime_ctrl, gpio_hi_in; -} router_test_sio; +} router_test_registers; +static router_test_registers router_test_sio; +enum { MANUAL_READS, IDLE_READS, DRAIN_READS }; +static unsigned read_mode, register_reads, nonidle_read; +static uint32_t simulated_start, simulated_elapsed; + +static router_test_registers* router_test_read_registers(void) { + if (read_mode == IDLE_READS) { + ++register_reads; + router_test_sio.mtime = simulated_start + + simulated_elapsed * (register_reads - 1u) / (FS_IDLE_POLLS + 1u); + router_test_sio.gpio_hi_in = (register_reads == nonidle_read ? 2u : 1u) << 24; + } else if (read_mode == DRAIN_READS) { + ++register_reads; + // SE0 is observed at zero and returns to J after the requested delay. + // A subsequent K either becomes SOP after qualified EOP or remains + // untrusted packet data until the real capture drain timeout expires. + router_test_sio.mtime = register_reads < 4u ? 0u : + register_reads < 9u ? simulated_elapsed : FS_CLOCK_HZ / 10000u; + router_test_sio.gpio_hi_in = + (register_reads < 4u ? 0u : register_reads < 7u ? 1u : 2u) << 24; + } + return &router_test_sio; +} #undef sio_hw -#define sio_hw (&router_test_sio) +#define sio_hw router_test_read_registers() #include "router.c" usb_hw_t native_test_usb; @@ -37,6 +61,15 @@ bool native_hub_select_device(uint8_t address, uint8_t owner, uint32_t cutoff) { return accept_selection; } +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) +void native_hub_note_selected_token(uint8_t address, uint8_t owner, uint32_t cutoff, uint8_t pid) { + (void)address; (void)owner; (void)cutoff; (void)pid; +} +void native_hub_note_failed_select(uint8_t address, uint8_t owner, uint32_t cutoff, uint8_t pid) { + (void)address; (void)owner; (void)cutoff; (void)pid; +} +#endif + static const routing_table* current_table(void) { uint32_t generation; return acquire_table(&generation); @@ -99,7 +132,163 @@ static void expect_prefixes(const routing_table* table, const uint8_t* addresses } } +static void test_clock_and_phase_guards(void) { + const uint32_t bad_clocks[] = { + FS_CLOCK_HZ == 240000000u ? 300000000u : 240000000u, + FS_CLOCK_HZ + 1u, + 150000000u, + }; + for (unsigned i = 0; i < sizeof(bad_clocks) / sizeof(bad_clocks[0]); ++i) { + probe_router_init(bad_clocks[i]); + assert(!probe_router_set_phase(0)); + // Even a stale ready flag cannot arm a differently compiled receiver. + counters.ready = 1; + probe_router_enable(true); + selections = 0; + raw_packet packet = {0}; + route_header(current_table(),0,TOKEN_SETUP_SIGNATURE,127,100,&packet); + assert(!selections && !packet.retargets); + } + + probe_router_init(FS_CLOCK_HZ); + assert(probe_router_set_phase(0)); + assert(probe_router_set_phase(FS_BIT_CYCLES - 1u)); + assert(!probe_router_set_phase(FS_BIT_CYCLES)); + assert(!probe_router_set_phase(UINT32_MAX)); + counters.ready = 1; + probe_router_enable(true); + assert(!probe_router_set_phase(0)); + probe_router_enable(false); + assert(probe_router_set_phase(0)); +} + +static void test_sample_deadlines(void) { + const uint32_t bit_cycles = FS_CLOCK_MHZ == 300u ? 25u : 20u; + uint32_t deadline = 1000u, line = LINE_SE1; + router_test_sio.mtime = deadline; + router_test_sio.gpio_hi_in = LINE_J << 24; + assert(sample_line(&deadline,&line)); + assert(line == LINE_J && deadline == 1000u + bit_cycles); + + router_test_sio.mtime = deadline + bit_cycles - 1u; + router_test_sio.gpio_hi_in = LINE_K << 24; + assert(sample_line(&deadline,&line)); + assert(line == LINE_K && deadline == 1000u + 2u * bit_cycles); + + router_test_sio.mtime = deadline + bit_cycles; + router_test_sio.gpio_hi_in = LINE_J << 24; + assert(!sample_line(&deadline,&line)); + assert(line == LINE_K && deadline == 1000u + 2u * bit_cycles); + + deadline = UINT32_MAX - bit_cycles + 1u; + router_test_sio.mtime = deadline; + assert(sample_line(&deadline,&line)); + assert(line == LINE_J && deadline == 0u); + router_test_sio.mtime = bit_cycles - 1u; + assert(sample_line(&deadline,&line) && deadline == bit_cycles); +} + +static void test_drain_qualification(void) { + const uint32_t bit_cycles = FS_CLOCK_MHZ == 300u ? 25u : 20u; + read_mode = IDLE_READS; + simulated_start = UINT32_MAX - 100u; + simulated_elapsed = 8u * bit_cycles - 1u; + register_reads = nonidle_read = 0; + assert(!observe_idle_j()); + simulated_elapsed = 8u * bit_cycles; + register_reads = 0; + assert(observe_idle_j()); + register_reads = 0; + nonidle_read = FS_IDLE_POLLS / 2u; + assert(!observe_idle_j()); + + read_mode = DRAIN_READS; + simulated_elapsed = (bit_cycles + 1u) / 2u - 1u; + register_reads = 0; + raw_packet packet = capture_packet(PROBE_ROUTER_DEFAULT_PHASE,current_table(),true); + assert(!packet.sop && packet.resync); + simulated_elapsed = (bit_cycles + 1u) / 2u; + register_reads = 0; + packet = capture_packet(PROBE_ROUTER_DEFAULT_PHASE,current_table(),true); + assert(packet.sop && packet.late); + read_mode = MANUAL_READS; +} + +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) +static void test_root_response_attribution(void) { + const uint32_t nak = 0x96a5a666u, data1 = 0x965aa666u; + probe_router_stats snapshot; + probe_router_init(FS_CLOCK_HZ); + counters.ready = 1; + probe_router_enable(true); + uint8_t addresses[PROBE_ROUTER_SLOTS]; + for (unsigned i = 0; i < PROBE_ROUTER_SLOTS; ++i) addresses[i] = 5u+i; + probe_router_publish(addresses,PROBE_ROUTER_UNASSIGNED); + const routing_table* table = current_table(); + raw_packet packet = {0}; + + // Two decisions for one physical token must retain a single observation. + route_header(table,5,TOKEN_IN_SIGNATURE,0,1000,&packet); + route_header(table,5,TOKEN_IN_SIGNATURE,0,1000,&packet); + router_test_sio.mtime = 1100; + observe_discarded_header(nak); + root_observe_eop(1200,true); + probe_router_snapshot(&snapshot); + assert(snapshot.root_in_count == 1 && snapshot.root_header == nak); + assert(snapshot.root_header_cycle == 1100 && snapshot.root_eop_cycle == 1200); + + // Recovery's own response cannot erase the pre-SETUP NAK observation. + route_header(table,5,TOKEN_SETUP_SIGNATURE,0,2000,&packet); + route_header(table,5,TOKEN_IN_SIGNATURE,0,2200,&packet); + router_test_sio.mtime = 2250; + observe_discarded_header(data1); + root_observe_eop(2300,true); + probe_router_snapshot(&snapshot); + assert(snapshot.root_header == data1); + assert(snapshot.before_setup_in_count == 1 && snapshot.before_setup_in_cutoff == 1000); + assert(snapshot.before_setup_header == nak && snapshot.before_setup_header_cycle == 1100); + assert(snapshot.before_setup_eop_cycle == 1200); + + // A child token, even a rejected or unmapped one, ends root attribution. + for (unsigned kind = 0; kind < 3; ++kind) { + route_header(table,5,TOKEN_IN_SIGNATURE,0,3000+kind*1000,&packet); + accept_selection = kind != 2; + route_header(table,kind == 1 ? 127 : 6,TOKEN_IN_SIGNATURE,0,3100+kind*1000,&packet); + accept_selection = true; + observe_discarded_header(nak); + root_observe_eop(3200+kind*1000,true); + probe_router_snapshot(&snapshot); + assert(snapshot.root_header == 0 && snapshot.root_eop_cycle == 0); + } + + // Idle qualification is not an observed EOP; a later packet cannot fill it. + route_header(table,5,TOKEN_IN_SIGNATURE,0,6000,&packet); + observe_discarded_header(nak); + root_observe_eop(6100,false); + root_observe_eop(6200,true); + probe_router_snapshot(&snapshot); + assert(snapshot.root_header == nak && snapshot.root_eop_cycle == 0); + + counters.root_in_count = UINT32_MAX; + route_header(table,5,TOKEN_IN_SIGNATURE,0,7000,&packet); + route_header(table,5,TOKEN_IN_SIGNATURE,0,7000,&packet); + probe_router_snapshot(&snapshot); + assert(snapshot.root_in_count == 0); + + probe_router_init(FS_CLOCK_HZ); + observe_discarded_header(nak); + probe_router_snapshot(&snapshot); + assert(snapshot.root_header == 0 && snapshot.before_setup_header == 0); +} +#endif + int main(void) { + test_clock_and_phase_guards(); + test_sample_deadlines(); + test_drain_qualification(); +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + test_root_response_attribution(); +#endif probe_router_init(FS_CLOCK_HZ); // Simulate observer readiness, not USB timing; this enables the actual // routing decision without starting the hardware-bound sampling loop. diff --git a/tests/native_hub_startup_test.c b/tests/native_hub_startup_test.c index 2cb0d46..37700ca 100644 --- a/tests/native_hub_startup_test.c +++ b/tests/native_hub_startup_test.c @@ -4,6 +4,9 @@ #include "hardware_stub.h" +static uint32_t startup_clock_hz = NATIVE_TEST_SYS_CLOCK_HZ; +#define clock_get_hz(clock) ((void)(clock), startup_clock_hz) + static void startup_set_bits(volatile uint32_t* address, uint32_t bits); static void startup_clear_bits(volatile uint32_t* address, uint32_t bits); static void startup_reset(uint32_t mask); @@ -195,7 +198,7 @@ static void startup_wait(void) { void probe_router_init(uint32_t hz) { observe_pullup(); - assert(hz == 240000000u); + assert(hz == FS_CLOCK_HZ); observer_initialized = true; observer_start_us = native_test_time_us; memset(routed_addresses,NONE,sizeof(routed_addresses)); @@ -217,7 +220,7 @@ void probe_router_enable(bool enabled) { } bool probe_router_set_phase(uint32_t phase) { - (void)phase; + assert(phase == PROBE_ROUTER_DEFAULT_PHASE && phase < FS_BIT_CYCLES); observe_pullup(); return true; } @@ -247,6 +250,10 @@ int main(int argc, char** argv) { assert(argc == 2); if (strcmp(argv[1],"delayed") == 0) ready_delay_us = 75000u; else if (strcmp(argv[1],"timeout") == 0) observer_never_ready = true; + else if (strcmp(argv[1],"mismatched-clock") == 0) + startup_clock_hz = FS_CLOCK_HZ == 240000000u ? 300000000u : 240000000u; + else if (strcmp(argv[1],"unsupported-clock") == 0) startup_clock_hz = 150000000u; + else if (strcmp(argv[1],"inexact-clock") == 0) startup_clock_hz = FS_CLOCK_HZ + 1u; else assert(strcmp(argv[1],"ready") == 0); // No native_test_initialize/startup helper: call the actual initializer @@ -254,6 +261,13 @@ int main(int argc, char** argv) { assert(!physical_pullup()); bool initialized = native_hub_init(); observe_pullup(); + if (startup_clock_hz != FS_CLOCK_HZ) { + assert(!initialized && !started && !physical_pullup()); + assert(!attach_edges && !detach_edges && !enumeration_done && !setup_irqs); + assert(!controller_resets && !observer_initialized && !observer_launched); + assert(!irq_enabled && !addresses_published && !routing_enabled && !wait_us); + return 0; + } assert(controller_resets == 1 && detach_edges == 0); if (observer_never_ready) { assert(!initialized && !started && !physical_pullup()); diff --git a/tests/native_hub_stubs/hardware_stub.h b/tests/native_hub_stubs/hardware_stub.h index c822a8a..09cdf87 100644 --- a/tests/native_hub_stubs/hardware_stub.h +++ b/tests/native_hub_stubs/hardware_stub.h @@ -10,6 +10,11 @@ #define __dmb() ((void)0) typedef struct { unsigned unused; } spin_lock_t; +typedef struct { + void (*out_chars)(const char*, int); + void (*out_flush)(void); +} stdio_driver_t; +extern stdio_driver_t stdio_uart; extern uint32_t native_test_interrupt_mask; void native_test_service_interrupt(void); static inline uint32_t save_and_disable_interrupts(void) { @@ -51,7 +56,7 @@ typedef struct { uint8_t ep0_buf_a[64]; uint8_t padding[3776]; } usb_device_dpram_t; -typedef struct { volatile uint32_t mtime, gpio_hi_oe_clr; } sio_hw_t; +typedef struct { volatile uint32_t mtime, gpio_hi_oe_clr, gpio_hi_in; } sio_hw_t; extern usb_hw_t native_test_usb; extern usb_device_dpram_t native_test_dpram; extern sio_hw_t native_test_sio; @@ -104,7 +109,12 @@ static inline void hw_set_bits(volatile uint32_t* address, uint32_t bits) { #define USBCTRL_IRQ 0u #define clk_sys 0u -static inline uint32_t clock_get_hz(unsigned clock) { (void)clock; return 240000000u; } +#ifdef SWITCH_PICO_SYS_CLOCK_MHZ +#define NATIVE_TEST_SYS_CLOCK_HZ (SWITCH_PICO_SYS_CLOCK_MHZ * 1000000u) +#else +#define NATIVE_TEST_SYS_CLOCK_HZ 240000000u +#endif +static inline uint32_t clock_get_hz(unsigned clock) { (void)clock; return NATIVE_TEST_SYS_CLOCK_HZ; } static inline void reset_block(uint32_t mask) { (void)mask; } static inline void unreset_block_wait(uint32_t mask) { (void)mask; } static inline void multicore_launch_core1(void (*entry)(void)) { (void)entry; } diff --git a/tests/native_hub_stubs/pico/stdio/driver.h b/tests/native_hub_stubs/pico/stdio/driver.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/pico/stdio/driver.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/pico/stdio_uart.h b/tests/native_hub_stubs/pico/stdio_uart.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/pico/stdio_uart.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_trace_test.c b/tests/native_hub_trace_test.c index 0b04941..852aafe 100644 --- a/tests/native_hub_trace_test.c +++ b/tests/native_hub_trace_test.c @@ -1437,6 +1437,72 @@ static void approved_status_reset_during_completion(void) { expect_no_control_packets(); } +static bool reset_hook_seen, reset_hook_bus_reset; + +static void service_during_child_reset(uint8_t instance) { + const uint8_t target = CHILDREN > 2 ? 3 : 2; + assert(instance == target-1u); + native_test_reset_hook = NULL; + reset_hook_seen = true; + if (reset_hook_bus_reset) { + native_test_bus_reset(false); + return; + } + // A sibling SETUP can arrive while reset bookkeeping reads stored state. + // Its IRQ must release SETUP_REC before the next root interrupt poll. + const tusb_control_request_t descriptor = descriptor_request(); + assert(native_test_setup(1,&descriptor,false)); + uint8_t packet[PACKET]; + uint16_t length = 0; + assert(native_test_private_in(0,0x8f,packet,&length) && + "child reset callback blocked the next hub status-change poll"); + assert(length == 1 && packet[0] == (1u << target)); +} + +static void port_reset_interrupt_progress(bool bus_reset) { + const uint8_t target = CHILDREN > 2 ? 3 : 2; + uint8_t packet[PACKET]; + uint16_t length = 0; + const tusb_control_request_t prepare[] = { + {.bRequest = TUSB_REQ_SET_ADDRESS, .wValue = 9}, + {.bRequest = TUSB_REQ_SET_CONFIGURATION, .wValue = 1}, + {.bmRequestType = 0x23, .bRequest = TUSB_REQ_SET_FEATURE, + .wValue = 8, .wIndex = target}, + }; + for (unsigned i = 0; i < sizeof(prepare)/sizeof(prepare[0]); ++i) { + assert(native_test_setup(0,&prepare[i],true)); + assert(native_test_in(0,packet,&length,true) && length == 0); + } + const tusb_control_request_t reset = { + .bmRequestType = 0x23, .bRequest = TUSB_REQ_SET_FEATURE, + .wValue = 4, .wIndex = target, + }; + assert(native_test_setup(0,&reset,true)); + reset_hook_bus_reset = bus_reset; + native_test_reset_hook = service_during_child_reset; + assert(native_test_in(0,packet,&length,true) && length == 0); + assert(reset_hook_seen); + const tusb_control_request_t descriptor = descriptor_request(); + if (bus_reset) { + assert(default_device == 0 && addresses[0] == 0 && + devices[0].configuration == 0); + for (unsigned p = 0; p < CHILDREN; ++p) + assert(ports[p].status == 0 && ports[p].change == 0); + assert(native_test_setup(0,&descriptor,true)); + assert(native_test_in(0,packet,&length,true) && length == 18); + assert(native_test_out(0,NULL,0,true)); + return; + } + assert(native_test_in(1,packet,&length,true) && length == 18); + assert(memcmp(packet,hub_device,length) == 0); + assert(native_test_out(1,NULL,0,true)); + native_test_advance(10000u); + assert(default_device == target && addresses[target] == 0); + assert(native_test_setup(target,&descriptor,true)); + assert(native_test_in(target,packet,&length,true) && length == 18); + assert(native_test_out(target,NULL,0,true)); +} + static void approved_status_port_reset(bool queued_status) { const uint8_t target = CHILDREN; uint8_t data[PACKET]; @@ -2208,6 +2274,8 @@ int main(int argc, char** argv) { else if (strcmp(argv[1],"approved-status-reset-during-completion") == 0) approved_status_reset_during_completion(); else if (strcmp(argv[1],"approved-status-port-reset-ready") == 0) approved_status_port_reset(false); else if (strcmp(argv[1],"approved-status-port-reset-queued") == 0) approved_status_port_reset(true); + else if (strcmp(argv[1],"port-reset-interrupt-progress") == 0) port_reset_interrupt_progress(false); + else if (strcmp(argv[1],"port-reset-interrupt-reset") == 0) port_reset_interrupt_progress(true); else if (strcmp(argv[1],"approved-status-invalid-length") == 0) approved_status_invalid_length(); else if (strcmp(argv[1],"approved-status-watch") == 0) approved_status_watch(); else if (strcmp(argv[1],"status-out-rejected-data") == 0) status_out_rejected_data(); diff --git a/tests/native_hub_transport_fixture.c b/tests/native_hub_transport_fixture.c index 7e67f68..f1550a6 100644 --- a/tests/native_hub_transport_fixture.c +++ b/tests/native_hub_transport_fixture.c @@ -14,6 +14,7 @@ uint32_t native_test_received_count[CHILDREN][2]; uint16_t native_test_received_length[CHILDREN][2]; uint8_t native_test_received_data[CHILDREN][2][PACKET]; static bool servicing_interrupt; +static void (*native_test_reset_hook)(uint8_t instance); #ifndef NATIVE_TEST_EXTERNAL_IRQ void native_test_service_interrupt(void) { @@ -42,7 +43,9 @@ const uint8_t* native_joycon_configuration_descriptor(uint8_t instance) { (void) const uint16_t* native_joycon_string_descriptor(uint8_t instance, uint8_t index, uint16_t language) { (void)instance; (void)language; return hub_string(index); } -void native_joycon_usb_reset(uint8_t instance) { (void)instance; } +void native_joycon_usb_reset(uint8_t instance) { + if (native_test_reset_hook) native_test_reset_hook(instance); +} const uint8_t* tud_hid_descriptor_report_cb(uint8_t instance) { (void)instance; return NULL; } uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t id, hid_report_type_t type, uint8_t* data, uint16_t length) { (void)instance; (void)id; (void)type; (void)data; (void)length; return 0; @@ -86,6 +89,7 @@ void native_test_initialize(void) { native_test_abort_stuck = false; native_test_interrupt_mask = 0; servicing_interrupt = false; + native_test_reset_hook = NULL; failed = bus_suspended = false; bank_lock = spin_lock_instance(0); active_device = default_device = 0; diff --git a/tests/switch2_native_gamepad_bridge_test.cpp b/tests/switch2_native_gamepad_bridge_test.cpp index 2da1015..a8d7656 100644 --- a/tests/switch2_native_gamepad_bridge_test.cpp +++ b/tests/switch2_native_gamepad_bridge_test.cpp @@ -76,7 +76,7 @@ void bluepad32_input_backend_native_sample_cancel(uint8_t instance) { assert(instance < PROBE_CONTROLLER_COUNT); cue_tokens[instance] = 0; } -bool bluepad32_input_backend_native_rumble_submit(uint8_t, const uint8_t*, uint8_t) { +bool bluepad32_input_backend_native_rumble_submit(uint8_t, const NativeHapticsActuatorFrame*) { assert(false && "gameplay motor dispatch belongs to the native backend fixture"); return false; } diff --git a/tests/switch2_usb_probe_protocol_test.c b/tests/switch2_usb_probe_protocol_test.c index 3f6fa1d..f537666 100644 --- a/tests/switch2_usb_probe_protocol_test.c +++ b/tests/switch2_usb_probe_protocol_test.c @@ -681,10 +681,21 @@ static void test_indexed_memory(void) { } static void expect_invalid_rumble(uint8_t report_id, const uint8_t* data, size_t length) { - probe_rumble_frame output = {.count = 3, .magnitude = {17, 93, 241}}; + NativeHapticsActuatorFrame output; + memset(&output, 0xa5, sizeof(output)); + NativeHapticsActuatorFrame before; + memcpy(&before, &output, sizeof(before)); assert(!probe_protocol_decode_rumble(report_id, data, length, &output)); - assert(output.count == 3); - assert(output.magnitude[0] == 17 && output.magnitude[1] == 93 && output.magnitude[2] == 241); + assert(memcmp(&output, &before, sizeof(output)) == 0); +} + +static void expect_wave(const NativeHapticsSample* sample, uint16_t low_frequency, + uint16_t high_frequency, uint16_t low_amplitude, + uint16_t high_amplitude) { + assert(sample->low_frequency_code == low_frequency); + assert(sample->high_frequency_code == high_frequency); + assert(sample->low_amplitude == low_amplitude); + assert(sample->high_amplitude == high_amplitude); } static void test_native_rumble(void) { @@ -695,49 +706,52 @@ static void test_native_rumble(void) { {0x50, 0x81, 0x01, 0x10, 0x1e, 0x00}, {0x52, 0x9f, 0x19, 0xe0, 0x9d, 0x00}, }; - probe_rumble_frame output; + NativeHapticsActuatorFrame output; uint8_t wire[65]; for (unsigned i = 0; i < 2; ++i) { memset(wire, 0xa5, sizeof(wire)); wire[0] = 0x01; memcpy(wire + 1, captured_blocks[i], sizeof(captured_blocks[i])); assert(probe_protocol_decode_rumble(0, wire, 64, &output)); - assert(output.count == 1 && output.magnitude[0] == i); + assert(output.sample_count == 1); + expect_wave(&output.samples[0], i ? 415 : 385, i ? 478 : 481, i ? 6 : 0, i ? 2 : 0); assert(probe_protocol_decode_rumble(1, wire + 1, 63, &output)); - assert(output.count == 1 && output.magnitude[0] == i); + assert(output.sample_count == 1); + expect_wave(&output.samples[0], i ? 415 : 385, i ? 478 : 481, i ? 6 : 0, i ? 2 : 0); } // Manually specified byte boundaries, not an encoder/decoder roundtrip. - // Frequencies are both 1023: they must not leak into either amplitude, - // nor be rejected merely because this compatibility decoder ignores them. + // All four fields cross byte boundaries. The decoder preserves codes even + // outside a particular output actuator's renderable frequency range. static const struct { uint8_t sample[5]; - uint8_t expected; + uint16_t low, high; } boundaries[] = { - {{0xff, 0x03, 0xf0, 0x3f, 0x00}, 0}, // amplitudes 0, 0 - {{0xff, 0x0b, 0xf0, 0x3f, 0x00}, 0}, // 2, 0 rounds down - {{0xff, 0x03, 0xf0, 0xff, 0x00}, 1}, // 0, 3 rounds up - {{0xff, 0xff, 0xf0, 0x3f, 0x00}, 16}, // 63, 0 - {{0xff, 0x03, 0xf1, 0x3f, 0x00}, 16}, // 64, 0 - {{0xff, 0xff, 0xf7, 0x3f, 0x80}, 128}, // 511, 512 - {{0xff, 0x03, 0xf8, 0xff, 0x7f}, 128}, // 512, 511 - {{0xff, 0xff, 0xff, 0x3f, 0x00}, 255}, // 1023, 0 - {{0xff, 0x03, 0xf0, 0xff, 0xff}, 255}, // 0, 1023 + {{0xff, 0x03, 0xf0, 0x3f, 0x00}, 0, 0}, + {{0xff, 0x0b, 0xf0, 0x3f, 0x00}, 2, 0}, + {{0xff, 0x03, 0xf0, 0xff, 0x00}, 0, 3}, + {{0xff, 0xff, 0xf0, 0x3f, 0x00}, 63, 0}, + {{0xff, 0x03, 0xf1, 0x3f, 0x00}, 64, 0}, + {{0xff, 0xff, 0xf7, 0x3f, 0x80}, 511, 512}, + {{0xff, 0x03, 0xf8, 0xff, 0x7f}, 512, 511}, + {{0xff, 0xff, 0xff, 0x3f, 0x00}, 1023, 0}, + {{0xff, 0x03, 0xf0, 0xff, 0xff}, 0, 1023}, }; wire[1] = 0x5f; for (unsigned i = 0; i < sizeof(boundaries) / sizeof(boundaries[0]); ++i) { memcpy(wire + 2, boundaries[i].sample, 5); assert(probe_protocol_decode_rumble(0, wire, 17, &output)); - assert(output.count == 1 && output.magnitude[0] == boundaries[i].expected); + assert(output.sample_count == 1); + expect_wave(&output.samples[0], 1023, 1023, boundaries[i].low, boundaries[i].high); } // Three distinguishable samples retain wire order; a shorter count ignores // stale later samples. Both callback envelopes accept minimal/compact/USB sizes. static const uint8_t ordered[16] = { 0x70, - 0x00, 0xfc, 0x0f, 0x00, 0x00, // amplitudes 1023, 0 -> 255 - 0x00, 0x00, 0x00, 0xc0, 0x3f, // amplitudes 0, 255 -> 64 - 0xff, 0xff, 0xf7, 0x3f, 0x80, // amplitudes 511, 512 -> 128 + 0x81, 0x05, 0x18, 0x5e, 0x40, // codes 385/481, amplitudes 513/257 + 0x82, 0x05, 0x18, 0x5e, 0x40, // adjacent frequency survives, no 7-bit quantization + 0x83, 0x05, 0x18, 0x5e, 0x40, }; static const size_t lengths[] = {17, 42, 64}; memcpy(wire + 1, ordered, sizeof(ordered)); @@ -748,9 +762,9 @@ static void test_native_rumble(void) { for (unsigned form = 0; form < 2; ++form) { assert(probe_protocol_decode_rumble((uint8_t)form, wire + form, lengths[i] - form, &output)); - assert(output.count == count && output.magnitude[0] == 255); - if (count >= 2) assert(output.magnitude[1] == 64); - if (count == 3) assert(output.magnitude[2] == 128); + assert(output.sample_count == count); + for (unsigned sample = 0; sample < count; ++sample) + expect_wave(&output.samples[sample], (uint16_t)(385 + sample), 481, 513, 257); } } } @@ -758,9 +772,9 @@ static void test_native_rumble(void) { wire[1] = 0x4f; // HOLD: nonzero stale samples must not become a stop/update. assert(probe_protocol_decode_rumble(0, wire, 17, &output)); - assert(output.count == 0); + assert(output.sample_count == 0); assert(probe_protocol_decode_rumble(1, wire + 1, 16, &output)); - assert(output.count == 0); + assert(output.sample_count == 0); // Complete 16-byte block required even for HOLD or a one-sample update. for (unsigned count = 0; count <= 3; ++count) { diff --git a/tests/switch_hd_rumble_synth_test.cpp b/tests/switch_hd_rumble_synth_test.cpp index ecf9e29..611cf2e 100644 --- a/tests/switch_hd_rumble_synth_test.cpp +++ b/tests/switch_hd_rumble_synth_test.cpp @@ -34,6 +34,21 @@ SwitchHapticsFrame one_side(unsigned side, SwitchHapticsSample sample = state()) return frame; } +NativeHapticsSample native_state(uint16_t low_code = 385, uint16_t low = 1023, + uint16_t high_code = 481, uint16_t high = 0) { + return {low_code, high_code, low, high}; +} + +NativeHapticsFrame native_side(unsigned side, NativeHapticsSample sample = native_state()) { + NativeHapticsFrame frame{}; + frame.actuators[side] = {1, {sample}}; + return frame; +} + +uint16_t native_q15(uint16_t amplitude) { + return static_cast((uint32_t{amplitude} * 32768 + 511) / 1023); +} + std::vector render(SwitchHdRumbleSynth& synth, uint64_t first, uint32_t frames) { std::vector pcm(static_cast(frames) * 2, 0xcc); @@ -556,6 +571,194 @@ void test_duplicate_order_and_invalid_frames() { }, "duplicate timestamp last-wins without phase reset or malformed-state mutation"); } +void test_native_precision_and_bands() { + for (uint16_t code : {1, 193, 385, 481, 482, 483, 670}) { + for (unsigned band = 0; band < 2; ++band) { + SwitchHdRumbleSynth synth; + synth.reset(0); + const unsigned side = band; + const auto frame = native_side(side, native_state(code, band ? 0 : 1023, + code, band ? 1023 : 0)); + std::vector pcm(12000); + for (unsigned first = 0; first < 6000; first += 60) { + expect(synth.push_native(frame, first * 1000 / 3), + "native periodic refresh accepted"); + synth.render(first, 60, pcm.data() + first * 2); + } + const double hz = 10 * std::exp2((code - 1) / 96.0); + expect_wave(pcm, side, [hz](size_t n) { return wave(hz * n / 3000); }, + "native 96-step frequency retains wire precision"); + expect_wave(pcm, 1 - side, [](size_t) { return 0; }, + "native bands stay on their physical actuator"); + expect(spectral_amplitude(pcm, side, hz) > 125, + "native PCM has its expected physical spectral peak"); + if (code >= 481 && code <= 483) { + const double adjacent = 10 * std::exp2(code / 96.0); + expect(spectral_amplitude(pcm, side, adjacent) < 15, + "adjacent native codes are spectrally distinct, not rounded to Switch indices"); + } + } + } + + SwitchHdRumbleSynth synth; + synth.reset(0); + synth.push_native(native_side(0, native_state(385, 682, 481, 341)), 0); + expect_wave(render(synth, 0, 150), 0, [](size_t n) { + return 127.0 * (2 * std::sin(kTau * 160 * n / 3000) + + std::sin(kTau * 320 * n / 3000)) / 3; + }, "native joint gain preserves independent band mixture"); + + std::vector previous; + for (uint16_t amplitude : {128, 129}) { + synth.reset(0); + synth.push_native(native_side(0, native_state(385, amplitude)), 0); + auto pcm = render(synth, 0, 150); + expect_wave(pcm, 0, [amplitude](size_t n) { + return wave(160.0 * n / 3000, native_q15(amplitude)); + }, "native amplitude normalizes all ten bits before existing gain"); + if (!previous.empty()) expect(previous != pcm, "adjacent ten-bit amplitudes remain distinguishable"); + previous = pcm; + } +} + +void test_native_windows_watchdogs_and_legacy() { + SwitchHdRumbleSynth synth; + synth.reset(0); + NativeHapticsFrame frame{}; + frame.actuators[0] = {3, {native_state(), native_state(385, 0), native_state(385, 512)}}; + frame.actuators[1] = {2, {native_state(385, 0), native_state(385, 0, 481, 1023)}}; + synth.push_native(frame, 0); + synth.push_native(native_side(0, native_state(385, 512)), 20000); + synth.push_native(NativeHapticsFrame{}, 40000); + const auto pcm = render(synth, 0, 230); + expect_wave(pcm, 0, [](size_t n) { + return n < 210 ? wave(160.0 * n / 3000, + n < 16 ? 32768 : n < 32 ? 0 : native_q15(512)) : 0; + }, "native samples use fixed 16-frame spacing with an independent refreshed watchdog"); + expect_wave(pcm, 1, [](size_t n) { + return n >= 16 && n < 150 ? wave(320.0 * n / 3000) : 0; + }, "native two-sample update uses 16-frame spacing and untouched side expires at 50 ms"); + + synth.reset(0); + synth.push_native(frame, 0); + auto legacy = one_side(0); + legacy.actuators[0] = {3, {state(), state(64, 0), state(64, 16384)}}; + synth.push(legacy, 0); + const auto mixed = render(synth, 0, 40); + expect_wave(mixed, 0, [](size_t n) { + return wave(160.0 * n / 3000, n < 8 ? 32768 : n < 16 ? 0 : 16384); + }, "legacy replacement uses its own 8 ms window in a native stream"); + expect_wave(mixed, 1, [](size_t n) { return n < 16 ? 0 : wave(320.0 * n / 3000); }, + "legacy partial update does not shorten the other native window"); + expect(!synth.push_native(frame, UINT64_MAX), + "native and legacy updates share host timestamp ordering"); + + synth.reset(0); + synth.push_rumble(255, 255, 0); + synth.push_native(native_side(0, native_state(385, 0)), 1000); + auto held = render(synth, 0, 300); + expect_wave(held, 0, [](size_t n) { return n < 3 ? wave(160.0 * n / 3000) : 0; }, + "native zero stops the targeted persistent motor"); + expect_wave(held, 1, [](size_t n) { return wave(320.0 * n / 3000); }, + "native zero-count side preserves stateful XInput output"); +} + +void test_native_late_overflow_and_cancellation() { + NativeHapticsFrame steps{}; + steps.actuators[0] = {3, {native_state(), native_state(385, 512), native_state(385, 256)}}; + SwitchHdRumbleSynth synth; + synth.reset(10000); + expect(synth.push_native(steps, 4000), "recent pre-epoch native update accepted"); + expect_wave(render(synth, 0, 150), 0, [](size_t n) { + return n < 132 ? wave(160.0 * n / 3000, native_q15(n < 14 ? 512 : 256)) : 0; + }, "pre-epoch native update keeps original 16-frame sample positions and expiry"); + synth.reset(0); + render(synth, 0, 40); + synth.push_native(steps, 0); + expect_wave(render(synth, 40, 130), 0, [](size_t n) { + return n + 40 < 150 ? wave(160.0 * (n + 40) / 3000, native_q15(256)) : 0; + }, "late native update skips elapsed substeps without refreshing expiry"); + synth.reset(100000); + expect(!synth.push_native(steps, 50000), "expired native pre-epoch command rejected"); + + SwitchHdRumbleSynth reference; + synth.reset(0); + reference.reset(0); + for (unsigned n = 0; n < 40; ++n) { + const auto frame = native_side(n % 2, native_state(static_cast(385 + n % 5))); + synth.push_native(frame, n * 1000); + reference.push_native(frame, n * 1000); + render(reference, n * 3, 3); + } + expect(synth.dropped_updates() > 0, "native bounded timeline accounts for overflow"); + expect_silent(render(synth, 0, 30), "native overflow never replays discarded history"); + expect(render(synth, 120, 120) == render(reference, 120, 120), + "native overflow preserves partial sides and full-precision accumulated phases"); + + synth.reset(0); + auto both = native_side(0); + both.actuators[1] = both.actuators[0]; + synth.push_native(both, 0); + render(synth, 0, 15); + synth.push_native(both, 10000); // Queued update must also be canceled. + synth.feedback(5000, 5000, 0, 255); + synth.cancel_native(1); + const auto canceled = render(synth, 15, 90); + expect_wave(canceled, 0, [](size_t n) { + return n < 15 ? feedback_wave(320.0 * (n + 15) / 3000) : 0; + }, "native cancel preserves overlay but removes live and queued left host work"); + expect_wave(canceled, 1, [](size_t n) { + return n < 15 ? feedback_wave(320.0 * (n + 15) / 3000) : + wave(160.0 * (n + 15) / 3000); + }, "native cancellation leaves the other side and its queued updates intact"); + synth.push_native(native_side(0), 35000); + expect_wave(render(synth, 105, 30), 0, [](size_t n) { + return wave(160.0 * (n + 105) / 3000); + }, "fresh native command after cancellation resumes without oscillator reset"); + synth.cancel_native(3); + expect_silent(render(synth, 135, 120), "both-side cancellation is a lasting stop"); + synth.push_native(both, 100000); + synth.reset(100000); + expect_silent(render(synth, 0, 150), "stream reset discards pending native work"); +} + +void test_native_validation_is_atomic() { + SwitchHdRumbleSynth synth; + synth.reset(0); + synth.push_native(native_side(0), 0); + auto invalid = native_side(0, native_state(385, 0)); + invalid.actuators[1] = {1, {native_state(671)}}; + expect(!synth.push_native(invalid, 2000), "unmeasured active native frequency rejects whole frame"); + invalid.actuators[1] = {1, {native_state(0)}}; + expect(!synth.push_native(invalid, 2000), "active code zero cannot generate DC"); + invalid.actuators[1] = {1, {native_state(385, 1024)}}; + expect(!synth.push_native(invalid, 2000), "native amplitude overflow rejected"); + invalid.actuators[1].sample_count = 4; + expect(!synth.push_native(invalid, 2000), "native count overflow rejected"); + expect(synth.push_native(native_side(1, native_state(0, 0, 1023, 0)), 1000), + "invalid frames do not advance timestamp ordering; silent bands accept wire range"); + const auto pcm = render(synth, 0, 120); + expect_wave(pcm, 0, [](size_t n) { return wave(160.0 * n / 3000); }, + "malformed right side cannot partially stop left host state"); + expect_wave(pcm, 1, [](size_t) { return 0; }, "silent arbitrary codes never produce DC"); +} + +void test_native_side_feedback_preserves_host() { + SwitchHdRumbleSynth synth; + synth.reset(0); + auto both = native_side(0); + both.actuators[1] = both.actuators[0]; + synth.push_native(both, 0); + synth.feedback_native(5000, 10000, 0, 255); + const auto pcm = render(synth, 0, 90); + expect_wave(pcm, 0, [](size_t n) { return wave(160.0 * n / 3000); }, + "right native cue does not mute or attenuate the untouched left host"); + expect_wave(pcm, 1, [](size_t n) { + return n >= 15 && n < 45 ? feedback_wave(320.0 * n / 3000) : + wave(160.0 * n / 3000); + }, "native cue overlays only the requested side and resumes live host on expiry"); +} + } // namespace int main() { @@ -573,6 +776,11 @@ int main() { test_stateful_rumble_hd_order_and_watchdogs(); test_stateful_rumble_feedback_resume(); test_stateful_rumble_overflow_and_reset(); + test_native_precision_and_bands(); + test_native_windows_watchdogs_and_legacy(); + test_native_late_overflow_and_cancellation(); + test_native_validation_is_atomic(); + test_native_side_feedback_preserves_host(); if (failures) { std::cerr << failures << " synthesis scenarios failed\n"; return 1; diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index b267712..b6a990c 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -10,15 +10,14 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: compiler = shutil.which("c++") or shutil.which("g++") assert compiler is not None, "a host C++ compiler is required" - for bluetooth_mode, native, short_packets, wii_bridge in ( - ("mixed", False, False, False), - ("mixed", True, False, False), - ("mixed", True, True, False), - ("ble", False, False, False), - ("classic", False, False, False), - ("mixed", False, False, True), + for bluetooth_mode, native, wii_bridge in ( + ("mixed", False, False), + ("mixed", True, False), + ("ble", False, False), + ("classic", False, False), + ("mixed", False, True), ): - suffix = "_wii_bridge" if wii_bridge else "_native32" if short_packets else "_native64" if native else "" + suffix = "_wii_bridge" if wii_bridge else "_native32" if native else "" executable = ( tmp_path / f"bluepad32_backend_lifecycle_test_{bluetooth_mode}{suffix}" ) @@ -42,7 +41,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "-DSWITCH_PICO_HAPTICS_EXPERIMENT=1", "-DSWITCH_PICO_HD_RUMBLE=1", "-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=0", - f"-DSWITCH_PICO_HD_PACKET_FRAMES={32 if short_packets else 64}", + "-DSWITCH_PICO_HD_PACKET_FRAMES=32", str(root / "src" / "firmware" / "input" / "haptics_experiment.cpp"), str( root @@ -68,7 +67,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: ), ] ) - if short_packets: + if native: command.extend( [ "-DSWITCH_PICO_CYW43_PACKET_READ=1", @@ -116,7 +115,11 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: ) subprocess.run(command, check=True, cwd=root) if wii_bridge: - for scenario in ("wii-bridge-sensors", "wii-bridge-cues", "wii-bridge-cue-races"): + for scenario in ( + "wii-bridge-sensors", + "wii-bridge-cues", + "wii-bridge-cue-races", + ): subprocess.run([str(executable), scenario], check=True, cwd=root) continue subprocess.run([str(executable), "transport-policy"], check=True, cwd=root) diff --git a/tests/test_bluepad32_classic_discovery_native.py b/tests/test_bluepad32_classic_discovery_native.py new file mode 100644 index 0000000..bbee916 --- /dev/null +++ b/tests/test_bluepad32_classic_discovery_native.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +def test_bluepad32_classic_discovery_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + sdk = Path( + os.environ.get("PICO_SDK_PATH", root / "build" / "_deps" / "pico_sdk-src") + ) + btstack = sdk / "lib" / "btstack" + bluepad = root / "external" / "bluepad32" / "src" / "components" / "bluepad32" + if ( + not (btstack / "src" / "btstack.h").is_file() + or not (bluepad / "bt" / "uni_bt_bredr.c").is_file() + ): + pytest.skip("requires Pico SDK and Bluepad32 checkout") + compiler = shutil.which("cc") or shutil.which("gcc") + assert compiler is not None, "a host C compiler is required" + git = shutil.which("git") + assert git is not None, "Git is required to prepare the project patch" + relative = Path("src/components/bluepad32/bt/uni_bt_bredr.c") + patched = tmp_path / relative + patched.parent.mkdir(parents=True) + shutil.copyfile(bluepad / "bt" / "uni_bt_bredr.c", patched) + subprocess.run( + [ + git, + "apply", + "--no-index", + f"--include={relative.as_posix()}", + str(root / "patches" / "bluepad32-sdl3-imu.patch"), + ], + cwd=tmp_path, + check=True, + ) + executable = tmp_path / "classic_discovery_test" + subprocess.run( + [ + compiler, + "-std=c11", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-ffunction-sections", + "-fdata-sections", + f"-I{root / 'tests' / 'btstack_credit_batch_native_stubs'}", + f"-I{root / 'bluepad32_config'}", + f"-I{bluepad / 'include'}", + f"-I{btstack / 'src'}", + f"-I{btstack / 'platform' / 'embedded'}", + f"-I{btstack / '3rd-party' / 'bluedroid' / 'encoder' / 'include'}", + f"-I{btstack / '3rd-party' / 'bluedroid' / 'decoder' / 'include'}", + f"-I{btstack / '3rd-party' / 'yxml'}", + str(root / "tests" / "bluepad32_classic_discovery_test.c"), + str(patched), + str(bluepad / "bt" / "uni_bt_conn.c"), + str(btstack / "src" / "btstack_util.c"), + str(btstack / "src" / "btstack_run_loop.c"), + "-Wl,--gc-sections", + "-o", + str(executable), + ], + cwd=root, + check=True, + ) + subprocess.run([str(executable)], cwd=root, check=True) diff --git a/tests/test_haptics_experiment_native.py b/tests/test_haptics_experiment_native.py index 2a7ce8c..a3e9798 100644 --- a/tests/test_haptics_experiment_native.py +++ b/tests/test_haptics_experiment_native.py @@ -7,10 +7,7 @@ import pytest @pytest.mark.parametrize("ram", [0, 1], ids=["flash", "sram"]) -@pytest.mark.parametrize("packet_frames", [32, 64], ids=["32frames", "64frames"]) -def test_haptics_experiment_native( - tmp_path: Path, ram: int, packet_frames: int -) -> None: +def test_haptics_experiment_native(tmp_path: Path, ram: int) -> 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" @@ -27,16 +24,10 @@ def test_haptics_experiment_native( "-pedantic", "-DSWITCH_PICO_HAPTICS_EXPERIMENT=1", f"-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM={ram}", - f"-DSWITCH_PICO_HD_PACKET_FRAMES={packet_frames}", - *( - [ - "-DSWITCH_PICO_CYW43_PACKET_READ=1", - "-DSWITCH_PICO_HCI_CREDIT_BATCH=1", - "-DSWITCH_PICO_SYS_CLOCK_MHZ=300", - ] - if packet_frames == 32 - else [] - ), + "-DSWITCH_PICO_HD_PACKET_FRAMES=32", + "-DSWITCH_PICO_CYW43_PACKET_READ=1", + "-DSWITCH_PICO_HCI_CREDIT_BATCH=1", + "-DSWITCH_PICO_SYS_CLOCK_MHZ=300", f"-I{root / 'tests' / 'haptics_experiment_native_stubs'}", f"-I{root / 'src' / 'firmware'}", str(root / "tests" / "haptics_experiment_test.cpp"), @@ -52,7 +43,7 @@ def test_haptics_experiment_native( ) subprocess.run([str(executable), str(corpus)], check=True, cwd=root) reports = corpus.read_bytes() - assert len(reports) == (18432 // packet_frames) * 143 + assert len(reports) == (18432 // 32) * 143 # Independent standard-library CRC across real module-generated packets: # A2 is covered once, CRC itself excluded, and stored little-endian. for offset in range(0, len(reports), 143): diff --git a/tests/test_native_gamepad_backend_native.py b/tests/test_native_gamepad_backend_native.py index 2aa50e7..86fed9e 100644 --- a/tests/test_native_gamepad_backend_native.py +++ b/tests/test_native_gamepad_backend_native.py @@ -7,11 +7,9 @@ from pathlib import Path import pytest -@pytest.mark.parametrize("source", ("GAMEPAD", "DUALSENSE")) -@pytest.mark.parametrize("controller_count", (2, 4)) -def test_native_gamepad_backend_native( - tmp_path: Path, source: str, controller_count: int -) -> None: +def compile_native_backend( + tmp_path: Path, source: str, controller_count: int, hd: bool = False +) -> tuple[Path, Path]: 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" @@ -28,6 +26,25 @@ def test_native_gamepad_backend_native( firmware / "input" / "controller_macro_capture.cpp", root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c", ] + hd_flags = [] + if hd: + hd_flags = [ + "-DSWITCH_PICO_HAPTICS_EXPERIMENT=1", + "-DSWITCH_PICO_HD_RUMBLE=1", + "-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=0", + "-DSWITCH_PICO_HD_PACKET_FRAMES=32", + "-DSWITCH_PICO_CYW43_PACKET_READ=1", + "-DSWITCH_PICO_HCI_CREDIT_BATCH=1", + "-DSWITCH_PICO_SYS_CLOCK_MHZ=300", + ] + sources.extend( + [ + firmware / "input" / "haptics_experiment.cpp", + firmware / "input" / "native_output_scheduler.cpp", + firmware / "input" / "switch_hd_rumble_synth.cpp", + firmware / "usb" / "switch" / "switch_haptics.cpp", + ] + ) subprocess.run( [ compiler, @@ -43,6 +60,7 @@ def test_native_gamepad_backend_native( "-DSWITCH2_BRIDGE_FULL_INPUT=1", f"-DSWITCH2_BRIDGE_{source}_INPUT=1", f"-DPROBE_CONTROLLER_COUNT={controller_count}", + *hd_flags, f"-I{root / 'tests' / 'bluepad32_native_stubs'}", f"-I{firmware}", f"-I{root / 'bluepad32_config'}", @@ -53,6 +71,15 @@ def test_native_gamepad_backend_native( check=True, cwd=root, ) + return root, executable + + +@pytest.mark.parametrize("source", ("GAMEPAD", "DUALSENSE")) +@pytest.mark.parametrize("controller_count", (2, 4)) +def test_native_gamepad_backend_native( + tmp_path: Path, source: str, controller_count: int +) -> None: + root, executable = compile_native_backend(tmp_path, source, controller_count) scenarios = [ "stable-logical-slot", "cue-lifetime", @@ -96,3 +123,17 @@ def test_native_gamepad_backend_native( ) for scenario in scenarios: subprocess.run([str(executable), scenario], check=True, cwd=root) + + +def test_native_gamepad_hd_backend_native(tmp_path: Path) -> None: + root, executable = compile_native_backend(tmp_path, "GAMEPAD", 4, hd=True) + for scenario in ( + "hd-second-pair-wii", + "hd-second-pair-other", + "hd-unselected-dualsense", + "hd-cues", + "hd-reselection", + "hd-delayed-cue", + "hd-admission-cancel", + ): + subprocess.run([str(executable), scenario], check=True, cwd=root) diff --git a/tests/test_native_hub_management_native.py b/tests/test_native_hub_management_native.py index acf39d8..fc3a660 100644 --- a/tests/test_native_hub_management_native.py +++ b/tests/test_native_hub_management_native.py @@ -5,13 +5,14 @@ from pathlib import Path import pytest +@pytest.mark.parametrize("clock_mhz", [240, 300], ids=["240MHz", "300MHz"]) @pytest.mark.parametrize( ("controller_count", "neutral_input"), [(2, False), (2, True), (4, True)], ids=["native-management", "neutral-one-pair", "neutral-two-pair"], ) def test_native_hub_management_native( - tmp_path: Path, controller_count: int, neutral_input: bool + tmp_path: Path, controller_count: int, neutral_input: bool, clock_mhz: int ) -> None: root = Path(__file__).resolve().parents[1] cc = shutil.which("cc") or shutil.which("gcc") @@ -33,6 +34,7 @@ def test_native_hub_management_native( "-fdata-sections", "-DSWITCH2_PROBE_HUB=1", f"-DPROBE_CONTROLLER_COUNT={controller_count}", + f"-DSWITCH_PICO_SYS_CLOCK_MHZ={clock_mhz}", ] flags.append(f"-DSWITCH2_PROBE_NEUTRAL_INPUT={int(neutral_input)}") transport = tmp_path / "native_hub_transport.o" @@ -78,33 +80,40 @@ def test_native_hub_management_native( ) for reboot_slot in ("root", "child"): subprocess.run([str(executable), reboot_slot], check=True, cwd=root) - router_executable = tmp_path / "native_hub_router_test" - subprocess.run( - [ - cc, - "-std=c11", - *flags, - *includes, - str(root / "tests" / "native_hub_router_test.c"), - "-o", - str(router_executable), - ], - check=True, - cwd=root, - ) - subprocess.run([str(router_executable)], check=True, cwd=root) + for trace in (False, True): + router_executable = tmp_path / f"native_hub_router_test_{int(trace)}" + trace_flags = ["-DSWITCH2_PROBE_TRACE_NATIVE_INPUT=1"] if trace else [] + subprocess.run( + [ + cc, + "-std=c11", + *flags, + *trace_flags, + *includes, + str(root / "tests" / "native_hub_router_test.c"), + "-o", + str(router_executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(router_executable)], check=True, cwd=root) +@pytest.mark.parametrize("clock_mhz", [240, 300], ids=["240MHz", "300MHz"]) @pytest.mark.parametrize("controller_count", [2, 4], ids=["one-pair", "two-pair"]) @pytest.mark.parametrize("trace_enabled", [False, True], ids=["plain", "trace"]) def test_native_hub_cold_startup( - tmp_path: Path, controller_count: int, trace_enabled: bool + tmp_path: Path, controller_count: int, trace_enabled: bool, clock_mhz: int ) -> None: root = Path(__file__).resolve().parents[1] cc = shutil.which("cc") or shutil.which("gcc") assert cc is not None, "a host C compiler is required" executable = tmp_path / "native_hub_startup_test" - flags = [f"-DPROBE_CONTROLLER_COUNT={controller_count}"] + flags = [ + f"-DPROBE_CONTROLLER_COUNT={controller_count}", + f"-DSWITCH_PICO_SYS_CLOCK_MHZ={clock_mhz}", + ] if trace_enabled: flags.append("-DSWITCH2_PROBE_TRACE_NATIVE_INPUT=1") subprocess.run( @@ -131,5 +140,12 @@ def test_native_hub_cold_startup( check=True, cwd=root, ) - for scenario in ("ready", "delayed", "timeout"): + for scenario in ( + "ready", + "delayed", + "timeout", + "mismatched-clock", + "unsupported-clock", + "inexact-clock", + ): subprocess.run([str(executable), scenario], check=True, cwd=root) diff --git a/tests/test_native_hub_trace_native.py b/tests/test_native_hub_trace_native.py index 703c945..1990df6 100644 --- a/tests/test_native_hub_trace_native.py +++ b/tests/test_native_hub_trace_native.py @@ -60,6 +60,8 @@ def test_native_hub_trace_lifecycle(tmp_path: Path, controller_count: int) -> No "approved-status-reset-during-completion", "approved-status-port-reset-ready", "approved-status-port-reset-queued", + "port-reset-interrupt-progress", + "port-reset-interrupt-reset", "approved-status-invalid-length", "approved-status-watch", "status-out-rejected-data", diff --git a/tests/test_switch2_usb_probe_protocol_native.py b/tests/test_switch2_usb_probe_protocol_native.py index 16c76cc..77e30f3 100644 --- a/tests/test_switch2_usb_probe_protocol_native.py +++ b/tests/test_switch2_usb_probe_protocol_native.py @@ -101,6 +101,7 @@ def test_switch2_usb_probe_protocol( f"-DSWITCH2_PROBE_NEUTRAL_INPUT={int(hub)}", *([f"-DSWITCH2_PROBE_{imu_mode}=1"] if imu_mode else []), f"-I{probe}", + f"-I{root / 'src' / 'firmware'}", f"-I{tmp_path}", f"-I{mbedtls / 'include'}", str(probe / "protocol.c"), diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index f7f86cb..78a01fc 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -790,7 +790,7 @@ std::vector read_haptics_payload() { require(read_u32(control_payload, 16) == configuration_crc32(payload.data(), payload.size()), "experiment response CRC is invalid"); - require(payload[71] == 0 && (payload[73] == 32 || payload[73] == 64) && + require(payload[71] == 0 && payload[73] == 32 && payload[74] <= 1 && payload[75] == 0, "experiment reserved payload bytes must remain zero"); return payload; @@ -851,7 +851,7 @@ void test_haptics_experiment_requests() { } #else std::vector expected(84, 0); - expected[73] = 64; + expected[73] = 32; perform_haptics_out(3, 0, false); perform_haptics_out(1, 4, false); perform_haptics_out(0, 0xff, false); @@ -884,11 +884,30 @@ void test_haptics_experiment_requests() { "busy start overwrote the accepted run"); // Model the independently progressing Core 1 service, not a USB echo. - current_haptics = { - 1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4, - 123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210, - 1100000, HapticsExperimentState::kRunning, 2, 0, 1, 0x89abcdef, 0x12345678, 64, false, - }; + current_haptics = {}; + current_haptics.run_id = 1; + current_haptics.connection_generation = 0x11223344; + current_haptics.start_us = 0xffff0000; + current_haptics.generated_packets = 103; + current_haptics.sent_packets = 101; + current_haptics.skipped_packets = 2; + current_haptics.send_failures = 3; + current_haptics.can_send_requests = 106; + current_haptics.synchronous_callbacks = 4; + current_haptics.max_generate_us = 123; + current_haptics.max_send_gap_us = 22000; + current_haptics.max_lateness_us = 11001; + current_haptics.max_request_wait_us = 9876; + current_haptics.first_tone_due_us = 0xfffffff0; + current_haptics.first_tone_sent_us = 0x30; + current_haptics.last_sent_us = 0x76543210; + current_haptics.last_pcm_end_us = 0xfedcba98; // Internal cue coverage is not wire data. + current_haptics.elapsed_us = 1100000; + current_haptics.state = HapticsExperimentState::kRunning; + current_haptics.slot = 2; + current_haptics.mode = 1; + current_haptics.host_updates = 0x89abcdef; + current_haptics.dropped_updates = 0x12345678; const uint32_t fields[] = { 1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4, 123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210, 1100000, diff --git a/tools/pico_usb_address_probe/router.c b/tools/pico_usb_address_probe/router.c index 882cc7f..a239f3d 100644 --- a/tools/pico_usb_address_probe/router.c +++ b/tools/pico_usb_address_probe/router.c @@ -24,8 +24,6 @@ extern bool native_hub_select_device(uint8_t address, uint8_t owner, uint32_t cu // makes it a zero-wait-state cycle counter next to the GPIO inputs, avoiding // SysTick's PPB accesses and 24-bit down-counter arithmetic in every sample. // Deadlines use modular 32-bit arithmetic for intervals below 2^31 cycles. -#define FS_CLOCK_HZ 240000000u -#define FS_BIT_CYCLES 20u #define LINE_SE0 0u #define LINE_J 1u #define LINE_K 2u @@ -85,6 +83,10 @@ static uint32_t fatal_fault; static bool valid_clock; static uint8_t address_decoder[2][256]; static bool address_decoder_ready; +#if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB && defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) +enum { ROOT_OBSERVE_IDLE, ROOT_OBSERVE_HEADER, ROOT_OBSERVE_EOP }; +static uint8_t root_observation_stage; // Core1 only after initialization. +#endif static __force_inline uint32_t atomic_read(const uint32_t* value) { return __atomic_load_n(value, __ATOMIC_RELAXED); @@ -195,6 +197,9 @@ void probe_router_init(uint32_t system_clock_hz) { memset(addresses, PROBE_ROUTER_UNASSIGNED, sizeof(addresses)); addresses[0] = 0u; memset(&counters, 0, sizeof(counters)); +#if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB && defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + root_observation_stage = ROOT_OBSERVE_IDLE; +#endif published_generation = 0u; reader_index = NO_READER; enabled = 0u; @@ -202,7 +207,7 @@ void probe_router_init(uint32_t system_clock_hz) { setup_publication = SETUP_INVALID; fatal_fault = 0u; valid_clock = system_clock_hz == FS_CLOCK_HZ; - counters.cycles_per_bit = system_clock_hz / 12000000u; + counters.cycles_per_bit = valid_clock ? FS_BIT_CYCLES : 0u; build_table(&tables[0], addresses, 0u); } @@ -230,12 +235,12 @@ void probe_router_publish(const uint8_t addresses[PROBE_ROUTER_SLOTS], uint8_t d void probe_router_enable(bool enable) { // ARM qualification (observed hub tokens/SETUPs) belongs to the control // request handler. This additionally prevents enabling a failed observer. - __atomic_store_n(&enabled, enable && atomic_read(&counters.ready) != 0u && + __atomic_store_n(&enabled, enable && valid_clock && atomic_read(&counters.ready) != 0u && atomic_read(&fatal_fault) == 0u, __ATOMIC_RELEASE); } bool probe_router_set_phase(uint32_t cycles) { - if (cycles >= atomic_read(&counters.cycles_per_bit) || atomic_read(&enabled) != 0u) + if (!valid_clock || cycles >= FS_BIT_CYCLES || atomic_read(&enabled) != 0u) return false; __atomic_store_n(&phase_cycles, cycles, __ATOMIC_RELEASE); return true; @@ -261,7 +266,24 @@ void probe_router_snapshot(probe_router_stats* out) { SNAPSHOT(last_raw_count); SNAPSHOT(last_raw_eop); SNAPSHOT(last_raw_late); + SNAPSHOT(capture_returns); + SNAPSHOT(discarded_headers); + SNAPSHOT(last_discarded_header); + SNAPSHOT(root_in_count); + SNAPSHOT(root_in_cutoff); + SNAPSHOT(root_header); + SNAPSHOT(root_header_cycle); + SNAPSHOT(root_eop_cycle); + SNAPSHOT(before_setup_in_count); + SNAPSHOT(before_setup_in_cutoff); + SNAPSHOT(before_setup_header); + SNAPSHOT(before_setup_header_cycle); + SNAPSHOT(before_setup_eop_cycle); #undef SNAPSHOT + out->enabled = atomic_read(&enabled); + out->fatal_fault = atomic_read(&fatal_fault); + out->published_generation = atomic_read(&published_generation); + out->reader_index = atomic_read(&reader_index); const uint32_t setup = __atomic_load_n(&setup_publication, __ATOMIC_ACQUIRE); out->last_setup_sequence = setup & SETUP_SEQUENCE_MASK; const uint32_t slot = setup >> SETUP_SLOT_SHIFT; @@ -308,20 +330,82 @@ static __force_inline bool sample_line(uint32_t* deadline, uint32_t* line) { return true; } +#if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB && defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) +static __force_inline void root_observe_token(uint8_t owner, uint32_t signature, + uint32_t cutoff, bool selected) { + if (selected && owner == 0 && signature == TOKEN_IN_SIGNATURE) { + // Early-prefix and full-address decisions share a cutoff. Do not reset + // an observation twice for the same token. + if (root_observation_stage == ROOT_OBSERVE_HEADER && + atomic_read(&counters.root_in_cutoff) == cutoff) return; + count_one(&counters.root_in_count); + atomic_write(&counters.root_in_cutoff,cutoff); + atomic_write(&counters.root_header,0); + atomic_write(&counters.root_header_cycle,0); + atomic_write(&counters.root_eop_cycle,0); + root_observation_stage = ROOT_OBSERVE_HEADER; + return; + } + // A new token ends attribution to the preceding root IN, including when + // its selection fails. A later child's response must not become the root's. + root_observation_stage = ROOT_OBSERVE_IDLE; + if (selected && owner == 0 && signature == TOKEN_SETUP_SIGNATURE) { + atomic_write(&counters.before_setup_in_count,atomic_read(&counters.root_in_count)); + atomic_write(&counters.before_setup_in_cutoff,atomic_read(&counters.root_in_cutoff)); + atomic_write(&counters.before_setup_header,atomic_read(&counters.root_header)); + atomic_write(&counters.before_setup_header_cycle,atomic_read(&counters.root_header_cycle)); + atomic_write(&counters.before_setup_eop_cycle,atomic_read(&counters.root_eop_cycle)); + } +} + +static __force_inline void observe_discarded_header(uint32_t header) { + atomic_write(&counters.last_discarded_header,header); + count_one(&counters.discarded_headers); + if (root_observation_stage == ROOT_OBSERVE_HEADER) { + atomic_write(&counters.root_header,header); + atomic_write(&counters.root_header_cycle,cycles_now()); + root_observation_stage = ROOT_OBSERVE_EOP; + } +} +#endif + +static __force_inline void root_observe_eop(uint32_t cycle, bool qualified) { +#if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB && defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + if (root_observation_stage == ROOT_OBSERVE_EOP) { + // This is the drain's observed SE0-to-J qualification, not a measured + // two-bit EOP width. Idle/timeout must not attribute a later EOP here. + if (qualified) atomic_write(&counters.root_eop_cycle,cycle); + root_observation_stage = ROOT_OBSERVE_IDLE; + } +#else + (void)cycle; + (void)qualified; +#endif +} + static __force_inline void route_header(const routing_table* table, uint32_t address, uint32_t signature, uint32_t initial_address, uint32_t cutoff, raw_packet* packet) { // TinyUSB clears SETUP_REC only AFTER copying the hardware-validated SETUP // into its event callback. Until then, preserve both address and owner. - if (usb_hw->sie_status & USB_SIE_STATUS_SETUP_REC_BITS) + if (usb_hw->sie_status & USB_SIE_STATUS_SETUP_REC_BITS) { +#if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB && defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + root_observe_token(PROBE_ROUTER_UNASSIGNED,signature,cutoff,false); +#endif return; + } invalidate_setup(); - if (address >= 128u || table->owner[address] >= PROBE_ROUTER_SLOTS) + if (address >= 128u || table->owner[address] >= PROBE_ROUTER_SLOTS) { +#if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB && defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + root_observe_token(PROBE_ROUTER_UNASSIGNED,signature,cutoff,false); +#endif return; + } #if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB if (atomic_read(&enabled) != 0u) { const bool selected = native_hub_select_device((uint8_t)address, table->owner[address], cutoff); #if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + root_observe_token(table->owner[address],signature,cutoff,selected); // Keep diagnostic PID classification behind the address-critical call. __asm volatile ("" : "+r"(signature) : : "memory"); const uint8_t pid = signature == TOKEN_OUT_SIGNATURE ? PID_OUT : @@ -338,6 +422,11 @@ static __force_inline void route_header(const routing_table* table, uint32_t add #endif if (initial_address != address) ++packet->retargets; } +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + else { + root_observe_token(PROBE_ROUTER_UNASSIGNED,signature,cutoff,false); + } +#endif #else if (initial_address != address && atomic_read(&enabled) != 0u) { if (cycles_after(cycles_now(), cutoff) >= 0) { @@ -390,15 +479,25 @@ drain_prepared_capture:; uint32_t se0_since = 0; for (;;) { uint32_t line = receive_line(), now = cycles_now(); - if (cycles_after(now,stop) >= 0) { result.resync = true; return result; } + if (cycles_after(now,stop) >= 0) { + root_observe_eop(now,false); + result.resync = true; + return result; + } if (line == LINE_SE0) { if (!saw_se0) se0_since = now; saw_se0 = true; } else { // Half a bit rejects pad skew while allowing late ACK EOP entry. if (line == LINE_J && saw_se0 && - cycles_after(now,se0_since) >= (int32_t)(FS_BIT_CYCLES / 2u)) break; - if (line == LINE_J && observe_idle_j()) break; + cycles_after(now,se0_since) >= (int32_t)FS_HALF_BIT_CYCLES) { + root_observe_eop(now,true); + break; + } + if (line == LINE_J && observe_idle_j()) { + root_observe_eop(now,false); + break; + } saw_se0 = false; } } @@ -448,7 +547,7 @@ edge: const uint32_t sop_time = cycles_now(); // This timestamp follows the PHY read and edge-detection instructions. // Captures showed an extra full-bit delay skipped SYNC's second symbol. - // Sweep the next sample relative to read completion, then keep 20-cycle + // Sweep the next sample relative to read completion, then keep one-bit // spacing; every stored line symbol is still physically observed. uint32_t deadline = sop_time + phase; result.sop = true; @@ -458,7 +557,13 @@ edge: // PID rejection happens before any address/body samples. Reuse the prepared // frame: rebuilding its stack image here can miss the end of a short ACK/NAK // and the following token. All other result/address accumulators are still zero. +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) +#define NOTE_DISCARDED_HEADER() observe_discarded_header(word0) +#else +#define NOTE_DISCARDED_HEADER() ((void)0) +#endif #define DISCARD_NON_TOKEN() do { \ + NOTE_DISCARDED_HEADER(); \ result.sop = false; word0 = LINE_K; draining = true; \ goto drain_prepared_capture; \ } while (0) @@ -533,6 +638,7 @@ edge: #undef ROUTE_EARLY #undef SET_EARLY_DECODER #undef DISCARD_NON_TOKEN +#undef NOTE_DISCARDED_HEADER result.count = RAW_BITS; goto done; eop: @@ -560,11 +666,11 @@ static bool __not_in_flash_func(observe_idle_j)(void) { // Stuffing prohibits eight consecutive J bit times inside a packet. // Use tight PHY polling, not sparse timer-paced reads that could miss K. const uint32_t start = cycles_now(); - for (uint32_t i = 0; i < 64u; ++i) { + for (uint32_t i = 0; i < FS_IDLE_POLLS; ++i) { if (receive_line() != LINE_J) return false; } - return cycles_after(cycles_now(), start) >= (int32_t)(8u * FS_BIT_CYCLES); + return cycles_after(cycles_now(), start) >= (int32_t)FS_IDLE_CYCLES; } #if !defined(SWITCH2_PROBE_HUB) || !SWITCH2_PROBE_HUB @@ -734,8 +840,8 @@ void __not_in_flash_func(probe_router_core1)(void) { break; } // Phase is relative to the observed J->K edge, not a promised physical - // edge timestamp. The host sweeps 0..19 cycles and correlates sampled - // headers with the native DCD's CRC-accepted SETUP interrupts. A successful + // edge timestamp. The host sweeps the compiled bit period and correlates + // sampled headers with the native DCD's CRC-accepted SETUP interrupts. A successful // passive phase still does NOT prove when the SIE latches its address. // Calibrate the real routing instruction path, not a lighter sampler // whose phase/register allocation changes when routing is enabled. @@ -745,6 +851,9 @@ void __not_in_flash_func(probe_router_core1)(void) { #else const raw_packet packet = capture_packet(phase, table); #endif + #if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB && defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + count_one(&counters.capture_returns); + #endif if (!packet.sop) { if (packet.resync) { draining = true; diff --git a/tools/pico_usb_address_probe/router.h b/tools/pico_usb_address_probe/router.h index 8bc826f..89bb7db 100644 --- a/tools/pico_usb_address_probe/router.h +++ b/tools/pico_usb_address_probe/router.h @@ -3,6 +3,28 @@ #include #include +// The standalone RAM probe remains at 240 MHz. Only the integrated hub +// consumes the firmware clock; every receiver deadline is compiled for it. +#if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB && defined(SWITCH_PICO_SYS_CLOCK_MHZ) +#define FS_CLOCK_MHZ SWITCH_PICO_SYS_CLOCK_MHZ +#else +#define FS_CLOCK_MHZ 240u +#endif +#if FS_CLOCK_MHZ != 240 && FS_CLOCK_MHZ != 300 +#error "Native SIO receiver requires a compiled 240 or 300 MHz clock" +#endif +#define FS_CLOCK_HZ (FS_CLOCK_MHZ * 1000000u) +#define FS_BIT_CYCLES (FS_CLOCK_HZ / 12000000u) +#define FS_HALF_BIT_CYCLES ((FS_BIT_CYCLES + 1u) / 2u) +#define FS_IDLE_CYCLES (8u * FS_BIT_CYCLES) +// Preserve the baseline tight-poll duration: 64 polls at 240, 80 at 300 MHz. +#define FS_IDLE_POLLS (FS_CLOCK_MHZ * 64u / 240u) +// Relative to the software SOP timestamp, after edge detection—not a fraction +// of the USB bit period. Fixed instruction latency needs a separate offset at +// 300 MHz. Phase 12 passed loaded child-control tests where scaled phase 5 failed. +// Preserve the established 240 MHz setting; Switch recognition is a separate check. +#define PROBE_ROUTER_DEFAULT_PHASE (FS_CLOCK_MHZ == 300u ? 12u : 4u) + #if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB && defined(PROBE_CONTROLLER_COUNT) #define PROBE_ROUTER_SLOTS (PROBE_CONTROLLER_COUNT + 1u) #else @@ -29,6 +51,17 @@ typedef struct { uint32_t last_raw_count; uint32_t last_raw_eop; uint32_t last_raw_late; + // Trace-enabled hub diagnostics, updated outside token sampling deadlines. + uint32_t capture_returns; + uint32_t discarded_headers; + uint32_t last_discarded_header; + uint32_t enabled, fatal_fault, published_generation, reader_index; + // First non-token header following a selected root-IN candidate. These are + // sampler observations, not endpoint decoding or full packet validation. + // The pre-SETUP copy survives the recovery control's own IN/status response. + uint32_t root_in_count, root_in_cutoff, root_header, root_header_cycle, root_eop_cycle; + uint32_t before_setup_in_count, before_setup_in_cutoff; + uint32_t before_setup_header, before_setup_header_cycle, before_setup_eop_cycle; } probe_router_stats; // Core 0 initializes before launching Core 1. The native SIE drives USB; diff --git a/tools/switch2_usb_probe/controller_input.cpp b/tools/switch2_usb_probe/controller_input.cpp index a618ee3..8863077 100644 --- a/tools/switch2_usb_probe/controller_input.cpp +++ b/tools/switch2_usb_probe/controller_input.cpp @@ -491,9 +491,9 @@ extern "C" void probe_controller_input_set_full_stick_calibration( } extern "C" bool probe_controller_input_submit_rumble( - uint8_t instance, const uint8_t* magnitudes, uint8_t count) { + uint8_t instance, const NativeHapticsActuatorFrame* frame) { return g_flash_ready && instance < PROBE_CONTROLLER_COUNT && - bluepad32_input_backend_native_rumble_submit(instance, magnitudes, count); + bluepad32_input_backend_native_rumble_submit(instance, frame); } extern "C" void probe_controller_input_cancel_rumble(uint8_t instance) { diff --git a/tools/switch2_usb_probe/controller_input.h b/tools/switch2_usb_probe/controller_input.h index 15b6801..7b74e70 100644 --- a/tools/switch2_usb_probe/controller_input.h +++ b/tools/switch2_usb_probe/controller_input.h @@ -2,6 +2,7 @@ #include #include +#include "core/native_haptics.h" #ifdef __cplusplus extern "C" { @@ -51,10 +52,10 @@ void probe_controller_input_set_native_features(uint8_t features); // Supply each child's advertised, validated nine-byte stick record. Native // output stays unavailable until that child's calibration has been supplied. void probe_controller_input_set_full_stick_calibration(uint8_t instance, const uint8_t calibration[9]); -// Native single-actuator gameplay envelope for this virtual half. Backend owns -// binding/generation checks, profile gain, finite playback and stale-stream stop. -bool probe_controller_input_submit_rumble(uint8_t instance, const uint8_t* magnitudes, - uint8_t count); +// Lossless native waveform for this virtual half. Backend owns source binding, +// profile gain, selected HD output and bounded conventional fallback. +bool probe_controller_input_submit_rumble(uint8_t instance, + const NativeHapticsActuatorFrame* frame); void probe_controller_input_cancel_rumble(uint8_t instance); #endif // Core0 native07/08 output. Disable discards queued/prepared data; repeated diff --git a/tools/switch2_usb_probe/main.c b/tools/switch2_usb_probe/main.c index e46a19d..cb954bf 100644 --- a/tools/switch2_usb_probe/main.c +++ b/tools/switch2_usb_probe/main.c @@ -27,6 +27,10 @@ #include "pico/stdlib.h" #include "hardware/sync.h" #include "hardware/uart.h" +#if SWITCH2_PROBE_HUB +#include "pico/stdio/driver.h" +#include "pico/stdio_uart.h" +#endif #include "tusb.h" #include "descriptors.h" #include "protocol.h" @@ -127,6 +131,40 @@ static void gate_join_shoulders(uint8_t instance, uint8_t report_id, } #endif +static void count_dropped_log_bytes(uint32_t count) { + if (!count) return; +#if SWITCH2_PROBE_HUB + __atomic_add_fetch(&log_dropped, count, __ATOMIC_RELAXED); +#else + log_dropped += count; +#endif +} + +static bool queue_log_bytes(const char* message, size_t size, uint32_t requested) { +#if !SWITCH2_PROBE_HUB + const uint32_t interrupts = save_and_disable_interrupts(); +#elif defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + const uint32_t trace_parent = native_hub_trace_phase(NATIVE_HUB_TRACE_PHASE_LOG_COPY); +#endif + const bool queued = LOG_CAPACITY - (log_written - log_read) >= size; + if (queued) { + const size_t offset = log_written % LOG_CAPACITY; + const size_t first = size < LOG_CAPACITY - offset ? size : LOG_CAPACITY - offset; + memcpy(log_bytes + offset, message, first); + memcpy(log_bytes, message + first, size - first); + log_written += (uint32_t)size; + count_dropped_log_bytes(requested - (uint32_t)size); + } else { + count_dropped_log_bytes(requested); + } +#if !SWITCH2_PROBE_HUB + restore_interrupts(interrupts); +#elif defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + native_hub_trace_phase(trace_parent); +#endif + return queued; +} + int probe_debug_printf(const char* format, ...) { #if SWITCH2_PROBE_HUB // Native-hub producers and the UART consumer all run on Core0 foreground. @@ -142,30 +180,30 @@ int probe_debug_printf(const char* format, ...) { va_end(args); if (result <= 0) return result; const size_t size = (size_t)result < sizeof(message) ? (size_t)result : sizeof(message) - 1; -#if !SWITCH2_PROBE_HUB - const uint32_t interrupts = save_and_disable_interrupts(); -#elif defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - const uint32_t trace_parent = native_hub_trace_phase(NATIVE_HUB_TRACE_PHASE_LOG_COPY); -#endif - const bool queued = LOG_CAPACITY - (log_written - log_read) >= size; - if (queued) { - const size_t offset = log_written % LOG_CAPACITY; - const size_t first = size < LOG_CAPACITY - offset ? size : LOG_CAPACITY - offset; - memcpy(log_bytes + offset, message, first); - memcpy(log_bytes, message + first, size - first); - log_written += (uint32_t)size; - log_dropped += (uint32_t)result - (uint32_t)size; - } else { - log_dropped += (uint32_t)result; - } -#if !SWITCH2_PROBE_HUB - restore_interrupts(interrupts); -#elif defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - native_hub_trace_phase(trace_parent); -#endif - return queued ? result : -1; + return queue_log_bytes(message, size, (uint32_t)result) ? result : -1; } +#if SWITCH2_PROBE_HUB +static void buffered_stdio_out(const char* bytes, int length) { + if (length <= 0) return; + // Stdio can also be called by panic/IRQ paths. Never recursively assert, + // race the foreground ring, or wait for UART from those contexts. + if (get_core_num() != 0 || __get_current_exception() != 0) { + count_dropped_log_bytes((uint32_t)length); + return; + } + queue_log_bytes(bytes, (size_t)length, (uint32_t)length); +} + +static void buffer_uart_stdio(void) { + // Install before Bluetooth starts. Bluepad32's vfprintf/printf output + // must share the ordered queue rather than blocking RADIO_POLL on UART. + // Keep the SDK's UART initialization, stdin and availability callbacks. + stdio_uart.out_chars = buffered_stdio_out; + stdio_uart.out_flush = NULL; // Foreground drain_log owns physical output. +} +#endif + static void drain_log(void) { while (uart_is_writable(uart0)) { #if !SWITCH2_PROBE_HUB @@ -305,12 +343,11 @@ void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, if (instance < PROBE_CONTROLLER_COUNT && report_type == HID_REPORT_TYPE_OUTPUT && controllers[instance].protocol.initialized && probe_transport_mounted(instance) && !probe_transport_suspended(instance)) { - probe_rumble_frame frame; + NativeHapticsActuatorFrame frame; if (probe_protocol_decode_rumble(report_id, buffer, length, &frame)) { // Count-zero HOLD leaves both motor state and watchdog untouched. // Valid gameplay traffic must not fill the slow UART log ring. - if (frame.count && probe_controller_input_submit_rumble( - instance, frame.magnitude, frame.count)) + if (frame.sample_count && probe_controller_input_submit_rumble(instance, &frame)) controllers[instance].gameplay_rumble_seen = true; return; } @@ -836,6 +873,9 @@ int main(void) { system_clock_initialize(); #endif stdio_init_all(); +#if SWITCH2_PROBE_HUB + buffer_uart_stdio(); +#endif #ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE probe_debug_printf("\n[PROBE] " PROBE_JOYCON_PRODUCT " Bluetooth-to-USB controller/native mouse bridge\n"); #elif SWITCH2_PROBE_NEUTRAL_INPUT @@ -994,7 +1034,7 @@ int main(void) { " log_dropped_bytes=%" PRIu32 "\n", now, probe_transport_mounted(0), bulk_packets, hid_packets, identity_requests, version_requests, setup_completions, - input_reports, command_drops, log_dropped); + input_reports, command_drops, __atomic_load_n(&log_dropped, __ATOMIC_RELAXED)); #ifdef SWITCH2_PROBE_USB_INIT for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) { probe_usb_controller* controller = &controllers[instance]; diff --git a/tools/switch2_usb_probe/probe_build.cmake b/tools/switch2_usb_probe/probe_build.cmake index 5d55de2..195a555 100644 --- a/tools/switch2_usb_probe/probe_build.cmake +++ b/tools/switch2_usb_probe/probe_build.cmake @@ -417,27 +417,27 @@ function(switch2_usb_probe_configure target) endif() if(SWITCH2_PROBE_NEUTRAL_INPUT) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.102-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair-trace") + pico_set_program_version(${target} "0.108-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair-trace") else() - pico_set_program_version(${target} "0.102-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair") + pico_set_program_version(${target} "0.108-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair") endif() elseif(SWITCH2_PROBE_HUB AND SWITCH2_BRIDGE_FULL_INPUT) if(probe_controller_count GREATER 2) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.102-live-two-pair-trace") + pico_set_program_version(${target} "0.108-live-two-pair-trace") else() - pico_set_program_version(${target} "0.102-live-two-pair") + pico_set_program_version(${target} "0.108-live-two-pair") endif() elseif(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.102-native-digital-stick-trace") + pico_set_program_version(${target} "0.108-native-digital-stick-trace") else() - pico_set_program_version(${target} "0.102-native-digital-stick") + pico_set_program_version(${target} "0.108-native-digital-stick") endif() elseif(SWITCH2_PROBE_HUB) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.102-native-hub-profiles-trace") + pico_set_program_version(${target} "0.108-native-hub-profiles-trace") else() - pico_set_program_version(${target} "0.102-native-hub-profiles") + pico_set_program_version(${target} "0.108-native-hub-profiles") endif() elseif(SWITCH2_PROBE_JOIN_CHORD_GATE) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) diff --git a/tools/switch2_usb_probe/protocol.c b/tools/switch2_usb_probe/protocol.c index 955b629..21a8d77 100644 --- a/tools/switch2_usb_probe/protocol.c +++ b/tools/switch2_usb_probe/protocol.c @@ -409,7 +409,7 @@ void probe_protocol_gate_native_report(const probe_protocol_state* state, } bool probe_protocol_decode_rumble(uint8_t report_id, const uint8_t* data, - size_t length, probe_rumble_frame* output) { + size_t length, NativeHapticsActuatorFrame* output) { if (!data || !output) return false; if (report_id == 0) { if (length < 17 || length > 64 || data[0] != 0x01) return false; @@ -424,16 +424,14 @@ bool probe_protocol_decode_rumble(uint8_t report_id, const uint8_t* data, // packs frequency/amplitude/frequency/amplitude as four 10-bit LE fields. // Header: format 01 [7:6], sample count [5:4], sequence [3:0]. // Sequence is informational; unused sample bytes and USB padding may be stale. - probe_rumble_frame decoded = {.count = (data[0] >> 4) & 3u}; - for (unsigned i = 0; i < decoded.count; ++i) { + NativeHapticsActuatorFrame decoded = {.sample_count = (data[0] >> 4) & 3u}; + for (unsigned i = 0; i < decoded.sample_count; ++i) { const uint8_t* sample = data + 1u + 5u * i; - const unsigned first = (sample[1] >> 2) | ((sample[2] & 0x0fu) << 6); - const unsigned second = (sample[3] >> 6) | ((unsigned)sample[4] << 2); - const unsigned amplitude = first > second ? first : second; - // ERM compatibility, not HD waveform reproduction: ignore frequencies - // and round max(amplitudes) across the full 10-bit range to 0..255. - // SDL's conservative outbound clamp is not an inbound validity limit. - decoded.magnitude[i] = (uint8_t)((amplitude * 255u + 511u) / 1023u); + NativeHapticsSample* out = &decoded.samples[i]; + out->low_frequency_code = sample[0] | ((uint16_t)(sample[1] & 3u) << 8); + out->low_amplitude = (sample[1] >> 2) | ((uint16_t)(sample[2] & 15u) << 6); + out->high_frequency_code = (sample[2] >> 4) | ((uint16_t)(sample[3] & 63u) << 4); + out->high_amplitude = (sample[3] >> 6) | ((uint16_t)sample[4] << 2); } *output = decoded; return true; diff --git a/tools/switch2_usb_probe/protocol.h b/tools/switch2_usb_probe/protocol.h index a11f678..43f39f6 100644 --- a/tools/switch2_usb_probe/protocol.h +++ b/tools/switch2_usb_probe/protocol.h @@ -3,6 +3,7 @@ #include #include #include "model.h" +#include "core/native_haptics.h" #define PROBE_COMMAND_MAX_SIZE 263u #define PROBE_REPLY_MAX_SIZE 96u @@ -50,17 +51,14 @@ typedef struct { uint32_t report_counter; } probe_protocol_state; -typedef struct { - uint8_t count; - uint8_t magnitude[3]; -} probe_rumble_frame; - -// Stateless Output 01 compatibility rumble: report_id 0 includes the leading +// Stateless Output 01 waveform decoding: report_id 0 includes the leading // wire ID (17..64 bytes); report_id 1 omits it (16..63 bytes). // Count zero means HOLD/no update, not stop or watchdog refresh. A nonempty // zero-amplitude sample is stop. Malformed input leaves output unchanged. +// Both 10-bit frequency and amplitude fields survive decoding; each physical +// output backend chooses its own supported frequency range and rendering. bool probe_protocol_decode_rumble(uint8_t report_id, const uint8_t* data, - size_t length, probe_rumble_frame* output); + size_t length, NativeHapticsActuatorFrame* output); void probe_protocol_reset(probe_protocol_state* state, bool is_left); // Blob: own address[6], count[1], zero-padded host addresses[42][6], AES key[16].