diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index 16d3a74..5cdb41b 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -819,13 +819,26 @@ 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 Switch 2 BLE links plus Classic now select 30 ms Switch 2 connection -intervals automatically; other topologies retain 7.5 ms. This trades Switch 2 -delivery latency for Classic airtime without changing HD encoding or bonds. -Physical DualSense power-off/reconnect verified both interval transitions -without losing the Joy-Con links. A 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. +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. 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 4847b73..d35e4fd 100644 --- a/README.md +++ b/README.md @@ -481,13 +481,16 @@ 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:** when at least two physical Switch 2 -BLE links coexist with a Classic Bluetooth link, the firmware requests a -30 ms connection interval for the Switch 2 links. Otherwise it requests -7.5 ms. A merged Joy-Con pair counts as two links. Unrelated BLE controllers +**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 runs during connection setup, before native DualSense -attachment, and reconciles asynchronous negotiation and topology changes. +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. This trades additional Switch 2 input, gyro and rumble delivery latency for Classic radio time. Multiple reports can travel per connection event, so @@ -497,13 +500,24 @@ 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 -Classic arrival/departure, a missing half, handle reuse, late negotiation -completion, clock wrap and transient request rejection. -In the physical power-off/reconnect check, both Joy-Cons negotiated 7.5 ms -after DualSense shut down, then 30 ms after it reconnected with PS. Their -Bluetooth handles stayed unchanged; native DualSense streaming restarted -automatically and sent 1,715 packets without skips at the observation point. +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. ### Rumble per controller diff --git a/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index 9c3292f..a1334e1 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 58ad4d7..c6c8e9c 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 fae6cf7..09dee34 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 933a7bf..dbc2d53 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 60c3519..bbb1893 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. Two fast Switch 2 links starve Classic native PCM. +// LE units are 1.25 ms. Reserve airtime for Classic paging and native PCM. constexpr uint16_t kSwitch2FastInterval = 6; constexpr uint16_t kSwitch2MixedInterval = 24; constexpr uint32_t kSwitch2IntervalSettleMs = 1000; @@ -591,11 +591,11 @@ void stop_background_scan() { g_background_scan_active = false; } } -// Core 1 only. Count physical links, including Classic setup, rather than -// logical players: a merged Joy-Con pair still consumes two LE connections. +// 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. void apply_radio_connection_policy() { unsigned switch2_links = 0; - bool classic_link = false; uni_hid_device_t* ready[kSlotCount]{}; for (const BackendSlot& slot : g_slots) { uni_hid_device_t* targets[] = {slot.device, slot.companion}; @@ -609,12 +609,10 @@ void apply_radio_connection_policy() { // The parser requests its initial interval during setup. // Do not race that request by changing a pending device here. if (slot.active) ready[index] = target; - } else if (type == GAP_CONNECTION_ACL) { - classic_link = true; } } } - const uint16_t desired = switch2_links >= 2 && classic_link + const uint16_t desired = switch2_links >= 2 ? kSwitch2MixedInterval : kSwitch2FastInterval; const uint32_t now_ms = btstack_run_loop_get_time_ms(); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index c4450fa..070b7ab 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -3034,33 +3034,37 @@ void test_switch2_admission() { "Pro2 must preserve vertical normal controls and ingest remappable extras"); } -void test_switch2_radio_policy() { +void test_switch2_radio_policy(bool individual) { start_backend(); + if (individual) set_runtime_joycon_mode(JoyConMode::kIndividual); auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); auto classic = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR); auto other_ble = device(3, true, UNI_BT_CONN_PROTOCOL_BLE); ready_switch2(left); + require(negotiated_intervals[left.conn.handle] == 6, + "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"); platform_on_device_connected(&other_ble); require(platform_on_device_ready(&other_ble) == UNI_ERROR_SUCCESS, "unrelated BLE controller must join"); - require(negotiated_intervals[left.conn.handle] == 6 && - negotiated_intervals[right.conn.handle] == 6, - "a pair without Classic must retain fast intervals"); platform_on_device_connected(&classic); require(negotiated_intervals[left.conn.handle] == 24 && negotiated_intervals[right.conn.handle] == 24 && negotiated_intervals[other_ble.conn.handle] == 6, - "Classic setup must relax both physical halves before native attachment, not unrelated BLE"); + "Classic setup must retain reconnect airtime without retiming 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] == 6 && - negotiated_intervals[right.conn.handle] == 6 && + require(negotiated_intervals[left.conn.handle] == 24 && + negotiated_intervals[right.conn.handle] == 24 && slot_snapshot(0).connection_generation == pair.connection_generation, - "Classic departure must restore fast intervals without rebinding the pair"); + "Classic departure must preserve reconnect airtime without rebinding the pair"); platform_on_device_connected(&classic); require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS, "Classic reconnect must complete"); @@ -3090,22 +3094,20 @@ void test_switch2_radio_settling() { "pending Switch2 setup must retain ownership of its initial interval"); require(platform_on_device_ready(&right) == UNI_ERROR_SUCCESS, "second Switch2 connection must become ready"); - platform_on_device_disconnected(&classic); - // The controller completes the old update after the topology reversed. + platform_on_device_disconnected(&right); + // The surviving controller completes the old update after losing its mate. for (auto* half : {&left, &right}) { negotiated_intervals[half->conn.handle] = pending_intervals[half->conn.handle]; } now_ms += 50; process_configuration_timer(&g_configuration_timer); - for (auto* half : {&left, &right}) { - negotiated_intervals[half->conn.handle] = pending_intervals[half->conn.handle]; - require(negotiated_intervals[half->conn.handle] == 6, - "late mixed-mode completion must not strand a pair at slow intervals across clock wrap"); - } + negotiated_intervals[left.conn.handle] = pending_intervals[left.conn.handle]; + require(negotiated_intervals[left.conn.handle] == 6, + "late coexistence update must not strand a solo at slow intervals across clock wrap"); process_configuration_timer(&g_configuration_timer); defer_interval_updates = false; reject_interval_updates = true; - platform_on_device_connected(&classic); + ready_switch2(right); const unsigned attempts = interval_requests[left.conn.handle]; now_ms += 999; process_configuration_timer(&g_configuration_timer); @@ -5477,7 +5479,9 @@ int main(int argc, char** argv) { } else if (scenario == "switch2-admission") { test_switch2_admission(); } else if (scenario == "switch2-radio-policy") { - test_switch2_radio_policy(); + test_switch2_radio_policy(false); + } else if (scenario == "switch2-radio-individual") { + test_switch2_radio_policy(true); } else if (scenario == "switch2-radio-settling") { test_switch2_radio_settling(); } else if (scenario == "switch2-mate-reconnect") { diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index f563125..591c16f 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -127,6 +127,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "switch2-pair-replacement", "switch2-admission", "switch2-radio-policy", + "switch2-radio-individual", "switch2-radio-settling", "switch2-mate-reconnect", "switch2-mate-pending",