diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index 5cdb41b..dc5bb52 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -819,26 +819,40 @@ from 197 host requests without queue drops; the user confirmed both sides. Normal GATT-client busy responses now retain queued output instead of disconnecting the controller. -Two or more Switch 2 BLE links now select 30 ms connection intervals even -before Classic connects; a single Switch 2 link retains 7.5 ms. Paired and -Individual Joy-Cons use the same physical-link policy. This trades Switch 2 -delivery latency for Classic paging and streaming airtime without changing -HD encoding or bonds. The earlier policy waited for a Classic link and passed -one physical power-off/reconnect check, but subsequent use exposed intermittent -DualSense connection failures with two Joy-Cons already active. Regression -coverage now requires airtime before Classic admission and after its departure. -The user confirmed reconnects and actuator output/clean stops at 30 ms. -A same-workload 15/30 ms comparison at 300 MHz rejected 15 ms for native -haptics: it timed out around four seconds, with 111 PCM sends, 38 skips and -one send failure. At 30 ms the full 30-second run retained all three links -and native streaming: 1,384 PCM sends, 47 skips, no send failures or host-update/ -Switch 2 ingress drops, and seven Joy-Con output-stage drops. The isolated -DualSense fixture delivered all 288 packets without skips. Release images keep -30 ms; long-duration reliability remains unqualified. All 349 tests and four -affected firmware builds passed for the preconnection policy. -A prior 30-second production mixed-rumble run kept native DualSense streaming, -with no send failures or ingress drops, 14 Joy-Con output-stage discards and -nine PCM skips. It is not lossless. +Joy-Con 2 links now retain 7.5 ms BLE intervals in Paired and Individual modes, +including when a Classic controller connects. This intentionally removes their +automatic 30 ms slowdown while preserving the idle-neutral sending fix. Other +Switch 2 models retain 30 ms when multiple physical Switch 2 links are present, +otherwise 7.5 ms; unrelated BLE controllers are untouched. Lifecycle coverage +checks modes, Classic arrival/departure, handle reuse, slow-interval correction, +and the remaining Switch 2 Pro coexistence/retry policy. + +Idle-neutral output ends after three successful neutral submissions/completions. +Late non-neutral completion re-arms stops even after a logical reset; failed or +pending writes do not consume the budget. Native subframes, expiry and normal +active-output cadence remain unchanged. The direct lab fixtures and unqualified +shared Classic/GATT admission experiment are excluded from this checkpoint. + +Measured limits remain important: +- At 30 ms, idle suppression reduced outgoing HCI ACL writes from 974 to 469 per + ten seconds with the same 469 DualSense PCM sends. Total HCI transactions did + not decrease because receive-credit traffic increased. +- The optimized 30-second mixed run sent 1,431 PCM packets without skips/send + failures, but had two Switch 2 ingress drops and nine output-stage drops. + The user later clarified that 30 ms Joy-Con rumble was weak; activity and + clean stops were not full quality approval. DualSense was weak/inconsistent. +- A single right-Joy-Con direct-packet reference confirmed clear tone and good + feel at a nominal 7.5 ms packet cadence: 267 packets per two-second burst, + 7.504 ms average submission spacing, same 320 Hz/amplitude as the weak 20 ms + one-/three-sample trials. This is distinct from the BLE link interval. +- Adding DualSense PCM to the fast fixture timed out in about 0.66 seconds, + with one or both Joy-Cons vibrating (both links connected in either case). + Both runs sent only 11 PCM packets; the two-vibrating run also had Joy-Con + submission gaps up to 32 ms. Explicit stop writes completed after abort. + +The requested fast-link default is not a claim of reliable mixed native PCM, +lossless transport or qualified endurance. DualSense buffer/cadence work remains +separate; no hidden gain or compatibility fallback change is included. The native-HD checkpoint passed 289 tests and all five firmware builds; its 40 pre-existing profiles and metadata survived. Its inventory then contained diff --git a/README.md b/README.md index d35e4fd..fbb5b4e 100644 --- a/README.md +++ b/README.md @@ -481,43 +481,59 @@ results do **not** establish lossless arbitrary workloads or perceptual equivalence. All 40 profiles, names, active selections and adapter configuration were preserved during this upgrade. -**Automatic mixed-controller scheduling:** whenever at least two physical -Switch 2 BLE links are present, the firmware requests a 30 ms connection -interval for those links, even before a Classic Bluetooth controller connects. -A single Switch 2 link uses 7.5 ms. A merged Joy-Con pair counts as two links; -Individual mode uses the same physical-link policy. Unrelated BLE controllers -are not retimed; bonds, profiles, HD encoding and the DualSense timeout stay -unchanged. The policy reconciles asynchronous negotiation and topology changes. -Waiting for a Classic connection before reserving its airtime left a reconnect -timing hole: DualSense could fail to connect with both Joy-Cons already active. -Keeping 30 ms after Classic disconnect also leaves airtime for its next attempt. +**Joy-Con 2 connection timing:** both halves now request **7.5 ms BLE connection +intervals**, in Paired and Individual modes, including while Classic controllers +are connected. This deliberately replaces their earlier automatic 30 ms slowdown +to favor Joy-Con responsiveness and rumble quality. Other Switch 2 models retain +the existing coexistence policy: 30 ms when at least two physical Switch 2 links +are present, otherwise 7.5 ms. Unrelated BLE controllers are not retimed. Bonds, +profiles, HD encoding and the DualSense timeout are unchanged. Negotiated +intervals are reconciled after asynchronous updates and topology changes. -This trades additional Switch 2 input, gyro and rumble delivery latency for -Classic radio time. Multiple reports can travel per connection event, so -30 ms does not impose a 33-report/s limit. Two Joy-Con 2 links at 7.5 ms caused -native DualSense PCM timeouts in hardware testing. With the automatic policy, -both negotiated 30 ms and DualSense started without manual re-arming. -A production-firmware 30-second simultaneous rumble run retained all three -connections and native streaming: zero ingress drops or send failures, -14 Joy-Con output-stage discards and nine DualSense PCM skips. This is not -a lossless or long-duration qualification. Lifecycle regressions cover airtime -reservation before Classic arrival in Paired and Individual modes, Classic -departure/reconnect, a missing half, handle reuse, late negotiation completion, -clock wrap and transient request rejection. -The earlier policy passed one physical DualSense power-off/reconnect check, -but subsequent use exposed intermittent connection failures. The preconnection -airtime regression fails under that policy and passes with the current one. -The user confirmed reconnects and correct actuator output/stops with the -preconnection 30 ms policy. A 15 ms trial also reconnected, but its native -DualSense stream timed out even while sending silence. Under the same -125 Hz-per-slot held-effect/silence workload at 300 MHz, 15 ms failed around -four seconds (111 PCM packets sent, 38 skipped, one send failure); 30 ms -completed 30 seconds (1,384 sent, 47 skipped, no send failures). All three -controllers remained connected. The 30 ms run had zero host-update or Switch 2 -ingress drops and seven Joy-Con output-stage drops; its separate DualSense -fixture delivered all 288 packets without skips. This supports keeping 30 ms, -not a lossless or long-duration claim. The 15 ms trial is not the release default. -The preconnection policy passed all 349 tests and four affected firmware builds. +The BLE connection interval is not the rumble packet cadence: multiple packets +can travel per connection event. The normal active-output algorithm is unchanged +by this checkpoint; the direct-packet lab fixtures are not release features. +Lifecycle coverage includes Paired/Individual mode changes, Classic arrival and +departure, handle reuse, correcting a slow negotiated Joy-Con interval, unrelated +BLE isolation, and the retained Switch 2 Pro interval/retry policy. + +**Idle Switch 2 rumble traffic:** the parser sends three successful neutral +writes, then suppresses further idle output. Any successful non-neutral packet +re-arms that stop budget, including a late completion from an older logical +epoch. Failed or pending ATT writes do not count as completed stops. Repeated +host stops still discard queued history without restarting settled idle traffic. +Active-effect cadence, native subframes and watchdogs are unchanged; the parser +timer remains available for control and expiry work. + +**Qualification and known mixed-radio limitation:** this default does not claim +reliable native DualSense PCM alongside two fast Joy-Con links. + +- Earlier 15/30 ms trials favored 30 ms for transport continuity: at 15 ms the + same 125 Hz-per-slot workload timed out around four seconds; at 30 ms it + completed 30 seconds with 1,384 PCM sends, 47 skips and no send failures. + All three links stayed connected, but the user later clarified that Joy-Con + rumble felt weak at 30 ms and DualSense rumble was also weak/inconsistent. +- Idle suppression was measured at 30 ms: outgoing HCI ACL writes fell from 974 + to 469 per ten-second idle sample, while both samples sent 469 DualSense PCM + packets without skips. Total HCI writes did not fall because receive-credit + traffic increased. A subsequent 30-second workload sent 1,431 PCM packets + with zero skips/failures, two Switch 2 ingress drops and nine output-stage + drops. This was a transport improvement, not full perceptual qualification. +- In the isolated right-Joy-Con fixture, one- and three-sample packets at a + 20 ms packet cadence both felt weak. One-sample packets at a nominal 7.5 ms + cadence, with the same 320 Hz frequency and amplitude, produced user-confirmed + clear tone and good feel. Each two-second burst sent 267 packets with a + 7.504 ms average submission gap, not a measured on-air or actuator interval. +- Adding native DualSense PCM to that fast fixture failed in about 0.66 seconds, + both with one Joy-Con vibrating and with both vibrating. Both Joy-Cons remained + connected in each test. DualSense sent 11 PCM packets before timing out; with + both vibrating, Joy-Con submission gaps reached 32 ms. The fixture aborted and + completed explicit stop writes. + +The shared Classic/GATT scheduler trial was not qualified and is excluded from +this checkpoint. Temporary packet fixtures are also excluded. Native DualSense +buffer/cadence qualification remains separate; no gain increase or fallback mode +is silently applied to conceal transport loss. ### Rumble per controller @@ -982,15 +998,15 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file: | Resource | Used or reserved | Device capacity | |---|---:|---:| -| Executable flash image | 833,320 bytes | 4 MiB | +| Executable flash image | 833,672 bytes | 4 MiB | | Indexed profile arenas | 256 KiB | 4 MiB flash | | Adapter configuration | 8 KiB | 4 MiB flash | | BTstack bonds and Switch 2 application authorizations | 8 KiB | 4 MiB flash | | RP2350 terminal sector | 4 KiB | 4 MiB flash | -| Allocated/reserved SRAM, including heap and stacks | 151,804 bytes | 520 KiB | +| Allocated/reserved SRAM, including heap and stacks | 151,820 bytes | 520 KiB | -The executable plus persistent reservations consume 1,115,944 bytes of flash, -leaving 3,078,360 bytes. Allocated SRAM sections leave 380,676 bytes of link-time +The executable plus persistent reservations consume 1,116,296 bytes of flash, +leaving 3,078,008 bytes. Allocated SRAM sections leave 380,660 bytes of link-time headroom; this is not a runtime heap high-water measurement. Core 0 has a 4 KiB stack, and Core 1 uses a dedicated 16 KiB stack in main SRAM for nested catalog migration/compaction rather than overflowing its 4 KiB scratch bank. diff --git a/bluepad32_config/parser/uni_hid_parser_switch2.c b/bluepad32_config/parser/uni_hid_parser_switch2.c index 11f5aa1..36c3c8a 100644 --- a/bluepad32_config/parser/uni_hid_parser_switch2.c +++ b/bluepad32_config/parser/uni_hid_parser_switch2.c @@ -22,6 +22,7 @@ #define SW2_TIMEOUT_MS 2000 #define SW2_OUTPUT_INTERVAL_MS 13 +#define SW2_NEUTRAL_WRITE_BUDGET 3 #define SW2_HAPTICS_CAPACITY 16 #define SW2_REPORT_SIZE 63 #define SW2_ACK 0x78 @@ -112,6 +113,8 @@ typedef struct { uint32_t pending_serial, pending_epoch, pending_revision; uint32_t playback_until; uint8_t pending_guard_ms; + uint8_t neutral_writes; + bool pending_neutral; bool pending_host, pending_barrier, barrier_pending, output_urgent; bool feedback_active, playback_guard; uint32_t sensor_start, sensor_host_start, sensor_last; @@ -1147,6 +1150,12 @@ void uni_hid_parser_switch2_reset_haptics(uni_hid_device_t* d) { static void sw2_rumble_complete(sw2_instance_t* ins) { uint32_t now = btstack_run_loop_get_time_ms(); ++ins->rumble_id; // Only a successful write consumes the physical sequence. + // Track transport success even for an old logical epoch: a late active + // packet invalidates prior silence and must be followed by fresh stops. + if (!ins->pending_neutral) + ins->neutral_writes = 0; + else if (ins->neutral_writes < SW2_NEUTRAL_WRITE_BUDGET) + ++ins->neutral_writes; ins->playback_until = now + ins->pending_guard_ms; ins->playback_guard = true; sw2_update_haptics(ins, now); @@ -1204,6 +1213,16 @@ static bool sw2_send_rumble(sw2_instance_t* ins, uint32_t now) { break; } } + ins->pending_neutral = sw2_physical_stop(ins, &frame); + if (ins->pending_neutral && !ins->pending_host && + ins->neutral_writes == SW2_NEUTRAL_WRITE_BUDGET) { + // Three successful neutral writes settle idle output. Repeated host + // stops still discard queued history, but need no further radio work. + // Never suppress a queued native sequence, even if its endpoint is zero. + ins->barrier_pending = false; + ins->output_urgent = false; + return false; + } ins->pending_epoch = ins->haptics_epoch; ins->pending_revision = ins->output_revision; ins->pending_barrier = ins->barrier_pending; diff --git a/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index a1334e1..be38a70 100755 Binary files a/firmware/switch-pico-adapter-feasibility.elf and b/firmware/switch-pico-adapter-feasibility.elf differ diff --git a/firmware/switch-pico-adapter-feasibility.uf2 b/firmware/switch-pico-adapter-feasibility.uf2 index c6c8e9c..ded9a30 100644 Binary files a/firmware/switch-pico-adapter-feasibility.uf2 and b/firmware/switch-pico-adapter-feasibility.uf2 differ diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index 09dee34..1798330 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index dbc2d53..97f860e 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index bbb1893..6a0de7d 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -48,7 +48,7 @@ constexpr uint32_t kDefaultPairingWindowDurationMs = constexpr uint32_t kPairingResetFeedbackDurationMs = 2000; // Bluetooth Classic units are 0.625 ms: 0x1900 = 4 seconds. constexpr uint16_t kClassicLinkSupervisionTimeout = 0x1900; -// LE units are 1.25 ms. Reserve airtime for Classic paging and native PCM. +// LE units are 1.25 ms. Joy-Cons retain the fast interval even with Classic. constexpr uint16_t kSwitch2FastInterval = 6; constexpr uint16_t kSwitch2MixedInterval = 24; constexpr uint32_t kSwitch2IntervalSettleMs = 1000; @@ -591,9 +591,9 @@ void stop_background_scan() { g_background_scan_active = false; } } -// Core 1 only. Count physical links, not logical players: a merged Joy-Con -// pair still consumes two LE connections. Reserve Classic airtime before an -// incoming connection exists; waiting for its ACL handle is too late for paging. +// Core 1 only. Count physical links, not logical players. Joy-Cons always keep +// their fast default; retain preconnection coexistence timing for other Switch 2 +// models when multiple physical Switch 2 links share the radio. void apply_radio_connection_policy() { unsigned switch2_links = 0; uni_hid_device_t* ready[kSlotCount]{}; @@ -612,9 +612,6 @@ void apply_radio_connection_policy() { } } } - const uint16_t desired = switch2_links >= 2 - ? kSwitch2MixedInterval - : kSwitch2FastInterval; const uint32_t now_ms = btstack_run_loop_get_time_ms(); for (uint8_t index = 0; index < kSlotCount; ++index) { auto& request = g_switch2_interval_requests[index]; @@ -622,6 +619,9 @@ void apply_radio_connection_policy() { request = {}; continue; } + const uint16_t desired = switch2_links >= 2 && joycon_side(ready[index]) == 0 + ? kSwitch2MixedInterval + : kSwitch2FastInterval; const auto handle = ready[index]->conn.handle; if (request.handle != handle) request = {}; const uint16_t actual = gap_le_connection_interval(handle); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index 070b7ab..2efe776 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -1385,10 +1385,9 @@ void test_switch2_mode_roundtrip(bool right_first) { slot_snapshot(2).connection_generation == classic_before.connection_generation && slot_snapshot(2).state.button_north && classic.rumble_calls == classic_calls && classic.last_low == 71 && - negotiated_intervals[left.conn.handle] == 24 && - negotiated_intervals[right.conn.handle] == 24 && - interval_requests[left.conn.handle] == 1 && interval_requests[right.conn.handle] == 1, - "mode roundtrip must preserve unrelated Classic state, haptics and physical mixed-link intervals"); + negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6, + "mode roundtrip must preserve unrelated Classic state, haptics and fast Joy-Con links"); ControllerProfile inactive{}; require(runtime_profile_storage.get(pair_identity, 2, &inactive) == ProfileStorageResult::kOk && inactive.weak_rumble_scale == 37, @@ -3046,25 +3045,25 @@ void test_switch2_radio_policy(bool individual) { "a single Switch2 link must retain fast scheduling"); ready_switch2(right); require(incoming_connections && - negotiated_intervals[left.conn.handle] == 24 && - negotiated_intervals[right.conn.handle] == 24, - "two Switch2 links must reserve Classic reconnect airtime before a Classic device exists"); + negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6, + "paired and individual Joy-Cons must retain fast links before Classic arrival"); platform_on_device_connected(&other_ble); require(platform_on_device_ready(&other_ble) == UNI_ERROR_SUCCESS, "unrelated BLE controller must join"); platform_on_device_connected(&classic); - require(negotiated_intervals[left.conn.handle] == 24 && - negotiated_intervals[right.conn.handle] == 24 && + require(negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6 && negotiated_intervals[other_ble.conn.handle] == 6, - "Classic setup must retain reconnect airtime without retiming unrelated BLE"); + "Classic setup must not slow Joy-Cons or retime unrelated BLE"); require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS, "Classic controller must complete setup alongside the pair"); const auto pair = slot_snapshot(0); platform_on_device_disconnected(&classic); - require(negotiated_intervals[left.conn.handle] == 24 && - negotiated_intervals[right.conn.handle] == 24 && + require(negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6 && slot_snapshot(0).connection_generation == pair.connection_generation, - "Classic departure must preserve reconnect airtime without rebinding the pair"); + "Classic departure must preserve fast Joy-Con links without rebinding the pair"); platform_on_device_connected(&classic); require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS, "Classic reconnect must complete"); @@ -3073,16 +3072,29 @@ void test_switch2_radio_policy(bool individual) { "a single surviving Switch2 link must return to fast scheduling even with Classic"); left = switch2_device(0, UNI_SW2_JOYCON_L_PID); ready_switch2(left); - require(negotiated_intervals[left.conn.handle] == 24 && + require(negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6, + "physical index and handle reuse must retain fast Joy-Con links"); + negotiated_intervals[left.conn.handle] = 24; + negotiated_intervals[right.conn.handle] = 24; + now_ms += 1000; + process_configuration_timer(&g_configuration_timer); + require(negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6, + "peer-negotiated slow Joy-Con links must reconcile to the fast default"); + platform_on_device_disconnected(&right); + right = switch2_device(1, UNI_SW2_PRO_PID); + ready_switch2(right); + require(negotiated_intervals[left.conn.handle] == 6 && negotiated_intervals[right.conn.handle] == 24, - "physical index and handle reuse must negotiate mixed intervals on the new connection"); + "Joy-Con preference must not remove the existing Switch2 Pro coexistence policy"); } void test_switch2_radio_settling() { start_backend(); auto classic = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR); - auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); - auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + auto left = switch2_device(0, UNI_SW2_PRO_PID); + auto right = switch2_device(1, UNI_SW2_PRO_PID); platform_on_device_connected(&classic); require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS, "Classic-first connection must become ready"); diff --git a/tests/switch2_parser_native_test.c b/tests/switch2_parser_native_test.c index f833e4c..764fffe 100644 --- a/tests/switch2_parser_native_test.c +++ b/tests/switch2_parser_native_test.c @@ -1149,6 +1149,79 @@ static void test_immediate_connection_failure_preserves_reconnect_discovery(void assert(!scan_running && !peers[0].used); // Explicit stop must not be overridden. } +static void test_idle_neutral_output_quiesces_and_resumes(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_L_PID, false); + advance(100); + assert(peer->rumbles == 3 && !rumble_active(peer)); + unsigned idle_writes = peer->rumbles; + advance(1300); + assert(peer->rumbles == idle_writes); + // Repeated host stops must not restart an already completed neutral burst. + for (unsigned i = 0; i < 20; ++i) { + assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 0, 0, 0, now_ms)); + advance(8); + } + assert(peer->rumbles == idle_writes); + // Quiet output must not delay a future effect or a finite watchdog stop. + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 20, 50, 40, 80); + advance(19); + assert(peer->rumbles == idle_writes); + advance(1); + assert(rumble_active(peer)); + advance(20); + assert(peer->rumbles > idle_writes + 1 && rumble_active(peer)); + advance(30); + assert(!rumble_active(peer)); + advance(100); + idle_writes = peer->rumbles; + advance(1300); + assert(peer->rumbles == idle_writes); + uni_switch2_haptics_frame_t frame = native_frame(3, 0, 8); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + advance(1); + assert_block(peer, 0, &frame.sides[0]); + advance(100); + assert_silent(peer, 0); + idle_writes = peer->rumbles; + advance(1300); + assert(peer->rumbles == idle_writes); +} + +static void test_neutral_budget_waits_for_success_and_stale_active_completion(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_R_PID, true); + next_write_error = BTSTACK_ACL_BUFFERS_FULL; + advance(1); + assert(peer->rumbles == 0); + advance(13); + assert(peer->rumbles == 1 && peer->query == QUERY_WRITE); + advance(100); + assert(peer->rumbles == 1); // Pending ATT writes cannot count as repeated stops. + query_done(peer, 0); + advance(13); + assert(peer->rumbles == 2); + query_done(peer, 0); + advance(13); + assert(peer->rumbles == 3); + query_done(peer, 0); + advance(100); + assert(peer->rumbles == 3 && peer->query == QUERY_NONE); + // Reset while an active write borrows its buffer. Its stale completion must + // invalidate the earlier idle state and require a fresh stop burst. + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 40, 80); + advance(1); + assert(rumble_active(peer) && peer->query == QUERY_WRITE); + uni_hid_parser_switch2_reset_haptics(&peer->device); + query_done(peer, 0); + for (unsigned i = 0; i < 3; ++i) { + advance(i ? 13 : 1); + assert(peer->rumbles == 5 + i && peer->query == QUERY_WRITE); + assert_silent(peer, 0); + query_done(peer, 0); + } + advance(100); + assert(peer->rumbles == 7 && peer->query == QUERY_NONE); +} + int main(void) { test_connected_callback_rejection(); test_advertisement_bounds_and_admission(); @@ -1171,6 +1244,8 @@ int main(void) { test_hold_coalescing_requires_identical_samples_and_side_masks(); test_expired_history_does_not_starve_fresh_sequences(); test_gatt_busy_retries_without_disconnect_or_sequence_loss(); + test_idle_neutral_output_quiesces_and_resumes(); + test_neutral_budget_waits_for_success_and_stale_active_completion(); reset(); puts("Switch2 protocol boundaries, setup failure, pairing, calibration, physical input, motion and rumble passed"); return 0;