fix: keep Joy-Con links fast and stop idle rumble writes

This commit is contained in:
Joey Yakimowich-Payne 2026-09-07 20:35:02 -06:00
commit 79a72d811d
10 changed files with 220 additions and 84 deletions

View file

@ -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

View file

@ -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.

View file

@ -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;

Binary file not shown.

Binary file not shown.

View file

@ -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);

View file

@ -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");

View file

@ -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;