From 6188fcb517b9527ec0077a81190f50e8634dccdf Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 5 Sep 2026 13:23:25 -0600 Subject: [PATCH] feat: drive native DualSense haptics from Switch HD rumble --- CMakeLists.txt | 9 + HAPTICS_EXPERIMENT.md | 57 ++- build.py | 1 + .../input/bluepad32_input_backend.cpp | 68 ++- src/firmware/input/haptics_experiment.cpp | 243 +++++++++-- src/firmware/input/haptics_experiment.h | 12 + src/firmware/input/switch_hd_rumble_synth.cpp | 369 ++++++++++++++++ src/firmware/input/switch_hd_rumble_synth.h | 89 ++++ .../profile/controller_profile_transform.cpp | 21 +- src/firmware/usb/switch/switch_haptics.cpp | 45 +- src/firmware/usb/switch/switch_haptics.h | 27 +- .../usb/switch/switch_haptics_amplitudes.h | 44 ++ .../usb/usb_configuration_management.cpp | 5 +- .../usb/usb_configuration_management.h | 4 +- src/switch_pico_bridge/config_manager.py | 143 +++++-- tests/controller_profile_transform_test.cpp | 22 + tests/haptics_experiment_test.cpp | 103 ++++- tests/switch_haptics_test.cpp | 53 +++ tests/switch_hd_rumble_synth_test.cpp | 394 ++++++++++++++++++ tests/test_config_manager.py | 228 +++++++--- tests/test_haptics_experiment_native.py | 2 + tests/test_switch_hd_rumble_synth_native.py | 31 ++ tests/usb_configuration_management_test.cpp | 48 ++- 23 files changed, 1836 insertions(+), 182 deletions(-) create mode 100644 src/firmware/input/switch_hd_rumble_synth.cpp create mode 100644 src/firmware/input/switch_hd_rumble_synth.h create mode 100644 src/firmware/usb/switch/switch_haptics_amplitudes.h create mode 100644 tests/switch_hd_rumble_synth_test.cpp create mode 100644 tests/test_switch_hd_rumble_synth_native.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 4bc104b..05836d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,11 @@ option(SWITCH_PICO_HAPTICS_EXPERIMENT "Enable the host-triggered DualSense PCM transport experiment" OFF) option(SWITCH_PICO_HAPTICS_EXPERIMENT_RAM "Execute the haptics experiment hot path from SRAM" ON) +option(SWITCH_PICO_HD_RUMBLE + "Auto-arm native Switch HD rumble for a DualSense in slot zero" OFF) +if(SWITCH_PICO_HD_RUMBLE) + set(SWITCH_PICO_HAPTICS_EXPERIMENT ON) +endif() set(SWITCH_PICO_INPUT_BACKEND "UART" CACHE STRING "Controller input backend") set_property(CACHE SWITCH_PICO_INPUT_BACKEND PROPERTY STRINGS UART BLUEPAD32) if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "UART" @@ -157,6 +162,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") if(SWITCH_PICO_HAPTICS_EXPERIMENT) target_sources(switch-pico PRIVATE ${SWITCH_PICO_SOURCE_DIR}/input/haptics_experiment.cpp + ${SWITCH_PICO_SOURCE_DIR}/input/switch_hd_rumble_synth.cpp ${SWITCH_PICO_SOURCE_DIR}/input/haptics_transport_probe.cpp) target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HAPTICS_EXPERIMENT=1 @@ -171,6 +177,9 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") "LINKER:--wrap=cyw43_bluetooth_hci_read" "LINKER:--wrap=btstack_run_loop_base_poll_data_sources") endif() + if(SWITCH_PICO_HD_RUMBLE) + target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HD_RUMBLE=1) + endif() else() target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_HID_INSTANCE_COUNT=1 diff --git a/HAPTICS_EXPERIMENT.md b/HAPTICS_EXPERIMENT.md index 3e35f76..7255b95 100644 --- a/HAPTICS_EXPERIMENT.md +++ b/HAPTICS_EXPERIMENT.md @@ -1,8 +1,8 @@ -# DualSense low-latency haptics experiment +# DualSense native HD rumble and transport experiment ## Goal and evidence -Prove a bounded-latency native Bluetooth PCM transport on one Pico 2 W / DualSense connection before reconnecting Nintendo HD-rumble decoding. Normal builds retain compatibility rumble. This is an opt-in deterministic transport experiment, not a claim of complete HD Rumble support. +Native Nintendo HD-rumble decoding is connected to the proven Bluetooth PCM transport through opt-in gameplay mode. A dedicated HD image auto-arms one DualSense in slot 0. Normal builds retain compatibility rumble, and the deterministic transport fixture remains available. Console gameplay was user-tested; precise actuator-onset latency and perceptual equivalence to Nintendo hardware are not claimed. 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. @@ -21,18 +21,45 @@ Sources: - https://github.com/awalol/DS5Dongle/blob/master/src/bt.cpp - https://github.com/awalol/DS5Dongle/blob/master/CMakeLists.txt - Local SDK `lib/btstack/src/l2cap.c`, `src/rp2_common/pico_btstack/btstack_run_loop_async_context.c`. +- Frequency reference: https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/rumble_data_table.md +- Reconstructed substep reference: https://github.com/HandHeldLegend/NS-LIB-HID/blob/becc24f0841bbb875da24ea622cc1ada00cb8492/docs/hd-rumble-implementation-guide.md +- Eight-millisecond playback-window reference: https://github.com/HandHeldLegend/HOJA-LIB-RP2040/blob/238f66d1c4aae87fc320d94d8abd38229e7da2d0/src/utilities/pcm.c ## Implementation contract 1. Build-only opt-in `SWITCH_PICO_HAPTICS_EXPERIMENT`; separate build directory/artifacts. Preserve wake identity, pairing storage, USB modes and ordinary firmware artifacts. Use stock clock/voltage. Experimental builds use the controller's advertised outgoing ACL capacity and one-packet receive batches with explicit rescheduling; normal builds retain the three-credit cap and sixteen-packet batches. Incoming flow control and all FIFO sizes remain unchanged. -2. One selected connected Sony DualSense/DualSense Edge, Bluetooth Classic, sufficient negotiated MTU. Explicit management start/stop; no tones at pairing or boot. +2. One selected connected Sony DualSense/DualSense Edge, Bluetooth Classic, sufficient negotiated MTU. The fixture requires explicit start. `SWITCH_PICO_HD_RUMBLE=ON` additionally auto-arms continuous gameplay for a DualSense in slot 0; it emits silence until host commands or local confirmation arrive. Other controllers retain compatibility output. 3. 142-byte report 0x32 plus A2 transaction = 143-byte L2CAP SDU. The first report selects native mode using sized state block 0x90, length 63, with rumble-selection bits and other write flags clear; it carries one silent 64-byte haptic block (0x92). Subsequent reports use compact audio control `{0x91,3,0x62,16,counter}` and **two** 64-byte haptic blocks: descriptor 0xd2, length 64, followed by 128 sample bytes. Thus 0xd2 is valid here, unlike the original spike's single-block mismatch. Deterministic padding and Bluetooth CRC. No speaker, microphone, USB audio endpoint, Opus or resampler. 4. Steady-state 64 stereo frames per report at 3 kHz, 46.875 reports/s. Absolute microsecond/sample deadlines use integer rational arithmetic; preserve fractional time and skip obsolete packets after stalls rather than burst-replaying them. Timer wakeups account for SDK +1 tick. Can-send permission and audio deadlines are separate. Arm flags before requests and handle synchronous callbacks without recursive stream generation. -5. Finite sequence: 48 report intervals of priming silence (1.024 s), four cycles of left 100 Hz tone / silence / right 200 Hz tone / silence (12 reports = 256 ms per phase), then 48 reports of trailing silence. Total 288 reports / 6.144 s. The initial mode handoff contains 32 silent frames; normal two-block streaming follows. Signed sample peak 32/127 is deliberately gentle, not a claim of 25% perceived force. Stop preempts the pattern, emits silence when sendable and restores compatibility output. Disconnect cancels without stale-pointer use. Only the selected controller's conventional outputs are overridden. +5. The deterministic fixture remains a finite 288-report / 6.144-second sequence: 48 priming intervals, four cycles of left 100 Hz / silence / right 200 Hz / silence (12 reports = 256 ms per phase), then 48 trailing-silence reports. Its peak remains 32/127. Gameplay is continuous, has no one-second priming pattern, and uses timestamped Switch commands instead. Stop restores compatibility output; disconnect cancels without stale-pointer use. 6. No historical PCM FIFO. Generate only the current due block when transmission is permitted; bounded control mailbox across cores. Record packet counts, skipped blocks, failed sends, synchronous callbacks, generation cost, send gaps, lateness, request wait and first-tone timestamps. HCI submission is not physical actuator onset. -7. Host `haptics-experiment start --slot 0`, `status`, `stop`, and `profile` use existing USB management framing. The experiment reports unsupported on ordinary builds. Existing general runtime diagnostics remain unchanged. Both experiment and transport-profile responses now require schema 2; update the host and experimental firmware together. +7. Host `haptics-experiment start`, `gameplay`, `status`, `stop`, and `profile` use existing USB management framing. Ordinary builds report unsupported. General runtime diagnostics remain unchanged. The experiment response is schema 3; transport profiling remains schema 2. Update the host and experimental firmware together. 8. Regression coverage must include synchronous callback delivery, rational clock and late wakeups, reference packet interpretation, finite completion/stop, disconnect/reconnect and compatibility restoration. Native probes cannot prove controller acceptance or physical latency. +## Gameplay mode + +Build using the provisioned Pico SDK/toolchain environment: + +```sh +cmake -S . -B build-hd-rumble -DPICO_BOARD=pico2_w \ + -DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32 \ + -DSWITCH_PICO_HD_RUMBLE=ON \ + -DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=ON -DSWITCH_PICO_LOG=OFF +cmake --build build-hd-rumble +``` + +Load `build-hd-rumble/switch-pico.elf` or `.uf2`. The HD option implies the experimental transport and auto-arms slot 0 on connection. On the Switch, reconnect the DualSense with PS. Manual PC arming is `uv run switch-pico-config haptics-experiment gameplay --slot 0`; it does not persist across power cycles. `stop` disarms the native stream, not ordinary compatibility rumble. The standard `build.py` commands explicitly disable both HD and experiment options. + +The decoder preserves each actuator's one-to-three ordered substeps and frequency indices. Amplitudes become linear Q0.15 values via precomputed lookups; compatibility magnitudes retain their previous mapping. Profile strong/weak scales apply to the low/high bands of both actuators without discarding substeps. + +The synthesizer has independent left/right low/high phase accumulators. Frequencies are `40 * 2^(index/32)` and `80 * 2^(index/32)` Hz. Each command occupies an 8 ms window, split into 24/12/8 PCM samples per substep for counts 1/2/3 at 3 kHz. New reports supersede unplayed old substeps; identical compressed words hold final state rather than replaying deltas. Each side expires 50 ms after its last update, matching the existing conservative Switch-rumble timeout policy. + +One report interval (21.333 ms) of causal lookback preserves commands received between Bluetooth sends without predicting future input. Fixed 16-entry cross-core and synthesis command histories contain decoded states, not PCM. Overflow is counted; stale sample intervals are skipped, not replayed as a backlog. `host_updates` and `dropped_updates` expose command ingestion and loss. + +Native gameplay gain is **1.5x after profile scaling**, following console feedback that the initial gain was weak. When the requested combined band weights exceed output headroom, both are reduced proportionally. This retains band balance and bounds samples to signed PCM range without clipping waveform peaks. Zero profile gains remain zero. Local profile confirmations retain their previous strength and temporarily override, rather than erase, the current host timeline. + +The gameplay stream continues with silence while idle. It is stopped on disconnect, explicit stop, or a stalled send-permission watchdog; it yields to compatibility behavior in XInput mode. Existing LED feedback can drain without switching the controller out of native haptics. Continuous idle streaming trades power for avoiding repeated audio-mode startup. + ## Building and running Use the repository's provisioned Pico SDK/toolchain environment. These CMake commands only build; they do not flash the adapter: @@ -57,17 +84,19 @@ uv run switch-pico-config haptics-experiment profile --json uv run switch-pico-config haptics-experiment stop --slot 0 ``` -The first tone is intentionally scheduled 1.024 seconds after the stream starts; that priming silence is not transport delay. Compare physical onset against `first_tone_due_us` / `first_tone_sent_us`, not the time the start command was entered. Each 256 ms tone/silence phase is a second timing marker. Host USB polling can miss intermediate state but the firmware retains maxima and final counters. +For the deterministic fixture only, the first tone is intentionally scheduled 1.024 seconds after start. Gameplay instead renders the timestamped host timeline with one report interval of lookback. First-tone fields identify the logical first nonsilent sample and the containing report's submission, not actual actuator onset. ## Protocol -USB vendor management operation 0x40: OUT two-byte payload `{action, slot}` (0=stop, 1=start, slot 0..3); existing request envelope. IN diagnostics, existing response envelope, schema 2, 72-byte payload. Schema 2 identifies the two-block/288-report pattern; schema 1 used one block/576 reports. +USB vendor management operation 0x40: OUT two-byte payload `{action, slot}` (0=stop, 1=finite fixture, 2=continuous gameplay; slot 0..3); existing request envelope. IN is schema 3, 84 bytes. The first 72 bytes retain the previous layout; mode and gameplay counters follow. - Seventeen little-endian u32 fields: run_id, connection_generation, start_us, generated_packets, sent_packets, skipped_packets, send_failures, can_send_requests, synchronous_callbacks, max_generate_us, max_send_gap_us, max_lateness_us, max_request_wait_us, first_tone_due_us, first_tone_sent_us, last_sent_us, elapsed_us. - Four u8 fields: state, slot, last_error, reserved (zero). +- Byte 72: mode (0=fixture, 1=gameplay); bytes 73–75: zero reserved bytes. +- Little-endian u32 at 76: `host_updates`; at 80: `dropped_updates`. - State: idle=0, pending=1, running=2, completed=3, stopped=4, disconnected=5, unsupported=6, error=7. Disabled build reports unsupported. - Microsecond timestamps are low 32 bits of Pico uptime; use unsigned modular differences for this bounded experiment. Host receipt time is not a hardware onset measurement. -- Error: none=0, unsupported controller=1, insufficient MTU=2, disconnected=3, timeout=4, transport failure=5, queued conventional output=6. Start rejects a nonempty conventional output queue rather than discarding LED/control reports or interleaving them with PCM; let prior output drain before retrying. +- Error: none=0, unsupported controller=1, insufficient MTU=2, disconnected=3, timeout=4, transport failure=5, queued conventional output=6. The fixture rejects a queued start. Gameplay allows a bounded startup interval for prior output to drain; it does not discard LED/control reports. ### Transport timing probe @@ -173,4 +202,14 @@ Final verification: **122 focused tests passed**; normal, experimental SRAM and Configuration remains generation 9 / CRC `b740995b`; wake configuration remains included. Clock stays at the configured stock 150 MHz, voltage unchanged. No FIFO enlargement, incoming flow-control removal, speaker/microphone stream or broad stack relocation was needed. -The SRAM experimental image remains loaded, with the experiment stopped. Nintendo HD-rumble decoding is **not yet connected** to this PCM sender; normal gameplay retains compatibility rumble. Physical onset still needs a synchronized sensor/contact-microphone measurement before claiming a gameplay-to-actuator latency bound. +### Gameplay integration verification + +The initial gameplay image passed 513 real Switch-format USB OUT reports through the decoder, profile scaling, cross-core history and native sender: 513 observed host updates, zero dropped updates, zero Bluetooth skips and zero send failures. A stronger-command repeat also passed; the user confirmed correct alternating left/right effects. A simultaneous local-profile identification test retained all 513 updates, dispatched local confirmation, and stayed in native gameplay with no skips or failures. + +The user then tested an actual Switch game and reported that it **worked well but needed more strength**. Gameplay gain was increased to 1.5x with joint headroom limiting, then built, flashed and exercised again: all 513 commands arrived, 200 Bluetooth reports were submitted during the test, zero command drops/skips/send failures, and 1,443 controller input reports continued. Maximum observed packet-generation time was 680 us, permission wait 292 us, and report gap 24,343 us. These are firmware/transport measurements, not a physical latency bound. + +Stop and re-arm were also exercised: stop confirmation was observed in about 19.5 ms; the new run resumed continuous silence and controller input. That re-arm recorded one skipped silent startup slot, with no host commands or send failures; the active USB-driven tests above had no skipped slots. + +Final focused verification: **148 tests passed**, covering decoder fidelity, frequency/phase behavior, substeps, gain/headroom, watchdogs, overflow, startup/stop, profile gain/feedback, host controls, existing backend lifecycle, UART and build helpers. HD gameplay, normal all-in-one, deterministic experiment, and Pico/UART firmware builds succeeded. Configuration remained generation 9 / CRC `b740995b`; wake identity, clock and voltage were not changed. + +The stronger HD gameplay image remains loaded and armed. This is a translation to DualSense actuators, not a promise of identical Nintendo force response. Physical onset still needs synchronized measurement. The user subsequently reported slight IMU aiming lag; that is being investigated separately from this completed haptics integration. diff --git a/build.py b/build.py index 712b3b2..1375f54 100644 --- a/build.py +++ b/build.py @@ -402,6 +402,7 @@ def build( ] # Experimental images are built explicitly in their own CMake directory. definitions.append("-DSWITCH_PICO_HAPTICS_EXPERIMENT=OFF") + definitions.append("-DSWITCH_PICO_HD_RUMBLE=OFF") run_cmd( [ diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 133905a..c732097 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -1256,6 +1256,16 @@ void process_configuration_timer(btstack_timer_source_t* timer) { profile_service_task_on_storage_core(now_ms); } +void dispatch_rumble(uni_hid_device_t* device, uint16_t duration_ms, + uint8_t weak, uint8_t strong) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (haptics_experiment_feedback(device, strong, weak, duration_ms)) { + return; + } +#endif + device->report_parser.play_dual_rumble(device, 0, duration_ms, weak, strong); +} + void process_rumble_timer(btstack_timer_source_t* timer) { __atomic_add_fetch(&g_rumble_timer_ticks, 1, __ATOMIC_RELAXED); const uint32_t now_ms = btstack_run_loop_get_time_ms(); @@ -1275,6 +1285,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) { const bool xinput_host_mode = host_rumble_duration_ms() == kXInputHostRumbleDurationMs; #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (xinput_host_mode) haptics_experiment_suspend_gameplay(); haptics_experiment_poll(); #endif @@ -1294,10 +1305,11 @@ void process_rumble_timer(btstack_timer_source_t* timer) { critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT - if (haptics_experiment_owns(slot.device)) { - // The experiment owns all output on this connection while active. - // Keep the latest XInput state but don't replay stale Switch pulses. - slot.rumble_pending = false; + if (haptics_experiment_owns(slot.device) && + !haptics_experiment_gameplay_owns(slot.device)) { + // Fixture/startup/restoration exclusively own output. Preserve + // stateful XInput requests until compatibility restoration ends. + if (!xinput_host_mode) slot.rumble_pending = false; critical_section_exit(&g_state_lock); continue; } @@ -1432,6 +1444,15 @@ void process_rumble_timer(btstack_timer_source_t* timer) { } } critical_section_exit(&g_state_lock); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (host_dispatch && haptics_experiment_gameplay_owns(device) && + (envelope.rumble.hd.actuators[0].sample_count != 0 || + envelope.rumble.hd.actuators[1].sample_count != 0)) { + // Full timestamped frames already went directly from the USB + // producer to the native timeline, even during local feedback. + host_dispatch = false; + } +#endif if (profile_lighting_restore && lighting_target_is_current( @@ -1452,15 +1473,14 @@ void process_rumble_timer(btstack_timer_source_t* timer) { device->report_parser.play_dual_rumble != nullptr) { __atomic_add_fetch( &g_rumble_dispatches, 1, __ATOMIC_RELAXED); - device->report_parser.play_dual_rumble( - device, 0, kProfileFeedbackPhaseDurationMs, - kProfileFeedbackWeakMagnitude, - kProfileFeedbackStrongMagnitude); + dispatch_rumble( + device, kProfileFeedbackPhaseDurationMs, + kProfileFeedbackWeakMagnitude, kProfileFeedbackStrongMagnitude); } else if (feedback_dispatch) { __atomic_add_fetch( &g_rumble_dispatches, 1, __ATOMIC_RELAXED); - device->report_parser.play_dual_rumble( - device, 0, feedback.duration_ms, + dispatch_rumble( + device, feedback.duration_ms, feedback.weak_magnitude, feedback.strong_magnitude); } else if (host_dispatch && device->report_parser.play_dual_rumble != nullptr) { @@ -1469,8 +1489,8 @@ void process_rumble_timer(btstack_timer_source_t* timer) { const bool stop = envelope.rumble.low_frequency_magnitude == 0 && envelope.rumble.high_frequency_magnitude == 0; - device->report_parser.play_dual_rumble( - device, 0, stop ? 0 : envelope.duration_ms, + dispatch_rumble( + device, stop ? 0 : envelope.duration_ms, envelope.rumble.high_frequency_magnitude, envelope.rumble.low_frequency_magnitude); } @@ -1645,6 +1665,15 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT haptics_experiment_attach( static_cast(slot_index), lighting_generation, device); +#ifdef SWITCH_PICO_HD_RUMBLE + if (slot_index == 0 && + host_rumble_duration_ms() != kXInputHostRumbleDurationMs && + connection_identity.vendor_id == 0x054c && + (connection_identity.product_id == 0x0ce6 || + connection_identity.product_id == 0x0df2)) { + haptics_experiment_request(2, static_cast(slot_index)); + } +#endif #endif if (lighting_target_is_current( static_cast(slot_index), @@ -2024,10 +2053,19 @@ void bluepad32_input_backend_queue_rumble( return; } +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + const uint64_t received_us = time_us_64(); + uint32_t native_generation = 0; + bool native_candidate = false; +#endif const uint16_t duration_ms = host_rumble_duration_ms(); critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; if (slot.active && slot.device != nullptr) { +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + native_generation = slot.connection_generation; + native_candidate = true; +#endif const RumbleEnvelope envelope{ slot_index, slot.connection_generation, rumble, duration_ms}; @@ -2044,6 +2082,12 @@ void bluepad32_input_backend_queue_rumble( } } critical_section_exit(&g_state_lock); +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + if (native_candidate) { + haptics_experiment_submit( + slot_index, native_generation, received_us, rumble.hd); + } +#endif } bool bluepad32_input_backend_identify( diff --git a/src/firmware/input/haptics_experiment.cpp b/src/firmware/input/haptics_experiment.cpp index da020b2..661d5d6 100644 --- a/src/firmware/input/haptics_experiment.cpp +++ b/src/firmware/input/haptics_experiment.cpp @@ -1,5 +1,6 @@ #include "input/haptics_experiment.h" #include "input/haptics_transport_probe.h" +#include "input/switch_hd_rumble_synth.h" #include #include @@ -40,7 +41,7 @@ enum Error : uint8_t { kQueuedOutput = 6, }; -enum class Phase { kIdle, kPattern, kDrain, kRestore }; +enum class Phase { kIdle, kPrepare, kPattern, kDrain, kRestore }; struct Attachment { uni_hid_device_t* device = nullptr; @@ -54,8 +55,15 @@ struct Command { uint8_t slot = kNoSlot; uint32_t run_id = 0; Attachment connection{}; + uint8_t mode = 0; }; +struct HostUpdate { + uint64_t received_us = 0; + SwitchHapticsFrame frame{}; +}; +constexpr uint8_t kHostQueueCapacity = 16; + // Only the attachment identities, mailbox and published snapshot cross cores. // No BTstack call (including synchronous reentry) holds this lock. critical_section_t g_lock; @@ -65,6 +73,12 @@ uint32_t g_snapshot_request_us = 0; Command g_command; bool g_busy = false; bool g_prepared = false; +bool g_accept_host = false; +HostUpdate g_host_queue[kHostQueueCapacity]; +uint8_t g_host_head = 0; +uint8_t g_host_count = 0; +uint32_t g_host_updates = 0; +uint32_t g_host_drops = 0; // Written on core 1 under the lock; core 0 only reads identities for requests. Attachment g_attachments[4]; @@ -89,6 +103,8 @@ uint64_t g_end_us = 0; uint64_t g_lifecycle_due_us = 0; uint64_t g_request_us = 0; uint64_t g_restore_deadline_us = 0; +SwitchHdRumbleSynth g_synth; +bool g_synth_started = false; // round(32 * sin(2*pi*n/30)). A stride of one is 100 Hz at 3 kHz; // a stride of two is 200 Hz. Preserve phase across all 12 packets of a tone. @@ -101,6 +117,28 @@ void cadence_timer(btstack_timer_source_t*); void lifecycle_timer(btstack_timer_source_t*); void request_send(); void restore_compatibility(HapticsExperimentState state); +void start_stream(); + +bool gameplay() { + return g_diagnostics.mode == 1; +} + +void drain_host_updates() { + // Bound work even if USB keeps publishing while the BT core drains. + for (uint8_t index = 0; index < kHostQueueCapacity; ++index) { + HostUpdate update; + critical_section_enter_blocking(&g_lock); + if (g_host_count == 0) { + critical_section_exit(&g_lock); + break; + } + update = g_host_queue[g_host_head]; + g_host_head = (g_host_head + 1) % kHostQueueCapacity; + --g_host_count; + critical_section_exit(&g_lock); + g_synth.push(update.frame, update.received_us); + } +} void update_max(uint32_t* value, uint32_t candidate) { if (candidate > *value) { @@ -133,11 +171,17 @@ void publish(bool finished = false) { critical_section_enter_blocking(&g_lock); // A newly accepted start must not be overwritten by the preceding run. if (g_snapshot.run_id == g_diagnostics.run_id) { + g_diagnostics.host_updates = g_host_updates; + const uint64_t dropped = uint64_t{g_host_drops} + + (gameplay() && g_synth_started ? g_synth.dropped_updates() : 0); + g_diagnostics.dropped_updates = + dropped > UINT32_MAX ? UINT32_MAX : static_cast(dropped); g_snapshot = g_diagnostics; g_snapshot_waiting = g_send_requested; g_snapshot_request_us = static_cast(g_request_us); if (finished) { g_busy = false; + g_accept_host = false; } } critical_section_exit(&g_lock); @@ -207,6 +251,9 @@ void timeout_drain() { } void begin_drain(HapticsExperimentState state, uint64_t deadline_us) { + critical_section_enter_blocking(&g_lock); + g_accept_host = false; + critical_section_exit(&g_lock); cancel_timer(&g_cadence_timer, &g_cadence_armed); g_phase = Phase::kDrain; g_finish_state = state; @@ -232,6 +279,9 @@ void end_pattern() { } void restore_compatibility(HapticsExperimentState state) { + critical_section_enter_blocking(&g_lock); + g_accept_host = false; + critical_section_exit(&g_lock); cancel_timer(&g_cadence_timer, &g_cadence_armed); g_send_requested = false; g_phase = Phase::kRestore; @@ -263,6 +313,9 @@ void request_send() { g_send_requested = true; g_request_in_progress = true; ++g_diagnostics.can_send_requests; + if (gameplay() && g_phase == Phase::kPattern) { + schedule_lifecycle(g_request_us + kDrainTimeoutUs); + } const uint8_t status = l2cap_request_can_send_now_event(g_connection.cid); g_request_in_progress = false; @@ -287,7 +340,7 @@ void request_send() { kPacketNumeratorUs); g_diagnostics.skipped_packets += current + 1 - g_next_packet; g_next_packet = current + 1; - if (g_next_packet < kPackets) { + if (gameplay() || g_next_packet < kPackets) { schedule_timer(&g_cadence_timer, &g_cadence_armed, packet_due(g_next_packet)); } @@ -333,7 +386,27 @@ bool HAPTICS_HOT(generate_packet)(uint8_t* report, uint32_t packet, frames = 64; } bool tone = false; - if (!silence && packet >= kPrimingPackets && packet < kToneEndPacket) { + if (gameplay() && !silence) { + drain_host_updates(); + // One report of causal lookback preserves every 8 ms USB command, + // including substeps arriving between 21.333 ms Bluetooth sends. + if (packet != 0 && g_diagnostics.sent_packets != 0) { + const uint64_t first_sample = uint64_t{packet - 1} * 64; + g_synth.render(first_sample, frames, report + sample_offset); + for (uint32_t frame = 0; frame < frames; ++frame) { + if (report[sample_offset + frame * 2] != 0 || + report[sample_offset + frame * 2 + 1] != 0) { + tone = true; + if (!g_first_tone_sent) { + g_diagnostics.first_tone_due_us = static_cast( + g_start_us + ((first_sample + frame) * 1000 + 2) / 3); + } + break; + } + } + } + } else if (!gameplay() && !silence && packet >= kPrimingPackets && + packet < kToneEndPacket) { const uint32_t relative = packet - kPrimingPackets; const uint32_t phase = (relative / kPhasePackets) % 4; if (phase == 0 || phase == 2) { @@ -375,6 +448,16 @@ void cadence_timer(btstack_timer_source_t*) { return; } const uint64_t now_us = time_us_64(); + if (g_phase == Phase::kPrepare) { + if (uni_circular_buffer_is_empty(&g_connection.device->outgoing_buffer)) { + start_stream(); + } else if (now_us >= g_lifecycle_due_us) { + finish(HapticsExperimentState::kError, kQueuedOutput); + } else { + schedule_timer(&g_cadence_timer, &g_cadence_armed, now_us + 2000); + } + return; + } if (g_phase == Phase::kPattern) { const uint64_t due_us = packet_due(g_next_packet); haptics_transport_probe_timer( @@ -410,8 +493,14 @@ void lifecycle_timer(btstack_timer_source_t*) { schedule_lifecycle(g_lifecycle_due_us); return; } - if (g_phase == Phase::kPattern) { - end_pattern(); + if (g_phase == Phase::kPrepare) { + finish(HapticsExperimentState::kError, kQueuedOutput); + } else if (g_phase == Phase::kPattern) { + if (gameplay()) { + timeout_drain(); + } else { + end_pattern(); + } } else if (g_phase == Phase::kDrain) { timeout_drain(); } else if (g_phase == Phase::kRestore) { @@ -426,10 +515,33 @@ void lifecycle_timer(btstack_timer_source_t*) { } } +void start_stream() { + cancel_timer(&g_cadence_timer, &g_cadence_armed); + g_start_us = time_us_64(); + g_end_us = gameplay() ? UINT64_MAX : g_start_us + 6144000; + g_diagnostics.start_us = static_cast(g_start_us); + g_diagnostics.first_tone_due_us = + gameplay() ? 0 : static_cast(packet_due(kPrimingPackets)); + g_diagnostics.state = HapticsExperimentState::kRunning; + g_phase = Phase::kPattern; + g_next_packet = 0; + g_send_requested = false; + g_last_was_silence = true; + g_first_tone_sent = false; + if (gameplay()) { + g_synth.reset(g_start_us); + g_synth_started = true; + } + schedule_lifecycle(gameplay() ? g_start_us + kDrainTimeoutUs : g_end_us); + request_send(); +} + void start(const Command& command) { g_diagnostics = {}; g_diagnostics.run_id = command.run_id; g_diagnostics.slot = command.slot; + g_diagnostics.mode = command.action == 2 ? 1 : 0; + g_synth_started = false; g_connection = command.connection; g_diagnostics.connection_generation = g_connection.generation; g_start_us = time_us_64(); @@ -465,32 +577,29 @@ void start(const Command& command) { // Never discard unrelated LED/control reports or allow them to switch the // controller back to compatibility midstream. A queued start is retryable // once the ordinary sender has drained it. - if (!uni_circular_buffer_is_empty(&device->outgoing_buffer)) { + if (!gameplay() && !uni_circular_buffer_is_empty(&device->outgoing_buffer)) { finish(HapticsExperimentState::kError, kQueuedOutput); return; } // Cancel any existing parser duration/delayed-start timer before taking // over. In the already-disabled case this deliberately emits no report. device->report_parser.play_dual_rumble(device, 0, 0, 0, 0); - if (!uni_circular_buffer_is_empty(&device->outgoing_buffer)) { - finish(HapticsExperimentState::kError, kQueuedOutput); - return; - } - g_start_us = time_us_64(); - g_end_us = g_start_us + 6144000; - g_diagnostics.start_us = static_cast(g_start_us); - g_diagnostics.first_tone_due_us = - static_cast(packet_due(kPrimingPackets)); - g_diagnostics.state = HapticsExperimentState::kRunning; - g_phase = Phase::kPattern; - g_next_packet = 0; - g_send_requested = false; - g_last_was_silence = true; - g_first_tone_sent = false; btstack_run_loop_set_timer_handler(&g_cadence_timer, cadence_timer); btstack_run_loop_set_timer_handler(&g_lifecycle_timer, lifecycle_timer); - schedule_lifecycle(g_end_us); - request_send(); + if (!uni_circular_buffer_is_empty(&device->outgoing_buffer)) { + if (gameplay()) { + // Let connection setup/LED reports drain before taking over. + g_phase = Phase::kPrepare; + g_diagnostics.state = HapticsExperimentState::kPending; + schedule_lifecycle(time_us_64() + kDrainTimeoutUs); + schedule_timer(&g_cadence_timer, &g_cadence_armed, time_us_64() + 2000); + publish(); + } else { + finish(HapticsExperimentState::kError, kQueuedOutput); + } + return; + } + start_stream(); } } // namespace @@ -504,12 +613,12 @@ void haptics_experiment_prepare() { } bool haptics_experiment_request(uint8_t action, uint8_t slot) { - if (action > 1 || slot >= 4) { + if (action > 2 || slot >= 4) { return false; } critical_section_enter_blocking(&g_lock); bool accepted = true; - if (action == 1) { + if (action != 0) { if (g_busy) { accepted = false; } else { @@ -519,24 +628,60 @@ bool haptics_experiment_request(uint8_t action, uint8_t slot) { g_snapshot.slot = slot; g_snapshot.connection_generation = g_attachments[slot].generation; g_snapshot.state = HapticsExperimentState::kPending; + g_snapshot.mode = action == 2 ? 1 : 0; + g_accept_host = action == 2; + g_host_head = 0; + g_host_count = 0; + g_host_updates = 0; + g_host_drops = 0; g_snapshot_waiting = false; g_busy = true; - g_command = {true, action, slot, run_id, g_attachments[slot]}; + g_command = {true, action, slot, run_id, g_attachments[slot], g_snapshot.mode}; } } else if (g_busy) { if (slot != g_snapshot.slot) { accepted = false; } else { + g_accept_host = false; // Replaces even an unconsumed start, without a FIFO of commands. g_command = { true, action, slot, g_snapshot.run_id, - {nullptr, g_snapshot.connection_generation, 0}}; + {nullptr, g_snapshot.connection_generation, 0}, g_snapshot.mode}; } } critical_section_exit(&g_lock); return accepted; } +bool haptics_experiment_submit(uint8_t slot, uint32_t generation, + uint64_t received_us, + const SwitchHapticsFrame& frame) { + if (!g_prepared || slot >= 4 || + frame.actuators[0].sample_count > 3 || frame.actuators[1].sample_count > 3 || + (frame.actuators[0].sample_count == 0 && frame.actuators[1].sample_count == 0)) { + return false; + } + critical_section_enter_blocking(&g_lock); + const bool accepted = g_accept_host && g_busy && g_snapshot.mode == 1 && + g_snapshot.slot == slot && + g_snapshot.connection_generation == generation && + g_attachments[slot].device != nullptr && + g_attachments[slot].generation == generation; + if (accepted) { + if (g_host_count == kHostQueueCapacity) { + g_host_head = (g_host_head + 1) % kHostQueueCapacity; + --g_host_count; + if (g_host_drops != UINT32_MAX) ++g_host_drops; + } + const uint8_t index = (g_host_head + g_host_count) % kHostQueueCapacity; + g_host_queue[index] = {received_us, frame}; + ++g_host_count; + if (g_host_updates != UINT32_MAX) ++g_host_updates; + } + critical_section_exit(&g_lock); + return accepted; +} + void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output) { if (output == nullptr) { return; @@ -603,11 +748,13 @@ void haptics_experiment_poll() { if (!command.pending) { return; } - if (command.action == 1) { + if (command.action != 0) { start(command); } else if (g_phase != Phase::kIdle && g_diagnostics.run_id == command.run_id) { - if (g_phase == Phase::kRestore) { + if (g_phase == Phase::kPrepare) { + finish(HapticsExperimentState::kStopped, kNoError); + } else if (g_phase == Phase::kRestore) { if (g_finish_state != HapticsExperimentState::kError) { g_finish_state = HapticsExperimentState::kStopped; } @@ -624,6 +771,7 @@ void haptics_experiment_poll() { g_diagnostics.slot = command.slot; g_diagnostics.connection_generation = command.connection.generation; g_diagnostics.state = HapticsExperimentState::kStopped; + g_diagnostics.mode = command.mode; haptics_transport_probe_begin( command.run_id, command.connection.generation, 0xffff); haptics_transport_probe_end(); @@ -637,12 +785,42 @@ bool haptics_experiment_owns(const uni_hid_device_t* device) { connection_current(); } +bool haptics_experiment_gameplay_owns(const uni_hid_device_t* device) { + return gameplay() && g_phase == Phase::kPattern && + haptics_experiment_owns(device); +} + +bool haptics_experiment_feedback(uni_hid_device_t* device, + uint8_t low, uint8_t high, uint16_t duration_ms) { + if (!haptics_experiment_gameplay_owns(device)) return false; + g_synth.feedback(time_us_64(), uint32_t{duration_ms} * 1000, low, high); + return true; +} + +void haptics_experiment_suspend_gameplay() { + critical_section_enter_blocking(&g_lock); + const uint8_t slot = g_busy && g_snapshot.mode == 1 ? g_snapshot.slot : kNoSlot; + critical_section_exit(&g_lock); + if (slot != kNoSlot) haptics_experiment_request(0, slot); +} + bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, uint16_t cid) { if (device != g_connection.device || !connection_current() || (g_phase != Phase::kPattern && g_phase != Phase::kDrain)) { return false; } + if (gameplay() && g_phase == Phase::kPattern && !g_in_callback && + !uni_circular_buffer_is_empty(&device->outgoing_buffer)) { + // Gameplay permits ordinary LED reports, never compatibility rumble. + // Yield this credit to the generic FIFO, then request fresh permission. + if (cid == g_connection.cid && g_send_requested) { + account_wait(time_us_64()); + g_send_requested = false; + schedule_timer(&g_cadence_timer, &g_cadence_armed, time_us_64() + 1000); + } + return false; + } // The generic FIFO is device-wide, not CID-specific. Consume control-CID // and unsolicited events too, without treating them as PCM permission. if (cid != g_connection.cid || !g_send_requested || g_in_callback) { @@ -700,7 +878,7 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, // CAN_SEND_NOW. Never submit a now-obsolete tone after its phase ended. ++g_diagnostics.skipped_packets; ++g_next_packet; - if (g_next_packet < kPackets) { + if (gameplay() || g_next_packet < kPackets) { schedule_timer(&g_cadence_timer, &g_cadence_armed, packet_due(g_next_packet)); } @@ -751,10 +929,13 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, } } else { ++g_next_packet; - if (g_next_packet < kPackets) { + if (gameplay() || g_next_packet < kPackets) { schedule_timer(&g_cadence_timer, &g_cadence_armed, packet_due(g_next_packet)); } + if (gameplay()) { + schedule_lifecycle(packet_due(g_next_packet) + kDrainTimeoutUs); + } } g_in_callback = false; publish(); diff --git a/src/firmware/input/haptics_experiment.h b/src/firmware/input/haptics_experiment.h index 21001ff..cc7bfa0 100644 --- a/src/firmware/input/haptics_experiment.h +++ b/src/firmware/input/haptics_experiment.h @@ -4,6 +4,7 @@ struct uni_hid_device_s; typedef struct uni_hid_device_s uni_hid_device_t; +struct SwitchHapticsFrame; enum class HapticsExperimentState : uint8_t { kIdle = 0, @@ -37,12 +38,19 @@ struct HapticsExperimentDiagnostics { HapticsExperimentState state = HapticsExperimentState::kIdle; uint8_t slot = 0xff; uint8_t last_error = 0; + uint8_t mode = 0; + uint32_t host_updates = 0; + uint32_t dropped_updates = 0; }; // Core 0 before launching BTstack; request/snapshot are cross-core safe. void haptics_experiment_prepare(); bool haptics_experiment_request(uint8_t action, uint8_t slot); void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output); +// Core 0 USB delivery. Rejects non-selected/stale connections; never buffers PCM. +bool haptics_experiment_submit(uint8_t slot, uint32_t generation, + uint64_t received_us, + const SwitchHapticsFrame& frame); // Core 1 / BTstack only. Poll consumes management requests, not PCM cadence. void haptics_experiment_attach(uint8_t slot, uint32_t generation, @@ -50,5 +58,9 @@ void haptics_experiment_attach(uint8_t slot, uint32_t generation, void haptics_experiment_detach(uni_hid_device_t* device); void haptics_experiment_poll(); bool haptics_experiment_owns(const uni_hid_device_t* device); +bool haptics_experiment_gameplay_owns(const uni_hid_device_t* device); +bool haptics_experiment_feedback(uni_hid_device_t* device, + uint8_t low, uint8_t high, uint16_t duration_ms); +void haptics_experiment_suspend_gameplay(); bool haptics_experiment_on_can_send_now(uni_hid_device_t* device, uint16_t cid); diff --git a/src/firmware/input/switch_hd_rumble_synth.cpp b/src/firmware/input/switch_hd_rumble_synth.cpp new file mode 100644 index 0000000..eceef54 --- /dev/null +++ b/src/firmware/input/switch_hd_rumble_synth.cpp @@ -0,0 +1,369 @@ +#include "input/switch_hd_rumble_synth.h" + +#include +#include +#include + +namespace { + +constexpr int64_t kWatchdogSamples = 150; // 50 ms at 3 kHz. +constexpr unsigned kWindowSamples = 24; // 8 ms, independently split per side. + +// round(40 * 2^(index/32) * 2^32 / 3000), index 0..159. High-band +// indices address the same logarithmic table with an offset of 32 (80 Hz base). +constexpr uint32_t kPhaseIncrement[160] = { + 57266231u, 58520198u, 59801623u, 61111108u, 62449267u, 63816728u, 65214133u, 66642136u, + 68101409u, 69592636u, 71116516u, 72673765u, 74265113u, 75891307u, 77553110u, 79251302u, + 80986680u, 82760057u, 84572267u, 86424158u, 88316601u, 90250483u, 92226711u, 94246213u, + 96309936u, 98418849u, 100573941u, 102776224u, 105026730u, 107326516u, 109676661u, 112078267u, + 114532461u, 117040396u, 119603246u, 122222217u, 124898535u, 127633456u, 130428265u, 133284272u, + 136202818u, 139185271u, 142233032u, 145347530u, 148530226u, 151782614u, 155106221u, 158502605u, + 161973360u, 165520115u, 169144533u, 172848316u, 176633202u, 180500965u, 184453421u, 188492425u, + 192619872u, 196837698u, 201147882u, 205552448u, 210053460u, 214653032u, 219353321u, 224156534u, + 229064922u, 234080791u, 239206493u, 244444433u, 249797069u, 255266913u, 260856530u, 266568545u, + 272405636u, 278370542u, 284466063u, 290695059u, 297060452u, 303565229u, 310212442u, 317005210u, + 323946720u, 331040229u, 338289067u, 345696633u, 353266403u, 361001930u, 368906843u, 376984851u, + 385239744u, 393675396u, 402295765u, 411104895u, 420106920u, 429306064u, 438706642u, 448313067u, + 458129845u, 468161582u, 478412986u, 488888866u, 499594138u, 510533826u, 521713061u, 533137089u, + 544811271u, 556741085u, 568932127u, 581390118u, 594120904u, 607130458u, 620424884u, 634010420u, + 647893440u, 662080459u, 676578133u, 691393265u, 706532806u, 722003860u, 737813686u, 753969702u, + 770479489u, 787350793u, 804591530u, 822209790u, 840213840u, 858612128u, 877413285u, 896626134u, + 916259690u, 936323164u, 956825972u, 977777733u, 999188277u, 1021067651u, 1043426121u, 1066274178u, + 1089622542u, 1113482169u, 1137864254u, 1162780236u, 1188241808u, 1214260916u, 1240849767u, 1268020839u, + 1295786880u, 1324160918u, 1353156266u, 1382786530u, 1413065613u, 1444007720u, 1475627372u, 1507939404u, + 1540958977u, 1574701585u, 1609183060u, 1644419580u, 1680427680u, 1717224255u, 1754826570u, 1793252268u, +}; + +// round(127 * 256 * sin(2*pi*index/256)). Linear interpolation retains +// sub-byte precision until the final two-band mix; opposite phases are exact +// negatives, including rounding, so quantization cannot introduce a DC bias. +constexpr int16_t kSine[256] = { + 0, 798, 1595, 2392, 3187, 3980, 4771, 5558, 6343, 7123, 7900, 8671, 9438, 10198, 10953, 11701, + 12442, 13175, 13901, 14618, 15326, 16025, 16715, 17394, 18063, 18721, 19367, 20002, 20625, 21236, 21834, 22418, + 22989, 23547, 24090, 24618, 25132, 25631, 26114, 26581, 27033, 27468, 27886, 28288, 28673, 29041, 29390, 29723, + 30037, 30333, 30611, 30871, 31112, 31334, 31538, 31722, 31887, 32033, 32160, 32267, 32355, 32424, 32473, 32502, + 32512, 32502, 32473, 32424, 32355, 32267, 32160, 32033, 31887, 31722, 31538, 31334, 31112, 30871, 30611, 30333, + 30037, 29723, 29390, 29041, 28673, 28288, 27886, 27468, 27033, 26581, 26114, 25631, 25132, 24618, 24090, 23547, + 22989, 22418, 21834, 21236, 20625, 20002, 19367, 18721, 18063, 17394, 16715, 16025, 15326, 14618, 13901, 13175, + 12442, 11701, 10953, 10198, 9438, 8671, 7900, 7123, 6343, 5558, 4771, 3980, 3187, 2392, 1595, 798, + 0, -798, -1595, -2392, -3187, -3980, -4771, -5558, -6343, -7123, -7900, -8671, -9438, -10198, -10953, -11701, + -12442, -13175, -13901, -14618, -15326, -16025, -16715, -17394, -18063, -18721, -19367, -20002, -20625, -21236, -21834, -22418, + -22989, -23547, -24090, -24618, -25132, -25631, -26114, -26581, -27033, -27468, -27886, -28288, -28673, -29041, -29390, -29723, + -30037, -30333, -30611, -30871, -31112, -31334, -31538, -31722, -31887, -32033, -32160, -32267, -32355, -32424, -32473, -32502, + -32512, -32502, -32473, -32424, -32355, -32267, -32160, -32033, -31887, -31722, -31538, -31334, -31112, -30871, -30611, -30333, + -30037, -29723, -29390, -29041, -28673, -28288, -27886, -27468, -27033, -26581, -26114, -25631, -25132, -24618, -24090, -23547, + -22989, -22418, -21834, -21236, -20625, -20002, -19367, -18721, -18063, -17394, -16715, -16025, -15326, -14618, -13901, -13175, + -12442, -11701, -10953, -10198, -9438, -8671, -7900, -7123, -6343, -5558, -4771, -3980, -3187, -2392, -1595, -798, +}; + +bool due(int64_t sample, uint64_t cursor) { + return sample <= 0 || static_cast(sample) <= cursor; +} + +bool older(uint64_t timestamp, uint64_t previous) { + return timestamp - previous > static_cast(INT64_MAX); +} + +int32_t rounded_shift(int32_t value, unsigned shift) { + const int32_t half = int32_t{1} << (shift - 1); + return value < 0 ? -((-value + half) >> shift) + : (value + half) >> shift; +} + +int32_t sine(uint32_t phase) { + const unsigned index = phase >> 24; + const int32_t first = kSine[index]; + const int32_t difference = kSine[(index + 1) & 255u] - first; + const int32_t fraction = static_cast((phase >> 8) & 65535u); + return first + rounded_shift(difference * fraction, 16); +} + +void apply_host_gain(uint16_t& low, uint16_t& high) { + if ((low | high) == 0) return; + // Native gameplay calibration: 1.5x after profile gains. Limit both bands + // together to the mixer's headroom, preserving their ratio and avoiding + // waveform clipping. Local confirmations retain their original gain. + const uint32_t boosted_low = (uint32_t{low} * 3 + 1) / 2; + const uint32_t boosted_high = (uint32_t{high} * 3 + 1) / 2; + const uint32_t total = boosted_low + boosted_high; + if (total > 65536u) { + low = static_cast(boosted_low * 65536u / total); + high = static_cast(boosted_high * 65536u / total); + } else { + low = static_cast(boosted_low); + high = static_cast(boosted_high); + } +} + +uint8_t mix(uint32_t low_phase, uint32_t high_phase, + uint16_t low, uint16_t high) { + const int32_t sum = (low ? sine(low_phase) * low : 0) + + (high ? sine(high_phase) * high : 0); + // Q8 sine times band weights with a combined ceiling of 65536. The + // 24-bit shift maps that ceiling to +/-127 without int32 overflow. + return static_cast(rounded_shift(sum, 24)); +} + +} // namespace + +void SwitchHdRumbleSynth::reset(uint64_t epoch_us) { + epoch_us_ = epoch_us; + cursor_ = 0; + last_host_us_ = last_feedback_us_ = 0; + have_host_ = have_feedback_ = false; + head_ = count_ = 0; + dropped_updates_ = 0; + for (unsigned side = 0; side < 2; ++side) { + sides_[side] = Side{}; + phase_[side][0] = phase_[side][1] = 0; + } + feedback_expires_ = 0; + feedback_low_ = feedback_high_ = 0; +} + +void SwitchHdRumbleSynth::count_drop() { + if (dropped_updates_ != UINT32_MAX) { + ++dropped_updates_; + } +} + +bool SwitchHdRumbleSynth::timestamp_sample(uint64_t timestamp_us, + int64_t& sample) const { + const uint64_t elapsed = timestamp_us - epoch_us_; + if (elapsed <= static_cast(INT64_MAX)) { + // ceil(elapsed * 3 / 1000), without overflowing an intermediate. + sample = static_cast((elapsed / 1000) * 3 + + ((elapsed % 1000) * 3 + 999) / 1000); + return true; + } + const uint64_t before = epoch_us_ - timestamp_us; + if (before > static_cast(INT64_MAX)) { + return false; // Exactly half the clock range has ambiguous ordering. + } + sample = -static_cast((before / 1000) * 3 + + (before % 1000) * 3 / 1000); + return true; +} + +bool SwitchHdRumbleSynth::push(const SwitchHapticsFrame& frame, + uint64_t received_us) { + Command command; + if ((have_host_ && older(received_us, last_host_us_)) || + !timestamp_sample(received_us, command.sample) || + command.sample + kWatchdogSamples <= 0) { + count_drop(); + return false; + } + bool has_update = false; + for (const auto& actuator : frame.actuators) { + if (actuator.sample_count > 3) { + count_drop(); + return false; + } + has_update |= actuator.sample_count != 0; + for (unsigned index = 0; index < actuator.sample_count; ++index) { + const auto& sample = actuator.samples[index]; + if (sample.low_frequency_index > 127 || + sample.high_frequency_index > 127 || + sample.low_amplitude_q15 > 32768 || + sample.high_amplitude_q15 > 32768) { + count_drop(); + return false; + } + } + } + have_host_ = true; + last_host_us_ = received_us; + if (has_update) { + command.frame = frame; + enqueue(command); + } + return true; +} + +void SwitchHdRumbleSynth::feedback(uint64_t at_us, uint32_t duration_us, + uint8_t low_magnitude, + uint8_t high_magnitude) { + Command command; + if ((have_feedback_ && older(at_us, last_feedback_us_)) || + !timestamp_sample(at_us, command.sample) || + !timestamp_sample(at_us + duration_us, command.expires)) { + count_drop(); + return; + } + have_feedback_ = true; + last_feedback_us_ = at_us; + command.is_feedback = true; + command.low = (static_cast(low_magnitude) * 32768u + 127u) / 255u; + command.high = (static_cast(high_magnitude) * 32768u + 127u) / 255u; + enqueue(command); +} + +void SwitchHdRumbleSynth::enqueue(const Command& command) { + if (count_ == kCapacity) { + const int64_t oldest_sample = commands_[head_].sample; + const int64_t watermark = command.sample < oldest_sample + ? command.sample : oldest_sample; + advance_to(watermark > 0 ? static_cast(watermark) : 0, true); + if (count_ == kCapacity) { + // An older command from the other producer is itself the oldest. + // Fold it directly into the baseline without losing either side. + apply(command); + count_drop(); + return; + } + } + unsigned position = count_; + while (position && commands_[(head_ + position - 1) % kCapacity].sample > + command.sample) { + commands_[(head_ + position) % kCapacity] = + commands_[(head_ + position - 1) % kCapacity]; + --position; + } + commands_[(head_ + position) % kCapacity] = command; + ++count_; +} + +void SwitchHdRumbleSynth::apply(const Command& command) { + if (command.is_feedback) { + feedback_expires_ = command.expires; + feedback_low_ = command.low; + feedback_high_ = command.high; + return; + } + for (unsigned side = 0; side < 2; ++side) { + if (command.frame.actuators[side].sample_count) { + sides_[side].frame = command.frame.actuators[side]; + sides_[side].start = command.sample; + sides_[side].expires = command.sample + kWatchdogSamples; + } + } +} + +void SwitchHdRumbleSynth::apply_due(bool discarded) { + while (count_ && due(commands_[head_].sample, cursor_)) { + apply(commands_[head_]); + head_ = (head_ + 1) % kCapacity; + --count_; + if (discarded) { + count_drop(); + } + } +} + +const SwitchHapticsSample& SwitchHdRumbleSynth::host_sample(unsigned side) const { + const Side& state = sides_[side]; + const unsigned spacing = kWindowSamples / state.frame.sample_count; + unsigned index = 0; + while (index + 1 < state.frame.sample_count && + due(state.start + (index + 1) * spacing, cursor_)) { + ++index; + } + return state.frame.samples[index]; +} + +uint64_t SwitchHdRumbleSynth::next_boundary(uint64_t limit) const { + const auto consider = [this, &limit](int64_t boundary) { + if (!due(boundary, cursor_) && static_cast(boundary) < limit) { + limit = static_cast(boundary); + } + }; + if (count_) { + consider(commands_[head_].sample); + } + for (const Side& side : sides_) { + const unsigned spacing = kWindowSamples / side.frame.sample_count; + for (unsigned index = 1; index < side.frame.sample_count; ++index) { + consider(side.start + index * spacing); + } + consider(side.expires); + } + consider(feedback_expires_); + return limit; +} + +void SwitchHdRumbleSynth::advance_phases(uint64_t samples) { + // Multiplication modulo 2^32 skips arbitrarily large intervals in O(1). + const uint32_t count = static_cast(samples); + for (unsigned side = 0; side < 2; ++side) { + const auto& sample = host_sample(side); + phase_[side][0] += kPhaseIncrement[sample.low_frequency_index] * count; + phase_[side][1] += kPhaseIncrement[sample.high_frequency_index + 32] * count; + } +} + +void SwitchHdRumbleSynth::advance_to(uint64_t sample, bool discarded) { + apply_due(discarded); + while (cursor_ < sample) { + const uint64_t boundary = next_boundary(sample); + advance_phases(boundary - cursor_); + cursor_ = boundary; + apply_due(discarded); + } +} + +void SwitchHdRumbleSynth::render(uint64_t first_sample, uint32_t frames, + uint8_t* interleaved_stereo) { + if (!frames) { + advance_to(first_sample); + return; + } + if (!interleaved_stereo) { + return; + } + if (first_sample > UINT64_MAX - frames) { + memset(interleaved_stereo, 0, static_cast(frames) * 2); + return; + } + if (first_sample < cursor_) { + const uint64_t consumed = cursor_ - first_sample; + const uint32_t silence = consumed < frames + ? static_cast(consumed) : frames; + memset(interleaved_stereo, 0, static_cast(silence) * 2); + interleaved_stereo += static_cast(silence) * 2; + first_sample += silence; + frames -= silence; + } + advance_to(first_sample); + const uint64_t end = first_sample + frames; + while (cursor_ < end) { + const uint64_t boundary = next_boundary(end); + uint32_t increment[2][2]; + uint16_t amplitude[2][2]; + const bool overlay = !due(feedback_expires_, cursor_) && + (feedback_low_ || feedback_high_); + for (unsigned side = 0; side < 2; ++side) { + const auto& sample = host_sample(side); + increment[side][0] = kPhaseIncrement[sample.low_frequency_index]; + increment[side][1] = kPhaseIncrement[sample.high_frequency_index + 32]; + const bool expired = due(sides_[side].expires, cursor_); + amplitude[side][0] = expired ? 0 : sample.low_amplitude_q15; + amplitude[side][1] = expired ? 0 : sample.high_amplitude_q15; + if (!overlay) apply_host_gain(amplitude[side][0], amplitude[side][1]); + } + uint32_t feedback_low_phase = kPhaseIncrement[64] * + static_cast(cursor_); + uint32_t feedback_high_phase = kPhaseIncrement[96] * + 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; + feedback_low_phase += kPhaseIncrement[64]; + feedback_high_phase += kPhaseIncrement[96]; + } else { + for (unsigned side = 0; side < 2; ++side) { + *interleaved_stereo++ = mix(phase_[side][0], phase_[side][1], + amplitude[side][0], amplitude[side][1]); + } + } + for (unsigned side = 0; side < 2; ++side) { + phase_[side][0] += increment[side][0]; + phase_[side][1] += increment[side][1]; + } + } + apply_due(); + } +} diff --git a/src/firmware/input/switch_hd_rumble_synth.h b/src/firmware/input/switch_hd_rumble_synth.h new file mode 100644 index 0000000..a91fd23 --- /dev/null +++ b/src/firmware/input/switch_hd_rumble_synth.h @@ -0,0 +1,89 @@ +#pragma once + +#include + +#include "usb/switch/switch_haptics.h" + +// Single-core, allocation-free 3 kHz stereo PCM timeline. All times use the same +// 64-bit microsecond clock; unsigned clock rollover is supported for intervals +// shorter than 2^63 us. reset() establishes sample zero and zero oscillator phase. +// Host PCM gets 1.5x gain after profile scaling, jointly limited to available +// mixer headroom so two-band balance is preserved. Feedback gain is unchanged. +class SwitchHdRumbleSynth { +public: + void reset(uint64_t epoch_us); + + // Zero-count sides are untouched (including their independent watchdog). + // Duplicate timestamps are accepted in call order; the last update wins. + // Out-of-order or malformed batches are rejected and counted. Pre-epoch + // batches retain their original substep position and expiry; already + // expired pre-epoch batches are rejected. Late ordered batches take effect + // at the render cursor's current substep, never replaying earlier substeps + // or extending their original expiry. + bool push(const SwitchHapticsFrame& frame, uint64_t received_us); + + // Signed int8 PCM encoded in bytes, left then right. Calls normally advance + // monotonically; forward gaps advance phase analytically, not sample by + // sample. Already consumed samples are returned as silence, never replayed. + void render(uint64_t first_sample, uint32_t frames, + uint8_t* interleaved_stereo); + + // Timestamped conventional override on both sides, at 160/320 Hz. Zero + // duration or two zero magnitudes cancels only the override at at_us. + // Host oscillators and updates continue underneath it; expiry reveals the + // current host state. Override phases also free-run from the stream epoch. + // Feedback timestamps must be chronological independently of push(). + void feedback(uint64_t at_us, uint32_t duration_us, + uint8_t low_magnitude, uint8_t high_magnitude); + + uint32_t dropped_updates() const { return dropped_updates_; } + +private: + static constexpr uint8_t kCapacity = 16; + + struct Command { + int64_t sample = 0; + int64_t expires = 0; + SwitchHapticsFrame frame{}; + uint16_t low = 0; + uint16_t high = 0; + bool is_feedback = false; + }; + + struct Side { + SwitchHapticsActuatorFrame frame{1, {}}; + int64_t start = 0; + int64_t expires = 0; + }; + + // A fixed ring shared by host and feedback commands, ordered by sample. + // Overflow consumes the oldest command analytically into the live baseline, + // advancing a discard watermark. Unrendered PCM before that watermark is + // silence. Thus missing history cannot replay later, and partial-side state + // and oscillator phase survive eviction without an additional history ring. + void enqueue(const Command& command); + void apply(const Command& command); + void apply_due(bool discarded = false); + void advance_to(uint64_t sample, bool discarded = false); + void advance_phases(uint64_t samples); + uint64_t next_boundary(uint64_t limit) const; + const SwitchHapticsSample& host_sample(unsigned side) const; + bool timestamp_sample(uint64_t timestamp_us, int64_t& sample) const; + void count_drop(); + + uint64_t epoch_us_ = 0; + uint64_t cursor_ = 0; + uint64_t last_host_us_ = 0; + uint64_t last_feedback_us_ = 0; + bool have_host_ = false; + bool have_feedback_ = false; + uint8_t head_ = 0; + uint8_t count_ = 0; + uint32_t dropped_updates_ = 0; + Command commands_[kCapacity]{}; + Side sides_[2]{}; + uint32_t phase_[2][2]{}; + int64_t feedback_expires_ = 0; + uint16_t feedback_low_ = 0; + uint16_t feedback_high_ = 0; +}; diff --git a/src/firmware/profile/controller_profile_transform.cpp b/src/firmware/profile/controller_profile_transform.cpp index 1002ed0..fb8cec0 100644 --- a/src/firmware/profile/controller_profile_transform.cpp +++ b/src/firmware/profile/controller_profile_transform.cpp @@ -416,12 +416,21 @@ uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude, ControllerRumbleOutput controller_profile_scale_host_rumble( const ControllerRumbleOutput& input, const ControllerProfile& profile) { - return { - controller_profile_scale_rumble_magnitude( - input.low_frequency_magnitude, profile.strong_rumble_scale), - controller_profile_scale_rumble_magnitude( - input.high_frequency_magnitude, profile.weak_rumble_scale), - }; + ControllerRumbleOutput output = input; + output.low_frequency_magnitude = controller_profile_scale_rumble_magnitude( + input.low_frequency_magnitude, profile.strong_rumble_scale); + output.high_frequency_magnitude = controller_profile_scale_rumble_magnitude( + input.high_frequency_magnitude, profile.weak_rumble_scale); + for (SwitchHapticsActuatorFrame& actuator : output.hd.actuators) { + for (uint8_t index = 0; index < actuator.sample_count && index < 3; ++index) { + SwitchHapticsSample& sample = actuator.samples[index]; + sample.low_amplitude_q15 = static_cast( + (uint32_t{sample.low_amplitude_q15} * profile.strong_rumble_scale + 127u) / 255u); + sample.high_amplitude_q15 = static_cast( + (uint32_t{sample.high_amplitude_q15} * profile.weak_rumble_scale + 127u) / 255u); + } + } + return output; } ControllerProfileConfirmationPolicy controller_profile_confirmation_policy( diff --git a/src/firmware/usb/switch/switch_haptics.cpp b/src/firmware/usb/switch/switch_haptics.cpp index e9ac083..487a24f 100644 --- a/src/firmware/usb/switch/switch_haptics.cpp +++ b/src/firmware/usb/switch/switch_haptics.cpp @@ -1,5 +1,5 @@ #include "usb/switch/switch_haptics.h" -#include +#include "usb/switch/switch_haptics_amplitudes.h" #include namespace { @@ -149,16 +149,31 @@ void SwitchHapticsDecoder::reset() { reset_actuator(actuators_[1]); } +void SwitchHapticsDecoder::append_sample( + const ActuatorState& state, SwitchHapticsActuatorFrame& output) { + if (output.sample_count >= 3) { + return; + } + output.samples[output.sample_count++] = { + state.low_frequency, state.high_frequency, + SwitchHapticsTables::kAmplitudeQ15[state.low_amplitude], + SwitchHapticsTables::kAmplitudeQ15[state.high_amplitude], + }; +} + SwitchHapticsDecoder::AmplitudePeak SwitchHapticsDecoder::decode_actuator( - ActuatorState& state, uint32_t word) { + ActuatorState& state, uint32_t word, SwitchHapticsActuatorFrame& output) { + output = {}; if (word == 0 || word == kNeutralWord) { reset_actuator(state); state.last_word = word; state.have_last_word = true; + append_sample(state, output); return {0, 0}; } if (state.have_last_word && state.last_word == word) { + append_sample(state, output); return {state.low_amplitude, state.high_amplitude}; } state.last_word = word; @@ -171,10 +186,12 @@ SwitchHapticsDecoder::AmplitudePeak SwitchHapticsDecoder::decode_actuator( if (frame_count == 0) { state.high_amplitude = 0; + append_sample(state, output); return {state.low_amplitude, 0}; } const auto record_sample = [&]() { + append_sample(state, output); if (state.low_amplitude > peak.low) { peak.low = state.low_amplitude; } @@ -269,37 +286,33 @@ SwitchHapticsDecoder::AmplitudePeak SwitchHapticsDecoder::decode_actuator( } if (!decoded) { + append_sample(state, output); return {state.low_amplitude, state.high_amplitude}; } return peak; } uint8_t SwitchHapticsDecoder::amplitude_to_magnitude(uint8_t amplitude_index) { - if (amplitude_index < 2) { - return 0; - } - - const double exponent = -8.0 + static_cast(amplitude_index) / 32.0; - const double scaled = std::exp2(exponent) * 255.0; - unsigned magnitude = static_cast(scaled + 0.5); - if (magnitude > 255u) { - magnitude = 255u; - } - return static_cast(magnitude); + return SwitchHapticsTables::kMagnitude[amplitude_index]; } ControllerRumbleOutput SwitchHapticsDecoder::decode(const uint8_t payload[8]) { + ControllerRumbleOutput output{}; AmplitudePeak peaks[2] = { {actuators_[0].low_amplitude, actuators_[0].high_amplitude}, {actuators_[1].low_amplitude, actuators_[1].high_amplitude}, }; if (payload != nullptr) { - peaks[0] = decode_actuator(actuators_[0], load_little_endian_word(payload)); - peaks[1] = decode_actuator(actuators_[1], load_little_endian_word(payload + 4)); + peaks[0] = decode_actuator( + actuators_[0], load_little_endian_word(payload), output.hd.actuators[0]); + peaks[1] = decode_actuator( + actuators_[1], load_little_endian_word(payload + 4), output.hd.actuators[1]); } const uint8_t low_peak = peaks[0].low > peaks[1].low ? peaks[0].low : peaks[1].low; const uint8_t high_peak = peaks[0].high > peaks[1].high ? peaks[0].high : peaks[1].high; - return {amplitude_to_magnitude(low_peak), amplitude_to_magnitude(high_peak)}; + output.low_frequency_magnitude = amplitude_to_magnitude(low_peak); + output.high_frequency_magnitude = amplitude_to_magnitude(high_peak); + return output; } diff --git a/src/firmware/usb/switch/switch_haptics.h b/src/firmware/usb/switch/switch_haptics.h index 9d8c75a..b940c19 100644 --- a/src/firmware/usb/switch/switch_haptics.h +++ b/src/firmware/usb/switch/switch_haptics.h @@ -4,9 +4,31 @@ #include #include +// Decoded indices are logarithmic frequencies; amplitudes are linear Q0.15. +// Low/high frequency index 64 means 160/320 Hz respectively. +struct SwitchHapticsSample { + uint8_t low_frequency_index = 64; + uint8_t high_frequency_index = 64; + uint16_t low_amplitude_q15 = 0; + uint16_t high_amplitude_q15 = 0; +}; + +struct SwitchHapticsActuatorFrame { + uint8_t sample_count = 0; + SwitchHapticsSample samples[3]{}; +}; + +struct SwitchHapticsFrame { + SwitchHapticsActuatorFrame actuators[2]{}; // Left, right. +}; + struct ControllerRumbleOutput { uint8_t low_frequency_magnitude; uint8_t high_frequency_magnitude; + // Counts are zero for conventional rumble (XInput/UART/local feedback). + // Switch packets carry ordered per-side substeps in addition to the + // compatibility magnitudes consumed by existing non-native backends. + SwitchHapticsFrame hd{}; }; typedef void (*ControllerRumbleCallback)( uint8_t instance, const ControllerRumbleOutput& rumble); @@ -40,7 +62,10 @@ private: }; static void reset_actuator(ActuatorState& state); - static AmplitudePeak decode_actuator(ActuatorState& state, uint32_t word); + static AmplitudePeak decode_actuator(ActuatorState& state, uint32_t word, + SwitchHapticsActuatorFrame& output); + static void append_sample(const ActuatorState& state, + SwitchHapticsActuatorFrame& output); static uint8_t amplitude_to_magnitude(uint8_t amplitude_index); ActuatorState actuators_[2]; diff --git a/src/firmware/usb/switch/switch_haptics_amplitudes.h b/src/firmware/usb/switch/switch_haptics_amplitudes.h new file mode 100644 index 0000000..e088a55 --- /dev/null +++ b/src/firmware/usb/switch/switch_haptics_amplitudes.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +// Generated from the decoder's existing envelope: round(2^(-8+i/32)*scale), +// with indices 0 and 1 silent. Avoid transcendental math on USB report delivery. +namespace SwitchHapticsTables { +constexpr uint16_t kAmplitudeQ15[256] = { + 0, 0, 134, 137, 140, 143, 146, 149, 152, 156, 159, 162, 166, 170, 173, 177, + 181, 185, 189, 193, 197, 202, 206, 211, 215, 220, 225, 230, 235, 240, 245, 251, + 256, 262, 267, 273, 279, 285, 292, 298, 304, 311, 318, 325, 332, 339, 347, 354, + 362, 370, 378, 386, 395, 403, 412, 421, 431, 440, 450, 459, 470, 480, 490, 501, + 512, 523, 535, 546, 558, 571, 583, 596, 609, 622, 636, 650, 664, 679, 693, 709, + 724, 740, 756, 773, 790, 807, 825, 843, 861, 880, 899, 919, 939, 960, 981, 1002, + 1024, 1046, 1069, 1093, 1117, 1141, 1166, 1192, 1218, 1244, 1272, 1300, 1328, 1357, 1387, 1417, + 1448, 1480, 1512, 1545, 1579, 1614, 1649, 1685, 1722, 1760, 1798, 1838, 1878, 1919, 1961, 2004, + 2048, 2093, 2139, 2186, 2233, 2282, 2332, 2383, 2435, 2489, 2543, 2599, 2656, 2714, 2774, 2834, + 2896, 2960, 3025, 3091, 3158, 3228, 3298, 3371, 3444, 3520, 3597, 3676, 3756, 3838, 3922, 4008, + 4096, 4186, 4277, 4371, 4467, 4565, 4664, 4767, 4871, 4978, 5087, 5198, 5312, 5428, 5547, 5668, + 5793, 5919, 6049, 6182, 6317, 6455, 6597, 6741, 6889, 7039, 7194, 7351, 7512, 7677, 7845, 8016, + 8192, 8371, 8555, 8742, 8933, 9129, 9329, 9533, 9742, 9955, 10173, 10396, 10624, 10856, 11094, 11337, + 11585, 11839, 12098, 12363, 12634, 12910, 13193, 13482, 13777, 14079, 14387, 14702, 15024, 15353, 15689, 16033, + 16384, 16743, 17109, 17484, 17867, 18258, 18658, 19066, 19484, 19911, 20347, 20792, 21247, 21713, 22188, 22674, + 23170, 23678, 24196, 24726, 25268, 25821, 26386, 26964, 27554, 28158, 28774, 29405, 30048, 30706, 31379, 32066, +}; +constexpr uint8_t kMagnitude[256] = { + 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, + 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, + 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 8, 8, + 8, 8, 8, 9, 9, 9, 9, 9, 9, 10, 10, 10, 10, 11, 11, 11, + 11, 12, 12, 12, 12, 13, 13, 13, 13, 14, 14, 14, 15, 15, 15, 16, + 16, 16, 17, 17, 17, 18, 18, 19, 19, 19, 20, 20, 21, 21, 22, 22, + 23, 23, 24, 24, 25, 25, 26, 26, 27, 27, 28, 29, 29, 30, 31, 31, + 32, 33, 33, 34, 35, 36, 36, 37, 38, 39, 40, 40, 41, 42, 43, 44, + 45, 46, 47, 48, 49, 50, 51, 52, 54, 55, 56, 57, 58, 60, 61, 62, + 64, 65, 67, 68, 70, 71, 73, 74, 76, 77, 79, 81, 83, 84, 86, 88, + 90, 92, 94, 96, 98, 100, 103, 105, 107, 110, 112, 114, 117, 119, 122, 125, + 128, 130, 133, 136, 139, 142, 145, 148, 152, 155, 158, 162, 165, 169, 173, 176, + 180, 184, 188, 192, 197, 201, 205, 210, 214, 219, 224, 229, 234, 239, 244, 250, +}; +} // namespace SwitchHapticsTables diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index 824b4c7..88fdadf 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -228,6 +228,9 @@ size_t encode_haptics_experiment(uint8_t* output, size_t output_size) { payload[68] = static_cast(diagnostics.state); payload[69] = diagnostics.slot; payload[70] = diagnostics.last_error; + payload[72] = diagnostics.mode; + write_u32(&payload[76], diagnostics.host_updates); + write_u32(&payload[80], diagnostics.dropped_updates); return encode_response( Operation::kHapticsExperiment, Status::kOk, 0, kHapticsExperimentSchemaVersion, diagnostics.run_id, @@ -761,7 +764,7 @@ bool process_out_request() { return true; case Operation::kHapticsExperiment: if (request.payload_size != 2 || - payload[0] > 1 || + payload[0] > 2 || payload[1] >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT) { return false; } diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index f5c1363..5d1a3ac 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -29,8 +29,8 @@ constexpr size_t kProfileMetadataPayloadSize = (CONTROLLER_PROFILE_COUNT + 1) * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1); constexpr uint16_t kProfileMetadataSchemaVersion = 1; -constexpr uint16_t kHapticsExperimentSchemaVersion = 2; -constexpr size_t kHapticsExperimentPayloadSize = 72; +constexpr uint16_t kHapticsExperimentSchemaVersion = 3; +constexpr size_t kHapticsExperimentPayloadSize = 84; constexpr uint16_t kHapticsTransportProbeSchemaVersion = 2; constexpr size_t kHapticsTransportProbePayloadSize = 128; constexpr size_t kMaximumResponseSize = diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index f6f4d19..387ba9f 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -135,8 +135,8 @@ PROFILE_METADATA_SCHEMA_VERSION = 1 PROFILE_METADATA_MAX_BYTES = 31 PROFILE_METADATA_VALUE_SIZE = 32 PROFILE_METADATA_SIZE = 288 -HAPTICS_EXPERIMENT_SCHEMA_VERSION = 2 -HAPTICS_EXPERIMENT_SIZE = 72 +HAPTICS_EXPERIMENT_SCHEMA_VERSION = 3 +HAPTICS_EXPERIMENT_SIZE = 84 HAPTICS_EXPERIMENT_SLOT_COUNT = 4 HAPTICS_TRANSPORT_PROBE_SCHEMA_VERSION = 2 HAPTICS_TRANSPORT_PROBE_SIZE = 128 @@ -144,6 +144,7 @@ HAPTICS_EXPERIMENT_STATES = ( "idle", "pending", "running", "completed", "stopped", "disconnected", "unsupported", "error", ) +HAPTICS_EXPERIMENT_MODES = ("fixture", "gameplay") HAPTICS_EXPERIMENT_ERRORS = { 0: "none", 1: "unsupported controller or Bluetooth protocol", @@ -162,6 +163,25 @@ HAPTICS_EXPERIMENT_EVIDENCE_NOTE = ( "onset or playback; USB ACK only accepts a request. " "The initial 1.024 s of priming silence is intentional, not transport delay." ) +HAPTICS_GAMEPLAY_EVIDENCE_NOTE = ( + "Send timestamps measure firmware/HCI submission, not physical actuator " + "onset or playback; USB ACK only accepts a request. Gameplay streams " + "continuously, including silence without host commands; first-tone " + "timestamps remain zero until the first nonzero PCM." +) +HAPTICS_GAMEPLAY_ARMING_NOTE = ( + "PC-controlled gameplay arming does not persist across power cycles. " + "Firmware built with SWITCH_PICO_HD_RUMBLE=ON automatically arms only " + "slot 0 by default; use gameplay --slot to select another controller." +) +HAPTICS_GAMEPLAY_TIMING = { + "sample_rate_hz": 3000, + "stereo_frames_per_packet": 64, + "lookback_us": 64000000 / 3000, + "command_window_us": 8000, + "watchdog_us": 50000, + "host_gain": 1.5, +} HAPTICS_TRANSPORT_PROBE_UNSUPPORTED_HINT = ( "Firmware does not support haptics transport profile operation 0x41. " "Install updated firmware built with SWITCH_PICO_HAPTICS_EXPERIMENT=ON " @@ -326,11 +346,18 @@ class HapticsExperimentDiagnostics: state: int slot: int | None last_error: int + mode: int + host_updates: int + dropped_updates: int @property def state_name(self) -> str: return HAPTICS_EXPERIMENT_STATES[self.state] + @property + def mode_name(self) -> str: + return HAPTICS_EXPERIMENT_MODES[self.mode] + @property def error_name(self) -> str: return HAPTICS_EXPERIMENT_ERRORS.get( @@ -348,16 +375,23 @@ class HapticsExperimentDiagnostics: return (self.first_tone_sent_us - self.first_tone_due_us) & 0xFFFFFFFF def to_json_object(self) -> dict[str, Any]: - return { + values = { **asdict(self), "schema_version": HAPTICS_EXPERIMENT_SCHEMA_VERSION, "state_name": self.state_name, + "mode_name": self.mode_name, "error_name": self.error_name, "firmware_supported": self.firmware_supported, "first_tone_submission_delay_us": self.first_tone_submission_delay_us, - "pattern": HAPTICS_EXPERIMENT_PATTERN, - "evidence_note": HAPTICS_EXPERIMENT_EVIDENCE_NOTE, } + if self.mode == 1: + values["gameplay"] = HAPTICS_GAMEPLAY_TIMING + values["evidence_note"] = HAPTICS_GAMEPLAY_EVIDENCE_NOTE + values["arming_note"] = HAPTICS_GAMEPLAY_ARMING_NOTE + else: + values["pattern"] = HAPTICS_EXPERIMENT_PATTERN + values["evidence_note"] = HAPTICS_EXPERIMENT_EVIDENCE_NOTE + return values @dataclass(frozen=True) @@ -2108,17 +2142,22 @@ def parse_haptics_experiment(envelope: Envelope) -> HapticsExperimentDiagnostics state, slot, last_error, reserved = struct.unpack_from( "<4B", envelope.payload, 68 ) - if envelope.flags != 0 or reserved != 0: + mode = envelope.payload[72] + host_updates, dropped_updates = struct.unpack_from("<2I", envelope.payload, 76) + if envelope.flags != 0 or reserved != 0 or any(envelope.payload[73:76]): raise ConfigManagerError("invalid haptics experiment reserved flags") if state >= len(HAPTICS_EXPERIMENT_STATES): raise ConfigManagerError(f"invalid haptics experiment state {state}") + if mode >= len(HAPTICS_EXPERIMENT_MODES): + raise ConfigManagerError(f"invalid haptics experiment mode {mode}") if slot >= HAPTICS_EXPERIMENT_SLOT_COUNT and not ( slot == 0xFF and HAPTICS_EXPERIMENT_STATES[state] in ("idle", "unsupported") ): raise ConfigManagerError(f"invalid haptics experiment slot {slot}") return HapticsExperimentDiagnostics( *counters, state=state, slot=None if slot == 0xFF else slot, - last_error=last_error, + last_error=last_error, mode=mode, host_updates=host_updates, + dropped_updates=dropped_updates, ) @@ -2178,9 +2217,10 @@ def read_haptics_experiment_profile( if ( (after.run_id, after.connection_generation) != correlation or (transport.run_id, transport.connection_generation) != correlation + or (after.slot, after.mode) != (before.slot, before.mode) ): raise ConfigManagerError( - "haptics experiment run or connection generation changed or does not " + "haptics experiment run, slot, mode, or connection generation changed or does not " "match the transport profile; cannot attribute measurements. " "Read profile again after the accepted run has started or finished." ) @@ -2264,10 +2304,11 @@ def _print_haptics_experiment( return print( f"Haptics experiment: {snapshot.state_name}; run={snapshot.run_id}; " - f"slot={snapshot.slot if snapshot.slot is not None else 'none'}" + f"slot={snapshot.slot if snapshot.slot is not None else 'none'}; " + f"mode={snapshot.mode_name}" ) for name, value in asdict(snapshot).items(): - if name not in ("state", "slot", "last_error"): + if name not in ("state", "slot", "last_error", "mode"): print(f" {name}: {value}") print(f" last_error: {snapshot.last_error} ({snapshot.error_name})") delay = snapshot.first_tone_submission_delay_us @@ -2275,18 +2316,27 @@ def _print_haptics_experiment( " first_tone_submission_delay_us: " f"{delay if delay is not None else 'not recorded'}" ) - print( - "Pattern: 3 kHz, 64 stereo frames/packet, peak 32/127; " - "48 packets priming silence (1.024 s), 4 cycles of " - "left 100 Hz / silence / right 200 Hz / silence " - "(12 packets = 256 ms each), 48 packets trailing silence (1.024 s); " - "288 packets / 6.144 s total. Initial mode handoff carries 32 silent frames." - ) + if snapshot.mode == 1: + print( + "Gameplay: continuous 3 kHz, 64 stereo frames/packet; " + "21333.333 us lookback, 8000 us command window, " + "50000 us host-effect watchdog; 1.5x gameplay gain, jointly " + "headroom-limited. Silence continues without commands." + ) + print(HAPTICS_GAMEPLAY_ARMING_NOTE) + else: + print( + "Pattern: 3 kHz, 64 stereo frames/packet, peak 32/127; " + "48 packets priming silence (1.024 s), 4 cycles of " + "left 100 Hz / silence / right 200 Hz / silence " + "(12 packets = 256 ms each), 48 packets trailing silence (1.024 s); " + "288 packets / 6.144 s total. Initial mode handoff carries 32 silent frames." + ) print( "Timestamp fields are low 32-bit Pico uptime microseconds; " "differences use unsigned wraparound." ) - print(HAPTICS_EXPERIMENT_EVIDENCE_NOTE, flush=True) + print(values["evidence_note"], flush=True) def _watch_haptics_experiment( @@ -2295,6 +2345,7 @@ def _watch_haptics_experiment( ) -> None: run_id = snapshot.run_id slot = snapshot.slot + mode = snapshot.mode while True: _print_haptics_experiment(snapshot, as_json=as_json) _raise_haptics_experiment_failure(snapshot) @@ -2302,6 +2353,12 @@ def _watch_haptics_experiment( return remaining = deadline - time.monotonic() if remaining <= 0: + if mode == 1: + raise ConfigManagerError( + f"haptics gameplay watch reached --timeout; run {run_id} " + "is still armed. Use haptics-experiment stop --slot " + f"{slot} to disarm; watching does not stop the stream." + ) raise ConfigManagerError( f"haptics experiment run {run_id} did not reach a terminal " "state before --timeout; it may still be active, use status " @@ -2309,7 +2366,7 @@ def _watch_haptics_experiment( ) time.sleep(min(0.1, remaining)) snapshot = read_haptics_experiment(device) - if snapshot.run_id != run_id or snapshot.slot != slot: + if (snapshot.run_id, snapshot.slot, snapshot.mode) != (run_id, slot, mode): raise ConfigManagerError( "haptics experiment run changed while watching; " "cannot attribute measurements to the requested run" @@ -2341,7 +2398,8 @@ def _run_haptics_experiment_command( if not before.firmware_supported: _raise_haptics_experiment_failure(before) active = before.state_name in ("pending", "running") - if action == "start" and active: + starting = action in ("start", "gameplay") + if starting and active: raise ConfigManagerError( f"haptics experiment is already {before.state_name} " f"on slot {before.slot}; stop that run before starting another" @@ -2361,7 +2419,7 @@ def _run_haptics_experiment_command( return _control_out( device, OP_HAPTICS_EXPERIMENT, - bytes((1 if action == "start" else 0, args.slot)), + bytes(({"start": 1, "gameplay": 2, "stop": 0}[action], args.slot)), ) print( f"{action.capitalize()} request accepted; pending firmware confirmation. " @@ -2369,8 +2427,9 @@ def _run_haptics_experiment_command( file=sys.stderr if args.json else sys.stdout, flush=True, ) expected_run_id = ( - (before.run_id + 1) & 0xFFFFFFFF if action == "start" else before.run_id + (before.run_id + 1) & 0xFFFFFFFF if starting else before.run_id ) + expected_mode = (1 if action == "gameplay" else 0) if starting else before.mode observed_run = False while True: snapshot = read_haptics_experiment(device) @@ -2380,20 +2439,27 @@ def _run_haptics_experiment_command( raise ConfigManagerError( "haptics experiment response belongs to another slot" ) + if snapshot.mode != expected_mode: + raise ConfigManagerError( + "haptics experiment response belongs to another mode" + ) if snapshot.state_name in ("disconnected", "unsupported", "error"): _print_haptics_experiment(snapshot, as_json=args.json) _raise_haptics_experiment_failure(snapshot) - if action == "start" and args.watch: + if starting and args.watch: _watch_haptics_experiment( device, snapshot, deadline, as_json=args.json ) return - if snapshot.state_name in ("running", "completed") and action == "start": + if starting and ( + snapshot.state_name == "running" + or (action == "start" and snapshot.state_name == "completed") + ): _print_haptics_experiment(snapshot, as_json=args.json) if snapshot.state_name == "running": print( - "Firmware reports running; use start --watch or " - "status --watch to capture completion and later failures.", + "Firmware reports running; use status --watch " + "to capture later states and failures.", file=sys.stderr if args.json else sys.stdout, ) return @@ -2405,12 +2471,23 @@ def _run_haptics_experiment_command( f"haptics experiment {action} ended in unexpected " f"state {snapshot.state_name}" ) - elif action == "stop" or observed_run or snapshot.run_id != before.run_id: + elif ( + action == "stop" or observed_run + or (snapshot.run_id, snapshot.slot, snapshot.mode) + != (before.run_id, before.slot, before.mode) + ): raise ConfigManagerError( f"haptics experiment run changed before {action} was confirmed" ) remaining = deadline - time.monotonic() if remaining <= 0: + if action == "gameplay": + raise ConfigManagerError( + "haptics gameplay was not confirmed before --timeout; " + "the stream may still be armed. Use status to inspect it " + f"or haptics-experiment stop --slot {args.slot} to disarm " + "(USB ACK alone does not confirm it)" + ) raise ConfigManagerError( f"haptics experiment {action} was not confirmed before " "--timeout; check status (USB ACK alone does not confirm it)" @@ -3389,8 +3466,13 @@ def build_parser() -> argparse.ArgumentParser: haptics_commands = haptics.add_subparsers( dest="haptics_command", required=True ) - for action in ("start", "status", "stop"): - experiment = haptics_commands.add_parser(action) + for action, help_text in ( + ("start", "run the finite PCM fixture"), + ("gameplay", "arm continuous Nintendo HD-rumble PCM until stopped"), + ("status", "read the current fixture or gameplay stream"), + ("stop", "stop and disarm the selected stream"), + ): + experiment = haptics_commands.add_parser(action, help=help_text) if action != "status": experiment.add_argument( "--slot", type=int, choices=range(HAPTICS_EXPERIMENT_SLOT_COUNT), @@ -3399,7 +3481,8 @@ def build_parser() -> argparse.ArgumentParser: if action != "stop": experiment.add_argument( "--watch", action="store_true", - help="capture 100 ms status samples until terminal or --timeout", + help="capture 100 ms status samples until terminal or --timeout; " + "does not stop an armed gameplay stream", ) experiment.add_argument( "--json", action="store_true", diff --git a/tests/controller_profile_transform_test.cpp b/tests/controller_profile_transform_test.cpp index f27434b..5f012e5 100644 --- a/tests/controller_profile_transform_test.cpp +++ b/tests/controller_profile_transform_test.cpp @@ -404,6 +404,28 @@ void test_rumble_scaling_and_confirmation_policy() { UINT8_MAX, "full rumble scaling did not saturate at uint8 maximum"); + ControllerRumbleOutput hd_input{}; + hd_input.hd.actuators[0].sample_count = 2; + hd_input.hd.actuators[0].samples[0] = {48, 96, 18000, 10000}; + hd_input.hd.actuators[0].samples[1] = {49, 97, 31000, 0}; + hd_input.hd.actuators[1].sample_count = 1; + hd_input.hd.actuators[1].samples[0] = {20, 70, 12000, 10000}; + ControllerProfile hd_profile = profile; + hd_profile.strong_rumble_scale = 0; + hd_profile.weak_rumble_scale = 128; + const auto hd_output = controller_profile_scale_host_rumble(hd_input, hd_profile); + require(hd_output.hd.actuators[0].sample_count == 2 && + hd_output.hd.actuators[1].sample_count == 1 && + hd_output.hd.actuators[0].samples[1].low_frequency_index == 49 && + hd_output.hd.actuators[1].samples[0].high_frequency_index == 70, + "profile gain lost HD substeps or side-specific frequencies"); + require(hd_output.hd.actuators[0].samples[0].low_amplitude_q15 == 0 && + hd_output.hd.actuators[1].samples[0].low_amplitude_q15 == 0 && + hd_output.hd.actuators[0].samples[0].high_amplitude_q15 == 5020 && + hd_output.hd.actuators[1].samples[0].high_amplitude_q15 == 5020 && + hd_output.hd.actuators[0].samples[1].high_amplitude_q15 == 0, + "profile band gains were not applied to linear HD amplitudes"); + profile.confirmation_policy = ControllerProfileConfirmationPolicy::kLed; require(controller_profile_confirmation_policy(profile) == ControllerProfileConfirmationPolicy::kLed, diff --git a/tests/haptics_experiment_test.cpp b/tests/haptics_experiment_test.cpp index ed1e52d..61e02ed 100644 --- a/tests/haptics_experiment_test.cpp +++ b/tests/haptics_experiment_test.cpp @@ -1,5 +1,6 @@ #include "input/haptics_experiment.h" #include "input/haptics_transport_probe.h" +#include "usb/switch/switch_haptics.h" #include #include @@ -527,7 +528,7 @@ void reconnect_and_pending_generation() { void support_and_transport_errors() { reset(); - assert(!haptics_experiment_request(2, 0)); + assert(!haptics_experiment_request(3, 0)); assert(!haptics_experiment_request(1, 4)); devices[0].remote_mtu = 142; assert(haptics_experiment_request(1, 0)); @@ -597,6 +598,103 @@ void timing_cost_reentrancy_and_wrap() { assert(wrapped.max_send_gap_us <= 22000 && wrapped.sent_packets == 288); } +void gameplay_timeline_and_lifecycle() { + reset(); + SwitchHapticsDecoder decoder; + const auto feed = [&](bool left) { + const uint32_t active = (1u << 30) | (96u << 23) | (64u << 16) | (64u << 2); + const uint32_t words[] = {left ? active : 0x40400100u, + left ? 0x40400100u : active}; + uint8_t bytes[8]{}; + for (unsigned side = 0; side < 2; ++side) { + for (unsigned byte = 0; byte < 4; ++byte) { + bytes[side * 4 + byte] = static_cast(words[side] >> (8 * byte)); + } + } + const auto decoded = decoder.decode(bytes); + assert(haptics_experiment_submit(0, 100, now_us, decoded.hd)); + }; + assert(haptics_experiment_request(2, 0)); + const uint64_t started = now_us; + feed(true); // A sole first command survives the Pending -> Running boundary. + haptics_experiment_poll(); + assert(snapshot().mode == 1 && snapshot().state == HapticsExperimentState::kRunning); + assert(haptics_experiment_gameplay_owns(&devices[0])); + now_us = started + 8000; + feed(false); + run_until(due(started, 1) + 1000); + assert(pcm.size() == 2 && snapshot().host_updates == 2); + unsigned left_nonzero = 0, right_nonzero = 0; + for (unsigned frame = 0; frame < 64; ++frame) { + const auto left = pcm[1].bytes[10 + frame * 2]; + const auto right = pcm[1].bytes[11 + frame * 2]; + if (frame < 24) { + assert(right == 0); + left_nonzero += left != 0; + } else { + assert(left == 0); + right_nonzero += right != 0; + } + } + assert(left_nonzero > 10 && right_nonzero > 20); + SwitchHapticsFrame stale{}; + stale.actuators[0].sample_count = 1; + stale.actuators[0].samples[0].low_amplitude_q15 = 16000; + assert(!haptics_experiment_submit(0, 101, now_us, stale)); + run_until(started + 6300000); + assert(snapshot().state == HapticsExperimentState::kRunning); + assert(snapshot().sent_packets > 288 && snapshot().skipped_packets == 0); + for (unsigned byte = 10; byte < 138; ++byte) assert(pcm.back().bytes[byte] == 0); + assert(snapshot().dropped_updates == 0 && generic_sent.empty()); + assert(haptics_experiment_feedback(&devices[0], 100, 60, 30)); + run_until(now_us + 22000); + assert(generic_sent.empty()); // Local confirmation must not leave PCM mode. + assert(haptics_experiment_request(0, 0)); + haptics_experiment_poll(); + run_until(now_us + 10000); + assert(snapshot().state == HapticsExperimentState::kStopped && snapshot().mode == 1); + assert(!haptics_experiment_owns(&devices[0]) && generic_sent.size() == 2); +} + +void gameplay_missing_callback_is_bounded() { + reset(); + delivery = Delivery::kNever; + assert(haptics_experiment_request(2, 0)); + haptics_experiment_poll(); + run_until(now_us + 105000); + assert(snapshot().state == HapticsExperimentState::kError && snapshot().last_error == 4); + assert(!haptics_experiment_owns(&devices[0]) && timers.empty()); +} + +void gameplay_queued_start_and_command_overflow() { + reset(); + devices[0].credit = false; + emit_generic(&devices[0], GenericKind::kLed); + assert(haptics_experiment_request(2, 0)); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kPending && pcm.empty()); + run_until(now_us + 10000); + devices[0].credit = true; + assert(!dispatch(&devices[0], devices[0].conn.control_cid)); + run_until(now_us + 3000); + assert(snapshot().state == HapticsExperimentState::kRunning && pcm.size() == 1); + assert(generic_queue.empty() && generic_sent.size() == 1); + assert(generic_sent.front().kind == GenericKind::kLed); + + SwitchHapticsFrame frame{}; + frame.actuators[0].sample_count = 1; + frame.actuators[0].samples[0].low_amplitude_q15 = 20000; + const size_t sent_before = pcm.size(); + for (unsigned i = 0; i < 17; ++i) { + now_us += 8000; + assert(haptics_experiment_submit(0, 100, now_us, frame)); + } + run_until(now_us); + assert(snapshot().state == HapticsExperimentState::kRunning); + assert(snapshot().host_updates == 17 && snapshot().dropped_updates == 1); + assert(snapshot().skipped_packets != 0 && pcm.size() == sent_before + 1); +} + } // namespace // Transport attribution has its own native fixture; this fixture isolates PCM @@ -712,5 +810,8 @@ int main(int argc, char** argv) { reconnect_and_pending_generation(); support_and_transport_errors(); timing_cost_reentrancy_and_wrap(); + gameplay_timeline_and_lifecycle(); + gameplay_queued_start_and_command_overflow(); + gameplay_missing_callback_is_bounded(); std::cout << "haptics experiment behavioral regressions passed\n"; } diff --git a/tests/switch_haptics_test.cpp b/tests/switch_haptics_test.cpp index 6db7ec4..18f1fba 100644 --- a/tests/switch_haptics_test.cpp +++ b/tests/switch_haptics_test.cpp @@ -197,6 +197,58 @@ void test_output_report_normalization() { } } +void test_full_fidelity_states() { + const auto check = [](bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + ++failures; + } + }; + SwitchHapticsDecoder decoder; + auto bytes = payload(type_2(80, 100, 32, 127), type_2(16, 16, 100, 32)); + auto decoded = decoder.decode(bytes.data()); + const auto& left = decoded.hd.actuators[0]; + const auto& right = decoded.hd.actuators[1]; + check(left.sample_count == 1 && right.sample_count == 1, + "full state must retain both independent actuators"); + check(left.samples[0].low_frequency_index == 32 && + left.samples[0].high_frequency_index == 80 && + right.samples[0].low_frequency_index == 100 && + right.samples[0].high_frequency_index == 16, + "per-side frequencies were collapsed"); + check(left.samples[0].low_amplitude_q15 == 32066 && + right.samples[0].low_amplitude_q15 == 4096, + "per-side linear amplitudes were collapsed or incorrectly decoded"); + + decoder.reset(); + bytes = payload(type_2(64, 16, 64, 16), 0x40400100u); + decoder.decode(bytes.data()); + bytes = payload(type_1_three_samples(17, 17, 29, 29, 24, 24), 0x40400100u); + decoded = decoder.decode(bytes.data()); + const auto sequence = decoded.hd.actuators[0]; + check(sequence.sample_count == 3 && + sequence.samples[0].low_frequency_index == 65 && + sequence.samples[1].low_frequency_index == 66 && + sequence.samples[2].low_frequency_index == 66, + "ordered frequency substeps were lost"); + check(sequence.samples[0].low_amplitude_q15 > + sequence.samples[1].low_amplitude_q15 && + sequence.samples[1].low_amplitude_q15 == + sequence.samples[2].low_amplitude_q15, + "amplitude substeps were replaced by their peak or final value"); + decoded = decoder.decode(bytes.data()); + check(decoded.hd.actuators[0].sample_count == 1 && + decoded.hd.actuators[0].samples[0].low_frequency_index == 66 && + decoded.hd.actuators[0].samples[0].low_amplitude_q15 == + sequence.samples[2].low_amplitude_q15, + "repeated compressed commands replayed an old substep sequence"); + bytes = payload(0, type_2(64, 16, 64, 16)); + decoded = decoder.decode(bytes.data()); + check(decoded.hd.actuators[0].samples[0].low_amplitude_q15 == 0 && + decoded.hd.actuators[1].samples[0].low_amplitude_q15 != 0, + "neutral on one side stopped the other side"); +} + } // namespace int main() { @@ -207,6 +259,7 @@ int main() { test_left_right_peak_combination(); test_type_3_and_type_4_frames(); test_malformed_and_reserved_words_preserve_state(); + test_full_fidelity_states(); test_output_report_normalization(); if (failures != 0) { diff --git a/tests/switch_hd_rumble_synth_test.cpp b/tests/switch_hd_rumble_synth_test.cpp new file mode 100644 index 0000000..6c1a2ae --- /dev/null +++ b/tests/switch_hd_rumble_synth_test.cpp @@ -0,0 +1,394 @@ +#include "input/switch_hd_rumble_synth.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr double kTau = 6.2831853071795864769; +int failures = 0; + +void expect(bool condition, const char* scenario) { + if (!condition) { + std::cerr << scenario << '\n'; + ++failures; + } +} + +int signed_byte(uint8_t value) { + return value < 128 ? value : static_cast(value) - 256; +} + +SwitchHapticsSample state(uint8_t low_index = 64, uint16_t low = 32768, + uint8_t high_index = 64, uint16_t high = 0) { + return SwitchHapticsSample{low_index, high_index, low, high}; +} + +SwitchHapticsFrame one_side(unsigned side, SwitchHapticsSample sample = state()) { + SwitchHapticsFrame frame; + frame.actuators[side].sample_count = 1; + frame.actuators[side].samples[0] = sample; + return frame; +} + +std::vector render(SwitchHdRumbleSynth& synth, uint64_t first, + uint32_t frames) { + std::vector pcm(static_cast(frames) * 2, 0xcc); + synth.render(first, frames, pcm.data()); + return pcm; +} + +double wave(double cycles, uint16_t amplitude = 32768) { + return 95.25 * std::sin(kTau * cycles) * amplitude / 32768; +} + +template +void expect_wave(const std::vector& pcm, unsigned side, + Function expected, const char* scenario) { + for (size_t sample = 0; sample < pcm.size() / 2; ++sample) { + const double wanted = expected(sample); + const int actual = signed_byte(pcm[sample * 2 + side]); + if (std::abs(actual - wanted) > 0.65) { + std::cerr << scenario << ": sample " << sample << " side " << side + << " expected " << wanted << ", got " << actual << '\n'; + ++failures; + return; + } + } +} + +void expect_silent(const std::vector& pcm, const char* scenario) { + expect(std::all_of(pcm.begin(), pcm.end(), [](uint8_t v) { return v == 0; }), + scenario); +} + +double spectral_amplitude(const std::vector& pcm, unsigned side, + double frequency) { + double real = 0; + double imaginary = 0; + const size_t frames = pcm.size() / 2; + for (size_t n = 0; n < frames; ++n) { + const double angle = kTau * frequency * n / 3000; + const int value = signed_byte(pcm[2 * n + side]); + real += value * std::cos(angle); + imaginary += value * std::sin(angle); + } + return 2 * std::hypot(real, imaginary) / frames; +} + +void test_physical_frequency_and_channels() { + for (unsigned side = 0; side < 2; ++side) { + for (unsigned band = 0; band < 2; ++band) { + for (uint8_t index : {0, 32, 64, 96, 127}) { + SwitchHdRumbleSynth synth; + synth.reset(123456); + const auto tone = one_side(side, state(index, band ? 0 : 32768, + index, band ? 32768 : 0)); + std::vector pcm(2400); + for (unsigned first = 0; first < 1200; first += 60) { + expect(synth.push(tone, 123456 + first * 1000 / 3), + "periodic host refresh accepted"); + synth.render(first, 60, pcm.data() + first * 2); + } + const double frequency = (band ? 80 : 40) * std::exp2(index / 32.0); + expect_wave(pcm, side, [frequency](size_t n) { + return wave(frequency * n / 3000); + }, "physical frequency and free-running phase"); + expect_wave(pcm, 1 - side, [](size_t) { return 0; }, + "opposite actuator remains silent"); + double peak_frequency = 0; + double peak_amplitude = 0; + for (int offset = -12; offset <= 12; ++offset) { + const double candidate = frequency + offset * 0.25; + const double amplitude = spectral_amplitude(pcm, side, candidate); + if (amplitude > peak_amplitude) { + peak_amplitude = amplitude; + peak_frequency = candidate; + } + } + expect(std::abs(peak_frequency - frequency) <= 0.5 && + peak_amplitude > 92 && peak_amplitude < 99, + "DFT peak matches physical frequency including extreme indices"); + } + } + } +} + +void test_linear_mix_headroom() { + SwitchHdRumbleSynth synth; + synth.reset(0); + auto frame = one_side(0, state(64, 32768, 32, 32768)); // Both bands 160 Hz. + frame.actuators[1] = one_side(1, state(64, 32768, 64, 32768)).actuators[1]; + synth.push(frame, 0); + const auto pcm = render(synth, 0, 150); + expect_wave(pcm, 0, [](size_t n) { return 2 * wave(160.0 * n / 3000) / 1.5; }, + "coherent full-scale bands use headroom without waveform clipping"); + expect_wave(pcm, 1, [](size_t n) { + return (wave(160.0 * n / 3000) + wave(320.0 * n / 3000)) / 1.5; + }, "full-scale two-band balance is preserved by the joint gain ceiling"); + int sum = 0; + for (size_t n = 0; n < pcm.size() / 2; ++n) { + sum += signed_byte(pcm[n * 2]); + expect(signed_byte(pcm[n * 2]) != -128, "PCM never overflows signed headroom"); + } + expect(std::abs(sum) <= 1, "symmetric rounding does not add DC bias"); + + synth.reset(0); + synth.push(one_side(0, state(64, 32768, 64, 16384)), 0); + expect_wave(render(synth, 0, 150), 0, [](size_t n) { + return 127.0 * (2 * std::sin(kTau * 160.0 * n / 3000) + + std::sin(kTau * 320.0 * n / 3000)) / 3; + }, "headroom-limited boost retains a 2:1 band amplitude ratio"); + + synth.reset(0); + synth.push(one_side(0, state(127, 0, 127, 0)), 0); + expect_silent(render(synth, 0, 180), "profile-zero amplitudes are never boosted"); +} + +void test_substeps_and_preemption() { + SwitchHdRumbleSynth synth; + synth.reset(0); + SwitchHapticsFrame frame; + frame.actuators[0] = {3, {state(), state(64, 0), state(64, 16384)}}; + frame.actuators[1] = {2, {state(64, 0), state(64, 0, 64, 32768)}}; + synth.push(frame, 0); + auto pcm = render(synth, 0, 32); + expect_wave(pcm, 0, [](size_t n) { + return wave(160.0 * n / 3000, n < 8 ? 32768 : n < 16 ? 0 : 16384); + }, "three left substeps occupy 8/8/8 samples then hold"); + expect_wave(pcm, 1, [](size_t n) { + return n < 12 ? 0 : wave(320.0 * n / 3000); + }, "two right substeps independently occupy 12/12 samples"); + + synth.reset(0); + synth.push(frame, 0); + synth.push(one_side(0, state(64, 0)), 3000); // Sample 9 cancels old step 3. + pcm = render(synth, 0, 32); + expect_wave(pcm, 0, [](size_t n) { + return n < 8 ? wave(160.0 * n / 3000) : 0; + }, "new batch preempts future old substeps, not already elapsed samples"); + expect_wave(pcm, 1, [](size_t n) { + return n < 12 ? 0 : wave(320.0 * n / 3000); + }, "zero-count side preserves pending substeps on the other actuator"); +} + +void test_multiple_usb_updates_and_watchdogs() { + SwitchHdRumbleSynth synth; + synth.reset(1000); + synth.push(one_side(0), 1000); + synth.push(one_side(0, state(64, 0)), 6001); // Ceil to sample 16. + synth.push(one_side(0, state(64, 16384)), 12000); // Sample 33. + auto pcm = render(synth, 0, 64); + expect_wave(pcm, 0, [](size_t n) { + return wave(160.0 * n / 3000, n < 16 ? 32768 : n < 33 ? 0 : 16384); + }, "all USB updates within one 21.333 ms PCM interval are rendered"); + + synth.reset(0); + auto both = one_side(0); + both.actuators[1] = both.actuators[0]; + synth.push(both, 0); + synth.push(one_side(0), 20000); + synth.push(SwitchHapticsFrame{}, 40000); // Must not refresh either side. + pcm = render(synth, 0, 230); + expect_wave(pcm, 0, [](size_t n) { + return n < 210 ? wave(160.0 * n / 3000) : 0; + }, "left watchdog expires exactly 50 ms after its own update"); + expect_wave(pcm, 1, [](size_t n) { + return n < 150 ? wave(160.0 * n / 3000) : 0; + }, "zero-count right side does not refresh its watchdog"); +} + +void test_phase_continuity_and_partitioning() { + SwitchHdRumbleSynth whole; + SwitchHdRumbleSynth partitioned; + whole.reset(0); + partitioned.reset(0); + for (SwitchHdRumbleSynth* synth : {&whole, &partitioned}) { + synth->push(one_side(0), 0); + synth->push(one_side(0, state(96)), 5000); // Change frequency at sample 15. + synth->push(one_side(0, state(96)), 9000); // Identical state must not reset phase. + synth->push(one_side(0, state(96, 8192)), 12000); + } + const auto pcm = render(whole, 0, 80); + expect_wave(pcm, 0, [](size_t n) { + const double cycles = n < 15 ? n * 160.0 / 3000 + : (15 * 160.0 + (n - 15) * 320.0) / 3000; + return wave(cycles, n < 36 ? 32768 : 8192); + }, "frequency and amplitude transitions preserve accumulated phase"); + std::vector split(160); + for (unsigned n = 0; n < 80; ++n) { + partitioned.render(n, 1, split.data() + n * 2); + } + expect(split == pcm, "PCM is independent of render block partitioning"); +} + +void test_feedback_returns_to_live_host() { + SwitchHdRumbleSynth synth; + synth.reset(0); + synth.push(one_side(0), 0); + // Feedback may be delivered before an older USB frame drains on Core 1. + synth.feedback(5001, 4999, 0, 255); // Samples [16,30), not a whole PCM block. + synth.push(one_side(0, state(96, 16384)), 8000); // Sample 24, underneath overlay. + auto pcm = render(synth, 0, 64); + expect_wave(pcm, 0, [](size_t n) { + if (n >= 16 && n < 30) { + return wave(320.0 * n / 3000) / 1.5; + } + const double cycles = n < 24 ? n * 160.0 / 3000 + : (24 * 160.0 + (n - 24) * 320.0) / 3000; + return wave(cycles, n < 24 ? 32768 : 16384); + }, "partial feedback expiry returns to live host state and host phase"); + expect_wave(pcm, 1, [](size_t n) { + return n >= 16 && n < 30 ? wave(320.0 * n / 3000) / 1.5 : 0; + }, "feedback overrides both sides only for its actual duration"); + + synth.reset(0); + synth.push(one_side(0), 0); + synth.feedback(0, 100000, 128, 0); + synth.feedback(4000, 100000, 0, 0); + synth.feedback(8000, 100000, 0, 255); + synth.feedback(12000, 0, 255, 255); + pcm = render(synth, 0, 60); + expect_wave(pcm, 0, [](size_t n) { + if (n < 12) { + return wave(160.0 * n / 3000, static_cast((128u * 32768 + 127) / 255)) / 1.5; + } + const bool feedback = n >= 24 && n < 36; + return wave((feedback ? 320.0 : 160.0) * n / 3000) / (feedback ? 1.5 : 1); + }, "zero magnitudes and zero duration cancel override without cancelling host"); + + synth.reset(0); + synth.push(one_side(0), 0); + synth.feedback(0, 80000, 0, 255); + pcm = render(synth, 0, 270); + expect_wave(pcm, 0, [](size_t n) { + return n < 240 ? wave(320.0 * n / 3000) / 1.5 : 0; + }, "feedback expiry cannot resurrect an expired host effect"); +} + +void test_late_commands_and_clock_rollover() { + SwitchHdRumbleSynth synth; + synth.reset(10000); + SwitchHapticsFrame steps; + steps.actuators[0] = {3, {state(64, 32768), state(64, 16384), state(64, 8192)}}; + expect(synth.push(steps, 4000), "recent pre-epoch effect is accepted"); + auto pcm = render(synth, 0, 150); + expect_wave(pcm, 0, [](size_t n) { + return n < 132 ? wave(160.0 * n / 3000, 8192) : 0; + }, "pre-epoch effect starts at current substep and keeps original expiry"); + synth.reset(100000); + expect(!synth.push(steps, 50000) && synth.dropped_updates() == 1, + "already expired pre-epoch effect is rejected"); + expect_silent(render(synth, 0, 64), "expired pre-epoch effect never replays"); + + synth.reset(0); + render(synth, 0, 40); + expect(synth.push(steps, 0), "late but ordered frame is accepted"); + pcm = render(synth, 40, 130); + expect_wave(pcm, 0, [](size_t n) { + return n + 40 < 150 ? wave(160.0 * (n + 40) / 3000, 8192) : 0; + }, "late frame skips old substeps and does not restart watchdog"); + expect(!synth.push(one_side(0), UINT64_MAX), "out-of-order timestamp is rejected"); + + const uint64_t epoch = UINT64_MAX - 1000; + synth.reset(epoch); + synth.push(one_side(0), epoch); + synth.push(one_side(0, state(64, 16384)), epoch + 3000); + synth.feedback(epoch + 4000, 1000, 0, 255); + pcm = render(synth, 0, 30); + expect_wave(pcm, 0, [](size_t n) { + if (n >= 12 && n < 15) { + return wave(320.0 * n / 3000) / 1.5; + } + return wave(160.0 * n / 3000, n < 9 ? 32768 : 16384); + }, "64-bit microsecond clock rollover preserves order and duration"); + expect(synth.dropped_updates() == 0, "clock rollover is not an out-of-order update"); +} + +void test_stall_and_overflow() { + SwitchHdRumbleSynth skipped; + SwitchHdRumbleSynth rendered; + skipped.reset(0); + rendered.reset(0); + SwitchHapticsFrame steps; + steps.actuators[0] = {3, {state(32), state(96), state(127)}}; + for (SwitchHdRumbleSynth* synth : {&skipped, &rendered}) { + synth->push(steps, 0); + synth->push(one_side(0, state(64)), 12000); + synth->push(one_side(0, state(32)), 40000); + } + render(rendered, 0, 180); + expect(render(skipped, 180, 80) == render(rendered, 180, 80), + "forward gap analytically integrates every queued frequency transition"); + + constexpr uint64_t far = 3000000000ull; + expect_silent(render(skipped, far, 64), "giant stall skips stale sound without a PCM backlog"); + expect(skipped.push(one_side(0), (far + 64) * 1000 / 3), + "fresh effect after giant stall is accepted"); + const auto fresh = render(skipped, far + 64, 150); + const double fresh_amplitude = spectral_amplitude(fresh, 0, 160); + expect(fresh_amplitude > 93 && fresh_amplitude < 98, + "fresh 160 Hz effect resumes at full band amplitude after giant stall"); + expect_wave(fresh, 1, [](size_t) { return 0; }, + "resuming after stall does not activate the other actuator"); + expect_silent(render(skipped, far, 64), "already consumed PCM is not replayable"); + + SwitchHdRumbleSynth overflowing; + SwitchHdRumbleSynth reference; + overflowing.reset(0); + reference.reset(0); + for (unsigned n = 0; n < 40; ++n) { + const auto frame = one_side(n % 2, state(static_cast(32 + n % 4 * 16))); + overflowing.push(frame, n * 1000); + reference.push(frame, n * 1000); + render(reference, n * 3, 3); + } + expect(overflowing.dropped_updates() > 0, "bounded command ring reports discarded history"); + expect_silent(render(overflowing, 0, 30), "overflow watermark silences discarded past"); + expect(render(overflowing, 120, 120) == render(reference, 120, 120), + "overflow preserves complete per-side baseline and accumulated phase"); +} + +void test_duplicate_order_and_invalid_frames() { + SwitchHdRumbleSynth synth; + synth.reset(0); + synth.push(one_side(0), 1000); + synth.push(one_side(0, state(64, 8192)), 1000); + expect(!synth.push(one_side(0, state(64, 0)), 999), + "older timestamp cannot override newest accepted state"); + auto invalid = one_side(0); + invalid.actuators[0].sample_count = 4; + expect(!synth.push(invalid, 2000), "too many substeps rejects whole batch"); + invalid = one_side(0, state(128)); + expect(!synth.push(invalid, 2000), "out-of-range frequency rejects whole batch"); + invalid = one_side(0, state(64, 32769)); + expect(!synth.push(invalid, 2000), "out-of-range linear amplitude rejects whole batch"); + expect(synth.dropped_updates() == 4, "rejected batches are counted"); + const auto pcm = render(synth, 0, 30); + expect_wave(pcm, 0, [](size_t n) { + return n < 3 ? 0 : wave(160.0 * n / 3000, 8192); + }, "duplicate timestamp last-wins without phase reset or malformed-state mutation"); +} + +} // namespace + +int main() { + test_physical_frequency_and_channels(); + test_linear_mix_headroom(); + test_substeps_and_preemption(); + test_multiple_usb_updates_and_watchdogs(); + test_phase_continuity_and_partitioning(); + test_feedback_returns_to_live_host(); + test_late_commands_and_clock_rollover(); + test_stall_and_overflow(); + test_duplicate_order_and_invalid_frames(); + if (failures) { + std::cerr << failures << " synthesis scenarios failed\n"; + return 1; + } + std::cout << "Switch HD rumble synthesis scenarios passed\n"; + return 0; +} diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 566b0cc..9e882b6 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -2121,17 +2121,19 @@ def haptics_response( state: int = 0, *, run_id: int = 0, slot: int = 0xFF, last_error: int = 0, sent_packets: int = 0, first_tone_due_us: int = 0, first_tone_sent_us: int = 0, - elapsed_us: int = 0, connection_generation: int = 9, + elapsed_us: int = 0, connection_generation: int = 9, mode: int = 0, + host_updates: int = 0, dropped_updates: int = 0, ) -> bytes: return make_response( config_manager.OP_HAPTICS_EXPERIMENT, struct.pack( - "<17I4B", run_id, connection_generation, 100, 105, + "<17I8B2I", run_id, connection_generation, 100, 105, sent_packets, 2, 3, 109, 4, 123, 22000, 11001, 9876, first_tone_due_us, first_tone_sent_us, - 0x76543210, elapsed_us, state, slot, last_error, 0, + 0x76543210, elapsed_us, state, slot, last_error, 0, mode, 0, 0, 0, + host_updates, dropped_updates, ), - schema=2, generation=run_id, + schema=3, generation=run_id, ) @@ -2142,8 +2144,6 @@ class HapticsDevice(FakeDevice): ) -> None: super().__init__() self.haptics_responses = responses - self.haptics_reads = 0 - self.haptics_reads_at_out: list[int] = [] self.transport_response = transport_response def ctrl_transfer( @@ -2167,7 +2167,6 @@ class HapticsDevice(FakeDevice): assert index == config_manager.REQUEST_INDEX self.requests.append(request) if bm_request_type == 0xC0: - self.haptics_reads += 1 response = self.haptics_responses[0] if len(self.haptics_responses) > 1: self.haptics_responses.pop(0) @@ -2185,7 +2184,6 @@ class HapticsDevice(FakeDevice): ) assert crc == zlib.crc32(payload) & 0xFFFFFFFF self.out_requests.append((request, payload, encoded)) - self.haptics_reads_at_out.append(self.haptics_reads) return len(encoded) @@ -2206,7 +2204,8 @@ def test_haptics_schema_timing_and_wraparound() -> None: haptics_response( 2, run_id=17, slot=2, sent_packets=101, first_tone_due_us=0xFFFFFFF0, first_tone_sent_us=0x30, - elapsed_us=1100000, + elapsed_us=1100000, mode=1, + host_updates=0x89ABCDEF, dropped_updates=0x12345678, ), ]) snapshot = config_manager.read_haptics_experiment(device) @@ -2224,35 +2223,54 @@ def test_haptics_schema_timing_and_wraparound() -> None: assert snapshot.last_sent_us == 0x76543210 assert snapshot.elapsed_us == 1100000 assert snapshot.to_json_object()["first_tone_submission_delay_us"] == 64 + assert snapshot.mode_name == "gameplay" + assert snapshot.host_updates == 0x89ABCDEF + assert snapshot.dropped_updates == 0x12345678 @pytest.mark.parametrize( ("mutation", "message"), [ ("schema", "unsupported haptics experiment schema"), - ("size", "payload size"), + ("short", "payload size"), + ("long", "payload size"), + ("old_size", "payload size"), + ("mode_only_size", "payload size"), ("state", "state"), ("slot", "slot"), ("active_without_slot", "slot"), - ("reserved", "reserved"), + ("mode", "mode"), + ("reserved71", "reserved"), + ("reserved73", "reserved"), + ("reserved74", "reserved"), + ("reserved75", "reserved"), ("flags", "reserved"), ], ) def test_haptics_rejects_malformed_diagnostics(mutation: str, message: str) -> None: payload = bytearray(haptics_response(2, slot=0)[20:]) - schema, flags = 2, 0 + schema, flags = 3, 0 if mutation == "schema": - schema = 1 - elif mutation == "size": + schema = 2 + del payload[72:] + elif mutation == "short": payload.pop() + elif mutation == "long": + payload.append(0) + elif mutation == "old_size": + del payload[72:] + elif mutation == "mode_only_size": + del payload[76:] elif mutation == "state": payload[68] = 8 elif mutation == "slot": payload[69] = 4 elif mutation == "active_without_slot": payload[69] = 0xFF - elif mutation == "reserved": - payload[71] = 1 + elif mutation == "mode": + payload[72] = 2 + elif mutation.startswith("reserved"): + payload[int(mutation.removeprefix("reserved"))] = 1 else: flags = 1 response = make_response( @@ -2272,9 +2290,12 @@ def test_haptics_disabled_firmware_is_readable_but_cannot_start( status = json.loads(captured.out) assert status["state_name"] == "unsupported" assert status["firmware_supported"] is False + assert status["mode"] == 0 assert "SWITCH_PICO_HAPTICS_EXPERIMENT=ON" in captured.err assert config_manager.main(["haptics-experiment", "start"]) == 1 assert "unsupported" in capsys.readouterr().err + assert config_manager.main(["haptics-experiment", "gameplay"]) == 1 + assert "unsupported" in capsys.readouterr().err assert device.out_requests == [] @@ -2296,26 +2317,38 @@ def test_haptics_old_firmware_stall_is_actionable_without_hiding_disconnect( assert raised.value is disconnected -def test_haptics_start_waits_for_firmware_not_usb_ack( +@pytest.mark.parametrize(("action", "mode"), [("start", 0), ("gameplay", 1)]) +def test_haptics_arming_waits_for_firmware_not_usb_ack( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], - haptics_clock: list[float], + haptics_clock: list[float], action: str, mode: int, ) -> None: device = HapticsDevice([ haptics_response(3, run_id=40, slot=0), - haptics_response(1, run_id=41, slot=0), - haptics_response(2, run_id=41, slot=0, sent_packets=1), + haptics_response(1, run_id=41, slot=0, mode=mode), + haptics_response(2, run_id=41, slot=0, sent_packets=1, mode=mode), ]) monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) - assert config_manager.main(["haptics-experiment", "start"]) == 0 + assert config_manager.main(["haptics-experiment", action, "--json"]) == 0 captured = capsys.readouterr() - assert device.haptics_reads_at_out == [1] - assert device.out_requests[0][1] == b"\x01\x00" - assert device.haptics_reads == 3 + row = json.loads(captured.out) + assert row["state_name"] == "running" + assert (row["run_id"], row["slot"], row["mode"]) == (41, 0, mode) + assert device.out_requests[0][1] == bytes((1 if action == "start" else 2, 0)) assert haptics_clock[0] >= 0.1 - assert "pending firmware confirmation" in captured.out - assert "Haptics experiment: running" in captured.out - assert "not physical actuator" in captured.out - assert "1.024 s" in captured.out and "6.144 s" in captured.out + if mode == 1: + assert "pattern" not in row + assert row["gameplay"] == { + "sample_rate_hz": 3000, + "stereo_frames_per_packet": 64, + "lookback_us": pytest.approx(21333.333333333), + "command_window_us": 8000, + "watchdog_us": 50000, + "host_gain": 1.5, + } + assert row["first_tone_submission_delay_us"] is None + else: + assert row["pattern"]["duration_us"] == 6144000 + assert "gameplay" not in row def test_haptics_start_watch_captures_correlated_measurement_series( @@ -2360,21 +2393,24 @@ def test_haptics_start_watch_captures_correlated_measurement_series( (7, 6, "wait for prior output to drain"), ], ) -def test_haptics_start_reports_asynchronous_rejection( +def test_haptics_gameplay_reports_asynchronous_rejection( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], haptics_clock: list[float], state: int, error: int, description: str, ) -> None: device = HapticsDevice([ haptics_response(), - haptics_response(1, run_id=1, slot=0), - haptics_response(state, run_id=1, slot=0, last_error=error), + haptics_response(1, run_id=1, slot=0, mode=1), + haptics_response(state, run_id=1, slot=0, last_error=error, mode=1), ]) monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) - assert config_manager.main(["haptics-experiment", "start"]) == 1 + assert config_manager.main(["haptics-experiment", "gameplay", "--json"]) == 1 captured = capsys.readouterr() assert description in captured.err assert f"last_error={error}" in captured.err - assert "Haptics experiment: running" not in captured.out + row = json.loads(captured.out) + assert (row["run_id"], row["slot"], row["mode"]) == (1, 0, 1) + assert row["state"] == state and row["last_error"] == error + assert "pattern" not in row def test_haptics_status_watch_reports_connection_loss_after_running( @@ -2432,32 +2468,37 @@ def test_haptics_watch_is_bounded_and_rejects_run_replacement( def test_haptics_busy_start_and_wrong_slot_stop_do_not_mutate_active_run( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - device = HapticsDevice([haptics_response(2, run_id=1, slot=3)]) + device = HapticsDevice([haptics_response(2, run_id=1, slot=3, mode=1)]) monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) assert config_manager.main(["haptics-experiment", "start"]) == 1 assert "already running" in capsys.readouterr().err + assert config_manager.main(["haptics-experiment", "gameplay"]) == 1 + assert "already running" in capsys.readouterr().err assert config_manager.main(["haptics-experiment", "stop"]) == 1 assert "not requested slot 0" in capsys.readouterr().err assert device.out_requests == [] +@pytest.mark.parametrize("mode", [0, 1]) def test_haptics_stop_waits_for_service_completion( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], - haptics_clock: list[float], + haptics_clock: list[float], mode: int, ) -> None: device = HapticsDevice([ - haptics_response(2, run_id=1, slot=2), - haptics_response(2, run_id=1, slot=2), - haptics_response(4, run_id=1, slot=2), + haptics_response(2, run_id=1, slot=2, mode=mode), + haptics_response(2, run_id=1, slot=2, mode=mode), + haptics_response(4, run_id=1, slot=2, mode=mode), ]) monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) assert config_manager.main([ "haptics-experiment", "stop", "--slot", "2", "--json", ]) == 0 captured = capsys.readouterr() - assert json.loads(captured.out)["state_name"] == "stopped" + row = json.loads(captured.out) + assert row["state_name"] == "stopped" + assert (row["run_id"], row["slot"], row["mode"]) == (1, 2, mode) assert device.out_requests[0][1] == b"\x00\x02" - assert device.haptics_reads == 3 + assert haptics_clock[0] >= 0.1 def test_haptics_invalid_slot_is_rejected_before_discovery( @@ -2538,14 +2579,15 @@ def test_haptics_profile_decodes_exact_wire_order_and_correlates_live_run( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: device = HapticsDevice([ - haptics_response(2, run_id=17, slot=0, sent_packets=10), - haptics_response(2, run_id=17, slot=0, sent_packets=11), + haptics_response(2, run_id=17, slot=0, sent_packets=10, mode=1), + haptics_response(2, run_id=17, slot=0, sent_packets=11, mode=1), ], transport_response=transport_response(active=1)) monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 0 row = json.loads(capsys.readouterr().out) assert row["run_id"] == 17 and row["connection_generation"] == 9 assert row["sent_packets"] == 11 + assert row["mode_name"] == "gameplay" and "pattern" not in row transport = row["transport"] transport.pop("evidence_note") assert transport == { @@ -2639,13 +2681,14 @@ def test_haptics_profile_accepts_retained_failed_run_with_invalid_handle( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: device = HapticsDevice( - [haptics_response(5, run_id=17, slot=0, last_error=3)], + [haptics_response(5, run_id=17, slot=0, last_error=3, mode=1)], transport_response=transport_response(connection_handle=0xFFFF), ) monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 0 row = json.loads(capsys.readouterr().out) assert row["state_name"] == "disconnected" and row["last_error"] == 3 + assert row["mode"] == 1 and "pattern" not in row assert row["transport"]["connection_handle"] == 0xFFFF assert row["transport"]["active"] is False @@ -2689,24 +2732,29 @@ def test_haptics_profile_does_not_hide_usb_disconnect_as_unsupported() -> None: @pytest.mark.parametrize( - ("after_run", "after_generation", "probe_run", "probe_generation"), + ("after_run", "after_generation", "probe_run", "probe_generation", + "after_slot", "after_mode"), [ - (17, 9, 16, 9), # A stale probe must not attach to the current run. - (18, 9, 17, 9), # A new run starts after reading the probe. - (18, 9, 18, 9), # A new run starts before reading the probe. - (17, 10, 17, 9), # A connection changes after reading the probe. - (17, 10, 17, 10), # A connection changes before reading the probe. - (17, 9, 17, 8), # Matching run IDs cannot mask stale connection data. + (17, 9, 16, 9, 0, 1), # A stale probe cannot attach to the current run. + (18, 9, 17, 9, 0, 1), # A new run starts after reading the probe. + (18, 9, 18, 9, 0, 1), # A new run starts before reading the probe. + (17, 10, 17, 9, 0, 1), # A connection changes after reading the probe. + (17, 10, 17, 10, 0, 1), # A connection changes before reading the probe. + (17, 9, 17, 8, 0, 1), # A matching run ID cannot mask stale connection data. + (17, 9, 17, 9, 1, 1), # A different slot must not inherit the profile. + (17, 9, 17, 9, 0, 0), # A fixture cannot impersonate gameplay. ], ) def test_haptics_profile_never_publishes_cross_run_metrics( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], after_run: int, after_generation: int, probe_run: int, probe_generation: int, + after_slot: int, after_mode: int, ) -> None: device = HapticsDevice([ - haptics_response(2, run_id=17, slot=0), + haptics_response(2, run_id=17, slot=0, mode=1), haptics_response( - 2, run_id=after_run, slot=0, connection_generation=after_generation, + 2, run_id=after_run, slot=after_slot, connection_generation=after_generation, + mode=after_mode, ), ], transport_response=transport_response( run_id=probe_run, connection_generation=probe_generation, @@ -2716,3 +2764,79 @@ def test_haptics_profile_never_publishes_cross_run_metrics( captured = capsys.readouterr() assert captured.out == "" assert "cannot attribute measurements" in captured.err + + +def test_haptics_gameplay_watch_timeout_leaves_stream_armed( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice([ + haptics_response(3, run_id=8, slot=0), + haptics_response(1, run_id=9, slot=3, mode=1), + haptics_response( + 2, run_id=9, slot=3, mode=1, sent_packets=2, host_updates=4, + dropped_updates=1, + ), + ]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main([ + "--timeout", "0.2", "haptics-experiment", "gameplay", "--slot", "3", + "--watch", "--json", + ]) == 1 + captured = capsys.readouterr() + series = [json.loads(line) for line in captured.out.splitlines()] + assert [row["state_name"] for row in series] == ["pending", "running", "running"] + assert all((row["run_id"], row["slot"], row["mode"]) == (9, 3, 1) for row in series) + assert all("pattern" not in row for row in series) + assert all(row["first_tone_submission_delay_us"] is None for row in series) + assert series[-1]["host_updates"] == 4 and series[-1]["dropped_updates"] == 1 + assert haptics_clock[0] == pytest.approx(0.2) + assert "still armed" in captured.err and "stop --slot 3" in captured.err + assert [request[1] for request in device.out_requests] == [b"\x02\x03"] + + +@pytest.mark.parametrize( + ("action", "response_run", "response_slot", "response_mode"), + [ + ("gameplay", 42, 0, 1), + ("gameplay", 41, 1, 1), + ("gameplay", 41, 0, 0), + ("start", 41, 0, 1), + ("stop", 40, 0, 0), + ], +) +def test_haptics_confirmation_never_attributes_another_run_slot_or_mode( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + action: str, response_run: int, response_slot: int, response_mode: int, +) -> None: + stopping = action == "stop" + device = HapticsDevice([ + haptics_response(2 if stopping else 3, run_id=40, slot=0, mode=int(stopping)), + haptics_response( + 4 if stopping else 2, run_id=response_run, slot=response_slot, + mode=response_mode, + ), + ]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["haptics-experiment", action, "--json"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "run changed" in captured.err or "belongs to another" in captured.err + + +def test_haptics_gameplay_watch_rejects_fixture_with_same_run_and_slot( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + haptics_clock: list[float], +) -> None: + device = HapticsDevice([ + haptics_response(2, run_id=7, slot=0, mode=1), + haptics_response(3, run_id=7, slot=0, mode=0), + ]) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main([ + "haptics-experiment", "status", "--watch", "--json", + ]) == 1 + captured = capsys.readouterr() + row = json.loads(captured.out) + assert row["state_name"] == "running" and row["mode"] == 1 + assert "cannot attribute measurements" in captured.err diff --git a/tests/test_haptics_experiment_native.py b/tests/test_haptics_experiment_native.py index 9bc37ec..1edb4c5 100644 --- a/tests/test_haptics_experiment_native.py +++ b/tests/test_haptics_experiment_native.py @@ -28,6 +28,8 @@ def test_haptics_experiment_native(tmp_path: Path, ram: int) -> None: f"-I{root / 'src' / 'firmware'}", str(root / "tests" / "haptics_experiment_test.cpp"), str(root / "src" / "firmware" / "input" / "haptics_experiment.cpp"), + str(root / "src" / "firmware" / "input" / "switch_hd_rumble_synth.cpp"), + str(root / "src" / "firmware" / "usb" / "switch" / "switch_haptics.cpp"), "-o", str(executable), ], diff --git a/tests/test_switch_hd_rumble_synth_native.py b/tests/test_switch_hd_rumble_synth_native.py new file mode 100644 index 0000000..9ccc070 --- /dev/null +++ b/tests/test_switch_hd_rumble_synth_native.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_switch_hd_rumble_synth_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + + executable = tmp_path / "switch_hd_rumble_synth_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root / 'src' / 'firmware'}", + str(root / "src" / "firmware" / "input" / "switch_hd_rumble_synth.cpp"), + str(root / "tests" / "switch_hd_rumble_synth_test.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root, timeout=15) diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index c06f957..e76c783 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -728,18 +728,21 @@ std::vector read_haptics_payload() { require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request), "experiment diagnostics IN was rejected"); - require(control_payload.size() == kResponseHeaderSize + 72 && + require(control_payload.size() == kResponseHeaderSize + 84 && control_payload[5] == 0x40 && control_payload[6] == static_cast(Status::kOk) && control_payload[7] == 0 && - read_u16(control_payload, 8) == 72 && - read_u16(control_payload, 10) == 2, - "experiment schema-2 envelope is invalid"); + read_u16(control_payload, 8) == 84 && + read_u16(control_payload, 10) == 3, + "experiment schema-3 envelope is invalid"); std::vector payload( control_payload.begin() + kResponseHeaderSize, control_payload.end()); require(read_u32(control_payload, 16) == configuration_crc32(payload.data(), payload.size()), "experiment response CRC is invalid"); + require(payload[71] == 0 && payload[73] == 0 && + payload[74] == 0 && payload[75] == 0, + "experiment reserved payload bytes must remain zero"); return payload; } @@ -783,13 +786,13 @@ void test_haptics_experiment_requests() { 0, CONTROL_STAGE_SETUP, &request), "malformed experiment control size was accepted"); } - std::vector expected(72, 0); + std::vector expected(84, 0); expected[69] = 0xff; #ifndef SWITCH_PICO_HAPTICS_EXPERIMENT expected[68] = 6; require(read_haptics_payload() == expected, "disabled firmware must expose only the unsupported snapshot"); - for (uint8_t action : {0, 1}) { + for (uint8_t action : {0, 1, 2}) { next_out_payload = make_request(Operation::kHapticsExperiment, {action, 0}); tusb_control_request_t request = setup_request( Operation::kHapticsExperiment, TUSB_DIR_OUT, @@ -799,9 +802,7 @@ void test_haptics_experiment_requests() { "disabled firmware accepted experiment control"); } #else - require(read_haptics_payload() == expected, - "enabled firmware must initially be idle without a selected slot"); - perform_haptics_out(2, 0, false); + perform_haptics_out(3, 0, false); perform_haptics_out(1, 4, false); perform_haptics_out(0, 0xff, false); require(haptics_request_count == 0, @@ -820,15 +821,15 @@ void test_haptics_experiment_requests() { require(haptics_request_count == 0, "bad CRC control reached the experiment service"); - perform_haptics_out(1, 2); + perform_haptics_out(2, 2); auto payload = read_haptics_payload(); - require(payload[68] == 1 && payload[69] == 2 && - read_u32(payload, 0) == 1 && read_u32(payload, 16) == 0 && - haptics_request_count == 1, + require(payload[68] == 1 && payload[69] == 2 && payload[72] == 1 && + read_u32(payload, 0) == 1 && read_u32(payload, 16) == 0, "USB acceptance must remain pending until the service starts"); perform_haptics_out(1, 1, false); + perform_haptics_out(2, 1, false); payload = read_haptics_payload(); - require(payload[68] == 1 && payload[69] == 2 && + require(payload[68] == 1 && payload[69] == 2 && payload[72] == 1 && read_u32(payload, 0) == 1, "busy start overwrote the accepted run"); @@ -836,7 +837,7 @@ void test_haptics_experiment_requests() { current_haptics = { 1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4, 123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210, - 1100000, HapticsExperimentState::kRunning, 2, 0, + 1100000, HapticsExperimentState::kRunning, 2, 0, 1, 0x89abcdef, 0x12345678, }; const uint32_t fields[] = { 1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4, @@ -847,15 +848,19 @@ void test_haptics_experiment_requests() { } expected[68] = 2; expected[69] = 2; + expected[72] = 1; + write_u32(&expected, 76, 0x89abcdef); + write_u32(&expected, 80, 0x12345678); require(read_haptics_payload() == expected, - "schema-2 timing fields are not in little-endian wire order"); + "schema-3 timing and gameplay mode fields are not in wire order"); perform_haptics_out(0, 2); payload = read_haptics_payload(); - require(payload[68] == 2 && read_u32(payload, 0) == 1, + require(payload[68] == 2 && payload[72] == 1 && read_u32(payload, 0) == 1, "USB stop ACK must not fabricate terminal completion"); current_haptics.state = HapticsExperimentState::kStopped; - require(read_haptics_payload()[68] == 4, + payload = read_haptics_payload(); + require(payload[68] == 4 && payload[72] == 1, "service stop transition was not observable"); next_out_payload = make_request(Operation::kHapticsExperiment, {1, 3}); @@ -879,7 +884,7 @@ void test_haptics_experiment_requests() { current_haptics.last_error = 3; payload = read_haptics_payload(); require(payload[68] == 5 && payload[69] == 3 && payload[70] == 3 && - read_u32(payload, 0) == 2, + read_u32(payload, 0) == 2 && payload[72] == 0, "asynchronous connection failure lost request correlation"); #endif } @@ -1163,17 +1168,18 @@ void bluepad32_input_backend_diagnostics( #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT bool haptics_experiment_request(uint8_t action, uint8_t slot) { ++haptics_request_count; - if (action == 1 && + if ((action == 1 || action == 2) && (current_haptics.state == HapticsExperimentState::kPending || current_haptics.state == HapticsExperimentState::kRunning)) { return false; } - if (action == 1) { + if (action == 1 || action == 2) { const uint32_t run_id = current_haptics.run_id + 1; current_haptics = {}; current_haptics.run_id = run_id; current_haptics.slot = slot; current_haptics.state = HapticsExperimentState::kPending; + current_haptics.mode = action == 2 ? 1 : 0; } return true; }