diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index 5e3d1b7..0f450ea 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -728,6 +728,9 @@ Current constraints: payload at actual submission. Native ownership cancels compatibility timers. - `switch_native_output.cpp` owns bounded generation-tagged queues, L2CAP permission-driven delivery, expiry, feedback/resume and held-state coalescing. +- `native_output_scheduler.cpp` arbitrates both native writers with urgent-stop + precedence, earliest deadlines, rotating ties, periodic credit reservations, + and generation-bound grant completion. Radio power policy is unchanged. - Adapter configuration schema 3 persists up to 16 explicit physical approvals. Existing profile schema 6/catalog 2, bonds and wake identity are unchanged. - Joy-Cons are currently separate, horizontally mapped controllers in diff --git a/CMakeLists.txt b/CMakeLists.txt index fedba5f..24b2bb4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -247,6 +247,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") ${SWITCH_PICO_SOURCE_DIR}/core/controller_identity.cpp ${SWITCH_PICO_SOURCE_DIR}/input/bluepad32_input_backend.cpp ${SWITCH_PICO_SOURCE_DIR}/input/switch_native_output.cpp + ${SWITCH_PICO_SOURCE_DIR}/input/native_output_scheduler.cpp ${SWITCH_PICO_SOURCE_DIR}/usb/switch/switch_native_haptics.cpp ${SWITCH_PICO_SOURCE_DIR}/input/controller_macro_capture.cpp ${SWITCH_PICO_SOURCE_DIR}/input/switch2_wake.cpp diff --git a/README.md b/README.md index c45bc4d..a834f90 100644 --- a/README.md +++ b/README.md @@ -363,13 +363,23 @@ states refresh at 40 ms. XInput uses held low/left-160-Hz and high/right-320-Hz effects until explicitly stopped. Standalone Joy-Cons downmix each band by dominant amplitude, choosing left on ties; logical Joy-Con pairing is not added. +Nintendo and DualSense native sends share a fixed-capacity **deadline-aware +host scheduler**. Real stop transitions take priority, pending packets use +earliest-deadline order with rotating ties, and earlier periodic deadlines +protect the last available controller ACL credit. Grants and releases are +connection-generation-bound; LED handoffs and reconnects do not retain stale +permissions. This schedules HCI submission, not the radio's on-air slots. +The standard firmware does **not** disable Bluetooth sniff/power-saving mode. + **Qualification is incomplete.** The optimized Pro-only build passed 1,025 distinct commands at 125 Hz with no loss or congestion. Separate held-state testing coalesced 505 of 513 commands into eight state changes plus refreshes, also without loss; the user confirmed both actuators, both bands and clean -stops. Mixed held-effect traffic also passed at approximately 125 Hz per -controller. Continuously changing both streams at that rate still loses Pro -commands and DualSense audio slots; transport experiments remain unqualified. +stops. On the final deadline-scheduler build, a 16.6-second mixed held-effect +test received 2,049 commands per controller with no Pro command drops and one +DualSense audio-slot skip. This result was accepted for the current setup. +Continuously changing both streams at 125 Hz still loses Pro commands and +DualSense audio slots; lossless high-rate transport is not claimed. Joy-Con hardware, four-controller operation, captured game effects and physical actuator timing are not qualified by the native regression suite. See [SWITCH_FAMILY_HD_RUMBLE_PLAN.md](SWITCH_FAMILY_HD_RUMBLE_PLAN.md) for the exact @@ -770,15 +780,15 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file: | Resource | Used or reserved | Device capacity | |---|---:|---:| -| Executable flash image | 777,656 bytes | 4 MiB | +| Executable flash image | 786,864 bytes | 4 MiB | | Indexed profile arenas | 256 KiB | 4 MiB flash | | Adapter configuration | 8 KiB | 4 MiB flash | | BTstack bonds | 8 KiB | 4 MiB flash | | RP2350 terminal sector | 4 KiB | 4 MiB flash | -| Allocated/reserved SRAM, including heap and stacks | 139,344 bytes | 520 KiB | +| Allocated/reserved SRAM, including heap and stacks | 139,616 bytes | 520 KiB | -The executable plus persistent reservations consume 1,060,280 bytes of flash, -leaving 3,134,024 bytes. Allocated SRAM sections leave 393,136 bytes of link-time +The executable plus persistent reservations consume 1,069,488 bytes of flash, +leaving 3,124,816 bytes. Allocated SRAM sections leave 392,864 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/SWITCH_FAMILY_HD_RUMBLE_PLAN.md b/SWITCH_FAMILY_HD_RUMBLE_PLAN.md index fb0ab52..27b1506 100644 --- a/SWITCH_FAMILY_HD_RUMBLE_PLAN.md +++ b/SWITCH_FAMILY_HD_RUMBLE_PLAN.md @@ -143,18 +143,20 @@ prove nonzero-effect fidelity. Exit-sniff requests alone failed to keep the Pro active; that unproven automatic-wake code has been removed. The no-sniff policy is an experimental per-link setting, not a qualified production default. -The current diagnostic build supports **same-boot** receive bounds 1/2/4 and -incoming-credit thresholds 2/3, retaining the existing credit timer and three -advertised receive buffers. Runtime controls were exercised and returned to -baseline receive bound 1 / credit threshold 2; the radio workload matrix is -awaiting normal Home/PS reconnects after flashing. Native approval remains on. +The diagnostic builds supported same-boot receive bounds 1/2/4 and incoming +credit thresholds 2/3 while retaining the existing credit timer and three +advertised receive buffers. These experiments did not establish a lossless +mixed high-rate configuration. Temporary `build-aio/link_probe.cpp`, `link_probe_config.h`, -`link_probe_credit.patch`, and `SWITCH_PICO_LINK_PROBE` CMake/management hooks -are **uncommitted experiment code**, not packaged release firmware. -Operation `0x44`, diagnostic schema 2, returns 468 bytes containing link -snapshots, selected HCI command/mode events, disconnect reasons, and control -completion state. OUT payload `` supports: +`link_probe_credit.patch`, and `SWITCH_PICO_LINK_PROBE` hooks were removed +when packaging the deadline-aware scheduler. Production settings remain one +received packet per service turn, two-credit batching, normal link policy, +and 300 MHz/1.3 V. Native approvals remain enabled. + +For historical reproduction, diagnostic operation `0x44` (schema 2, 468-byte +payload) captured link snapshots, selected HCI events, disconnect reasons and +control completion state. Its OUT payload `` supported: - connected Pro handle + policy 1 (role-switch only), 5 (normal role-switch plus sniff), or `0xffff` (readback); @@ -194,8 +196,57 @@ DualSense stream was in can-send timeout. Original interval 24 / policy 5 were restored and the connected DualSense native stream was re-armed. Do not promote interval-only tuning from command acceptance. -Keep approvals/bonds/profiles unchanged. Remove diagnostic hooks and restore -or qualify settings before publishing another production build. +Further interval/window tests with attempt 1 / timeout 0 also failed: +7.5 ms completed 464 of 1,025 Pro commands, dropped 560 and skipped 76 +DualSense audio slots; 5 ms completed 162 of 513 Pro commands, dropped 350 +and skipped 52 slots. Each coalesced one command. Original timing was restored. + +A host-side outstanding-packet quota protected DualSense only by sacrificing +Pro delivery. At quotas 8/4/2/1, Pro completed 224/184/97/50 of 513 commands, +with 22/8/0/0 DualSense skips. A repeated quota-2 trial completed 95 commands +with zero DualSense skips; the repeated quota-8 baseline completed 207 with +27 skips. Quotas were restored and that experimental gate was removed. + +### Shared deadline-aware scheduler implementation + +The user selected a deadline-aware host scheduler rather than making an +unqualified power-policy change permanent. `input/native_output_scheduler.*` +now coordinates Nintendo and DualSense native senders: + +- Fixed four-client table, no payload/PCM FIFO or heap allocation. +- Urgent real stop transitions first; earliest pending deadlines next; + rotating equal-deadline ties; bounded waves of synchronous grants. +- The last free controller ACL credit is held for an earlier announced + periodic deadline. This is host admission, not reserved on-air time. +- Future-reservation changes do not dispatch synchronously. A regression + reproduced the former rollover race: announcing the next PCM interval + dispatched later Pro work before the already-due PCM request was registered. +- Every send/abort releases one grant, identified by connection generation. + A stale completion cannot release a replacement connection's grant. +- Generic LED FIFO handoffs preserve fixture/drain ownership, without + retaining a notification that prevents the next PCM request. +- Both writers retain their original packet formats, gains, command lifetimes + and generation/expiry rules. No Bluetooth power-policy commands are added. + +Software verification: 261 repository tests pass, including 11 dedicated +arbiter scenarios and both native sender lifecycle suites. All five firmware +variants build. High-rate radio qualification remains a separate failure: +the corrected scheduler candidate completed 342/1,025 Pro commands, coalesced +one, dropped 682, and skipped 48 DualSense audio slots under a zero-amplitude +changing-command workload. Both connections stayed stable. A mixed held-effect +run completed 33 state changes, coalesced 992 holds and dropped none out of +1,025 Pro commands, with 197 reports including refreshes; DualSense accepted +all host commands and skipped one audio slot. These are measured limits, not +a claim that host arbitration solves the remaining mixed-transport limit. + +Final packaged-build check: all 24 profiles, active indices, aliases and names +were preserved; adapter configuration remained generation 13 with Pro native +approval enabled. A 16.6-second mixed held-effect run received 2,049 commands +per controller, with zero Pro drops, 1,984 coalesced holds, stable connections, +and one DualSense audio-slot skip. The user accepted this result as sufficient +for the current setup. No further power-policy tuning was applied, and no +lossless high-rate, Joy-Con, four-controller or captured-game qualification is +implied by that acceptance. Linux's current Nintendo driver also documents disconnect risk from excessive output traffic and uses input-report-aware throttling. This is corroborating diff --git a/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index c963f10..5603432 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 1cd8445..c59baa0 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 abadd3f..9d1d765 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 1cd8445..70d9e32 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 25a34ae..be68b89 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -7,6 +7,9 @@ #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT #include "input/haptics_experiment.h" #endif +#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE) +#include "input/native_output_scheduler.h" +#endif #include "configuration/configuration_service.h" #include "profile/profile_service.h" #include @@ -1887,16 +1890,12 @@ uni_platform* get_platform() { #if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE) extern "C" bool uni_platform_on_l2cap_can_send_now( uni_hid_device_t* device, uint16_t cid) { -#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE - // Nintendo shares this event with queued LED/subcommand output. Let the - // normal queue run too; its parser refreshes payload/counter at submission. - if (switch_native_output_on_can_send_now(device, cid)) return false; -#endif + bool block_generic = false; #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT - return haptics_experiment_on_can_send_now(device, cid); -#else - return false; + block_generic = haptics_experiment_blocks_generic(device); #endif + const bool consumed = native_output_scheduler_on_can_send_now(device, cid); + return consumed || block_generic; } #endif @@ -1959,6 +1958,9 @@ void bluepad32_input_backend_init() { critical_section_init(&g_state_lock); configuration_service_prepare(); profile_service_prepare(); +#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE) + native_output_scheduler_prepare(); +#endif #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE switch_native_output_prepare(); #endif diff --git a/src/firmware/input/haptics_experiment.cpp b/src/firmware/input/haptics_experiment.cpp index 37b1456..ae14e22 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/native_output_scheduler.h" #include "input/switch_hd_rumble_synth.h" #include @@ -122,9 +123,10 @@ static const int8_t HAPTICS_DATA kSine[30] = { void cadence_timer(btstack_timer_source_t*); void lifecycle_timer(btstack_timer_source_t*); -void request_send(); +void request_send(bool promote = false); void restore_compatibility(HapticsExperimentState state); void start_stream(); +bool on_output_grant(uni_hid_device_t* device, uint16_t cid, uint32_t generation); bool gameplay() { return g_diagnostics.mode == 1; @@ -256,8 +258,24 @@ void schedule_lifecycle(uint64_t deadline_us) { schedule_timer(&g_lifecycle_timer, &g_lifecycle_armed, deadline_us); } +void schedule_packet() { + const uint64_t due_us = packet_due(g_next_packet); + native_output_scheduler_reserve(g_connection.device, + g_connection.generation, due_us); + schedule_timer(&g_cadence_timer, &g_cadence_armed, due_us); +} + +void reserve_after_packet() { + const uint64_t due_us = + g_phase == Phase::kPattern && (gameplay() || g_next_packet + 1 < kPackets) + ? packet_due(g_next_packet + 1) : UINT64_MAX; + native_output_scheduler_reserve(g_connection.device, + g_connection.generation, due_us); +} + void finish(HapticsExperimentState state, uint8_t error) { haptics_transport_probe_end(); + native_output_scheduler_cancel(g_connection.device); cancel_timer(&g_cadence_timer, &g_cadence_armed); cancel_timer(&g_lifecycle_timer, &g_lifecycle_armed); g_send_requested = false; @@ -283,8 +301,8 @@ void timeout_drain() { } ++g_diagnostics.send_failures; g_diagnostics.last_error = kTimeout; - // The outstanding BTstack notification cannot be canceled. Stop accepting - // it as PCM permission; the normal FIFO may use it for compatibility output. + // Cancel native admission; an outstanding stack notification may still + // arrive later and belongs to the ordinary compatibility FIFO. g_send_requested = false; restore_compatibility(HapticsExperimentState::kError); } @@ -299,8 +317,10 @@ void begin_drain(HapticsExperimentState state, uint64_t deadline_us) { schedule_lifecycle(deadline_us); if (time_us_64() >= deadline_us) { timeout_drain(); - } else if (!g_send_requested) { - request_send(); + } else { + // Promote a pending tone in place: its eventual grant must send the + // urgent stop, without requesting a second stack notification. + request_send(true); } } @@ -322,6 +342,7 @@ void restore_compatibility(HapticsExperimentState state) { g_accept_host = false; critical_section_exit(&g_lock); cancel_timer(&g_cadence_timer, &g_cadence_armed); + native_output_scheduler_cancel(g_connection.device); g_send_requested = false; g_phase = Phase::kRestore; g_finish_state = g_diagnostics.send_failures != 0 @@ -343,20 +364,45 @@ void restore_compatibility(HapticsExperimentState state) { publish(); } -void request_send() { - if (g_send_requested || g_request_in_progress || +void request_send(bool promote) { + if ((g_send_requested && !promote) || g_request_in_progress || (g_phase != Phase::kPattern && g_phase != Phase::kDrain)) { return; } - g_request_us = time_us_64(); + const uint64_t now_us = time_us_64(); + const bool stopping = g_phase == Phase::kDrain; + if (!stopping) { + if (now_us >= g_end_us) { + end_pattern(); + return; + } + if (now_us < packet_due(g_next_packet)) { + schedule_packet(); + return; + } + const uint32_t current = static_cast( + ((now_us - g_start_us) * kPacketDenominator) / packet_numerator_us()); + g_diagnostics.skipped_packets += current - g_next_packet; + g_next_packet = current; + reserve_after_packet(); + } + if (!g_send_requested) { + g_request_us = now_us; + } 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); + const uint8_t status = native_output_scheduler_request( + g_connection.device, g_connection.generation, + stopping ? now_us : packet_due(g_next_packet), stopping, on_output_grant); + if (stopping && g_send_requested) { + // Clear cadence only after promotion: reserve() can pump a pending + // request synchronously, so the urgent bit must already be installed. + reserve_after_packet(); + } g_request_in_progress = false; if (status == ERROR_CODE_SUCCESS && !g_send_requested) { // The synchronous callback already published its result and armed the @@ -380,8 +426,7 @@ void request_send() { g_diagnostics.skipped_packets += current + 1 - g_next_packet; g_next_packet = current + 1; if (gameplay() || g_next_packet < kPackets) { - schedule_timer(&g_cadence_timer, &g_cadence_armed, - packet_due(g_next_packet)); + schedule_packet(); } } } else if (g_phase == Phase::kDrain) { @@ -505,8 +550,7 @@ void cadence_timer(btstack_timer_source_t*) { if (now_us >= g_end_us) { end_pattern(); } else if (now_us < packet_due(g_next_packet)) { - schedule_timer(&g_cadence_timer, &g_cadence_armed, - packet_due(g_next_packet)); + schedule_packet(); } else { request_send(); } @@ -753,6 +797,7 @@ void haptics_experiment_detach(uni_hid_device_t* device) { if (device == nullptr) { return; } + native_output_scheduler_cancel(device); if (g_phase != Phase::kIdle && g_connection.device == device) { // Do not dereference the device or send restoration on a dead link. finish(HapticsExperimentState::kDisconnected, kConnection); @@ -824,11 +869,24 @@ bool haptics_experiment_feedback(uni_hid_device_t* device, return true; } +bool haptics_experiment_blocks_generic(const uni_hid_device_t* device) { + return haptics_experiment_owns(device) && + (g_phase == Phase::kDrain || + (g_phase == Phase::kPattern && !gameplay())); +} -bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, - uint16_t cid) { - if (device != g_connection.device || !connection_current() || +namespace { + +bool HAPTICS_HOT(on_output_grant)(uni_hid_device_t* device, uint16_t cid, uint32_t generation) { + if (!native_output_scheduler_granted(device)) { + return haptics_experiment_blocks_generic(device); + } + if (g_in_callback) { + return true; + } + if (device != g_connection.device || generation != g_connection.generation || !connection_current() || (g_phase != Phase::kPattern && g_phase != Phase::kDrain)) { + native_output_scheduler_complete(device, generation); return false; } if (gameplay() && g_phase == Phase::kPattern && !g_in_callback && @@ -840,12 +898,13 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, g_send_requested = false; schedule_timer(&g_cadence_timer, &g_cadence_armed, time_us_64() + 1000); } + native_output_scheduler_complete(device, generation); 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) { - return true; + // Only the scheduler's current interrupt-CID grant is PCM permission. + if (cid != g_connection.cid || !g_send_requested) { + native_output_scheduler_complete(device, generation); + return haptics_experiment_blocks_generic(device); } g_send_requested = false; g_in_callback = true; @@ -864,15 +923,16 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, schedule_lifecycle(g_end_us + kDrainTimeoutUs); } if (g_phase == Phase::kDrain && now_us >= g_lifecycle_due_us) { + native_output_scheduler_complete(device, generation); timeout_drain(); g_in_callback = false; return true; } if (g_phase == Phase::kPattern && now_us < packet_due(g_next_packet)) { // Notifications are not reservations of credit for a future deadline. - schedule_timer(&g_cadence_timer, &g_cadence_armed, - packet_due(g_next_packet)); + schedule_packet(); g_in_callback = false; + native_output_scheduler_complete(device, generation); publish(); return true; } @@ -885,6 +945,7 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, g_next_packet = current; due_us = packet_due(current); } + reserve_after_packet(); uint8_t report[kReportBytes]; const uint64_t generate_start_us = time_us_64(); const bool tone = generate_packet(report, g_next_packet, stopping); @@ -897,17 +958,27 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, if (!stopping && submit_us >= packet_due(g_next_packet + 1)) { // A flash/interrupt stall can occur during synthesis as well as before // CAN_SEND_NOW. Never submit a now-obsolete tone after its phase ended. - ++g_diagnostics.skipped_packets; - ++g_next_packet; + uint32_t next = static_cast( + ((submit_us - g_start_us) * kPacketDenominator) / + packet_numerator_us()) + 1; + if (!gameplay() && next > kPackets) { + next = kPackets; + } + g_diagnostics.skipped_packets += next - g_next_packet; + g_next_packet = next; if (gameplay() || g_next_packet < kPackets) { - schedule_timer(&g_cadence_timer, &g_cadence_armed, - packet_due(g_next_packet)); + schedule_packet(); + } else { + native_output_scheduler_reserve(device, g_connection.generation, + UINT64_MAX); } g_in_callback = false; + native_output_scheduler_complete(device, generation); publish(); return true; } if (stopping && submit_us >= g_lifecycle_due_us) { + native_output_scheduler_complete(device, generation); timeout_drain(); g_in_callback = false; return true; @@ -915,6 +986,7 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, const uint64_t send_started_us = time_us_64(); const uint8_t status = l2cap_send(cid, report, sizeof(report)); const uint64_t send_returned_us = time_us_64(); + native_output_scheduler_complete(device, generation); haptics_transport_probe_send( static_cast(send_returned_us - send_started_us), static_cast(send_returned_us), @@ -952,8 +1024,7 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, } else { ++g_next_packet; if (gameplay() || g_next_packet < kPackets) { - schedule_timer(&g_cadence_timer, &g_cadence_armed, - packet_due(g_next_packet)); + schedule_packet(); } if (gameplay()) { schedule_lifecycle(packet_due(g_next_packet) + kDrainTimeoutUs); @@ -963,3 +1034,5 @@ bool HAPTICS_HOT(haptics_experiment_on_can_send_now)(uni_hid_device_t* device, publish(); return true; } + +} // namespace diff --git a/src/firmware/input/haptics_experiment.h b/src/firmware/input/haptics_experiment.h index e040ede..23b0483 100644 --- a/src/firmware/input/haptics_experiment.h +++ b/src/firmware/input/haptics_experiment.h @@ -67,5 +67,5 @@ 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); -bool haptics_experiment_on_can_send_now(uni_hid_device_t* device, - uint16_t cid); +// Preserve fixture/drain exclusivity on unsolicited or control-CID events. +bool haptics_experiment_blocks_generic(const uni_hid_device_t* device); diff --git a/src/firmware/input/native_output_scheduler.cpp b/src/firmware/input/native_output_scheduler.cpp new file mode 100644 index 0000000..4333029 --- /dev/null +++ b/src/firmware/input/native_output_scheduler.cpp @@ -0,0 +1,304 @@ +#include "input/native_output_scheduler.h" + +#include +#include +#include + +namespace { +constexpr uint8_t kCapacity = 4; +constexpr uint8_t kNoClient = kCapacity; +constexpr uint8_t kNoCapacity = 0x07; +constexpr uint8_t kInvalidConnection = 0x02; + +struct Client { + uni_hid_device_t* device = nullptr; + uint32_t generation = 0; + uint16_t cid = 0; + uint16_t handle = 0; + uint64_t deadline_us = 0; + uint64_t periodic_deadline_us = 0; + NativeOutputGrant callback = nullptr; + bool pending = false; + bool waiting = false; + bool urgent = false; + bool reserved = false; + bool generic_deferred = false; + uint8_t request_status = 0; +}; +struct EventContext { + uni_hid_device_t* device; + uint16_t cid; + bool consumed = false; + EventContext* previous; +}; + +Client clients[kCapacity]; +uint8_t granted = kNoClient; +uint8_t cursor = 0; +bool prepared = false; +bool pumping = false; +bool repump = false; +bool timer_armed = false; +btstack_timer_source_t retry_timer{}; +EventContext* current_event = nullptr; + +void pump(); + +bool current(const Client& client) { + return client.device != nullptr && client.device->conn.connected && + client.device->conn.interrupt_cid == client.cid && + client.device->conn.handle == client.handle && client.cid != 0; +} + +void retire(uint8_t index) { + if (granted == index) granted = kNoClient; + clients[index] = {}; +} + +uint8_t find(uni_hid_device_t* device, uint32_t generation, bool allocate) { + uint8_t empty = kNoClient; + for (uint8_t i = 0; i < kCapacity; ++i) { + Client& client = clients[i]; + if (client.device == device) { + if (client.generation != generation || !current(client)) retire(i); + else return i; + } else if (client.device != nullptr && !current(client)) { + retire(i); + } + if (client.device == nullptr && empty == kNoClient) empty = i; + } + if (!allocate || empty == kNoClient) return kNoClient; + clients[empty].device = device; + clients[empty].generation = generation; + clients[empty].cid = device->conn.interrupt_cid; + clients[empty].handle = device->conn.handle; + return empty; +} + +bool earlier(const Client& first, const Client& second) { + if (first.urgent != second.urgent) return first.urgent; + return first.deadline_us < second.deadline_us; +} + +bool generic_event_in_progress(const uni_hid_device_t* device) { + for (auto* event = current_event; event != nullptr; event = event->previous) + if (event->device == device) return true; + return false; +} + +bool admission_allowed(uint8_t index) { + const Client& client = clients[index]; + if (client.urgent) return true; + if (hci_number_free_acl_slots_for_handle(client.handle) != 1) return true; + for (uint8_t i = 0; i < kCapacity; ++i) { + if (i == index || !current(clients[i]) || !clients[i].reserved) continue; + const uint64_t deadline = clients[i].periodic_deadline_us; + if (deadline < client.deadline_us || + (deadline == client.deadline_us && + (i + kCapacity - cursor) % kCapacity < (index + kCapacity - cursor) % kCapacity)) + return false; + } + return true; +} + +void retry(btstack_timer_source_t*) { + timer_armed = false; + pump(); +} + +void arm_retry(uint64_t due_us) { + if (timer_armed) btstack_run_loop_remove_timer(&retry_timer); + const uint64_t now_ms = time_us_64() / 1000; + const uint64_t due_ms = due_us / 1000; + const uint32_t delay = due_ms > now_ms + 1 ? due_ms - now_ms - 1 : 0; + btstack_run_loop_set_timer_handler(&retry_timer, retry); + btstack_run_loop_set_timer(&retry_timer, delay); + timer_armed = true; + btstack_run_loop_add_timer(&retry_timer); +} + +void pump() { + if (!prepared) return; + if (pumping) { + repump = true; + return; + } + pumping = true; + if (timer_armed) { + btstack_run_loop_remove_timer(&retry_timer); + timer_armed = false; + } + uint64_t retry_at = UINT64_MAX; + bool exhausted = true; + // Bound synchronous callbacks/self-resubmission. A fresh loop turn services + // receive processing and timers before another bounded wave of grants. + for (uint8_t work = 0; work < kCapacity * 2; ++work) { + repump = false; + for (uint8_t i = 0; i < kCapacity; ++i) + if (clients[i].device != nullptr && !current(clients[i])) retire(i); + if (granted != kNoClient) { + exhausted = false; + break; + } + uint8_t selected = kNoClient; + for (uint8_t offset = 0; offset < kCapacity; ++offset) { + const uint8_t i = (cursor + offset) % kCapacity; + const Client& client = clients[i]; + if (!client.pending || !current(client) || + !l2cap_can_send_packet_now(client.cid) || !admission_allowed(i)) continue; + if (selected == kNoClient || earlier(client, clients[selected])) selected = i; + } + if (selected != kNoClient) { + Client& client = clients[selected]; + const auto callback = client.callback; + auto* device = client.device; + const uint16_t cid = client.cid; + const uint32_t generation = client.generation; + client.pending = false; + client.request_status = 0; + client.generic_deferred = false; + granted = selected; + cursor = (selected + 1) % kCapacity; + const bool consumed = callback(device, cid, generation); + for (auto* event = current_event; event != nullptr; event = event->previous) + if (event->device == device && event->cid == cid) event->consumed |= consumed; + const bool yield_generic = !consumed && client.device == device && + client.generation == generation && client.cid == cid && current(client) && + !uni_circular_buffer_is_empty(&device->outgoing_buffer) && + !generic_event_in_progress(device); + if (yield_generic && granted == selected) client.generic_deferred = true; + if (yield_generic && granted == kNoClient && !client.waiting) { + client.waiting = true; + const uint8_t status = l2cap_request_can_send_now_event(cid); + if (client.device == device && client.generation == generation && status != 0) + client.waiting = false; + } + continue; + } + // No ready client can use the resource. Register one notification per + // pending CID; ready-state and deadline selection are rechecked when any + // callback arrives, rather than treating a callback as reserved airtime. + for (uint8_t offset = 0; offset < kCapacity; ++offset) { + const uint8_t i = (cursor + offset) % kCapacity; + Client& client = clients[i]; + if (!client.pending || !current(client)) continue; + if (!admission_allowed(i)) { + for (uint8_t other = 0; other < kCapacity; ++other) { + if (other == i || !current(clients[other]) || !clients[other].reserved) continue; + const uint64_t due = clients[other].periodic_deadline_us; + const uint64_t next = due > time_us_64() ? due : time_us_64() + 1000; + if (next < retry_at) retry_at = next; + } + continue; + } + if (client.waiting) continue; + const uint32_t generation = client.generation; + auto* device = client.device; + const uint16_t cid = client.cid; + client.waiting = true; + const uint8_t status = l2cap_request_can_send_now_event(cid); + if (client.device != device || client.generation != generation || client.cid != cid) continue; + if (status != 0) { + client.waiting = false; + client.pending = false; + client.request_status = status; + } + } + if (!repump) { + exhausted = false; + break; + } + } + if (exhausted && granted == kNoClient) retry_at = time_us_64() + 1000; + if (granted == kNoClient && retry_at != UINT64_MAX) arm_retry(retry_at); + pumping = false; +} +} // namespace + +void native_output_scheduler_prepare() { + if (prepared) return; + prepared = true; +} + +uint8_t native_output_scheduler_request(uni_hid_device_t* device, + uint32_t generation, + uint64_t deadline_us, + bool urgent_stop, + NativeOutputGrant callback) { + if (!prepared || device == nullptr || callback == nullptr || !device->conn.connected || + device->conn.interrupt_cid == 0) return kInvalidConnection; + const uint8_t index = find(device, generation, true); + if (index == kNoClient) return kNoCapacity; + Client& client = clients[index]; + client.deadline_us = deadline_us; + client.urgent = urgent_stop; + client.callback = callback; + client.pending = true; + client.request_status = 0; + pump(); + return client.device == device && client.generation == generation ? client.request_status : 0; +} + +void native_output_scheduler_reserve(uni_hid_device_t* device, + uint32_t generation, + uint64_t deadline_us) { + if (!prepared || device == nullptr || !device->conn.connected || !device->conn.interrupt_cid) return; + const uint8_t index = find(device, generation, deadline_us != UINT64_MAX); + if (index == kNoClient) return; + clients[index].reserved = deadline_us != UINT64_MAX; + clients[index].periodic_deadline_us = deadline_us; + // Writers may announce the following interval immediately before requesting + // the current due packet. Never dispatch competing work in that gap. + // A standalone reservation release still wakes pending work next loop turn. + if (!pumping && granted == kNoClient) { + for (const auto& client : clients) { + if (client.pending && current(client) && l2cap_can_send_packet_now(client.cid)) { + arm_retry(time_us_64() + 1000); + break; + } + } + } +} + +void native_output_scheduler_complete(uni_hid_device_t* device, uint32_t generation) { + const uint8_t index = granted; + const bool owned = index != kNoClient && clients[index].device == device && + clients[index].generation == generation; + const bool wake_generic = owned && clients[index].generic_deferred && + !generic_event_in_progress(device); + if (owned) clients[index].generic_deferred = false; + if (owned) granted = kNoClient; + pump(); + if (wake_generic && clients[index].device == device && clients[index].generation == generation && + current(clients[index]) && granted != index && !clients[index].waiting && + !uni_circular_buffer_is_empty(&device->outgoing_buffer)) { + clients[index].waiting = true; + const uint8_t status = l2cap_request_can_send_now_event(clients[index].cid); + if (clients[index].device == device && clients[index].generation == generation && status != 0) + clients[index].waiting = false; + } +} + +void native_output_scheduler_cancel(uni_hid_device_t* device) { + if (!prepared || device == nullptr) return; + for (uint8_t i = 0; i < kCapacity; ++i) + if (clients[i].device == device) retire(i); + pump(); +} + +bool native_output_scheduler_granted(const uni_hid_device_t* device) { + return granted != kNoClient && clients[granted].device == device && current(clients[granted]); +} + +bool native_output_scheduler_on_can_send_now(uni_hid_device_t* device, uint16_t cid) { + if (!prepared || device == nullptr) return false; + for (auto& client : clients) + if (client.device == device && client.cid == cid && current(client)) client.waiting = false; + const bool leased = granted != kNoClient && clients[granted].device == device && + current(clients[granted]); + EventContext event{device, cid, leased, current_event}; + current_event = &event; + pump(); + current_event = event.previous; + return event.consumed; +} diff --git a/src/firmware/input/native_output_scheduler.h b/src/firmware/input/native_output_scheduler.h new file mode 100644 index 0000000..6e3f79c --- /dev/null +++ b/src/firmware/input/native_output_scheduler.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +struct uni_hid_device_s; +using NativeOutputGrant = bool (*)(uni_hid_device_s*, uint16_t, uint32_t); + +// Fixed capacity: one native output client per physical controller, four total. +// prepare() precedes Core 1 launch; all other calls belong to BTstack/Core 1. +// Callbacks run without locks, at most one grant at a time. A callback's bool +// retains the existing generic-FIFO consumption convention (Sony true, Nintendo +// false). The grantee MUST complete() after its single send attempt or an abort; +// a grant must never be held across a timer/event-loop wait. +void native_output_scheduler_prepare(); +uint8_t native_output_scheduler_request(uni_hid_device_s* device, + uint32_t generation, + uint64_t deadline_us, + bool urgent_stop, + NativeOutputGrant grant); +// Announce the next periodic deadline without requesting an early send. The +// arbiter leaves the last free controller ACL credit for an earlier announced +// deadline, with rotating ties, except urgent stops. UINT64_MAX clears this +// reservation without canceling a pending request. This is host admission, not +// an on-air reservation. +// This update never invokes a grant callback synchronously. +void native_output_scheduler_reserve(uni_hid_device_s* device, + uint32_t generation, + uint64_t deadline_us); +void native_output_scheduler_complete(uni_hid_device_s* device, uint32_t generation); +void native_output_scheduler_cancel(uni_hid_device_s* device); +bool native_output_scheduler_granted(const uni_hid_device_s* device); +bool native_output_scheduler_on_can_send_now(uni_hid_device_s* device, + uint16_t cid); diff --git a/src/firmware/input/switch_native_output.cpp b/src/firmware/input/switch_native_output.cpp index b1c2119..e90bb2f 100644 --- a/src/firmware/input/switch_native_output.cpp +++ b/src/firmware/input/switch_native_output.cpp @@ -1,4 +1,5 @@ #include "input/switch_native_output.h" +#include "input/native_output_scheduler.h" #include "usb/switch/switch_native_haptics.h" #include "usb/switch/switch_haptics_amplitudes.h" @@ -31,6 +32,7 @@ struct SharedSlot { uint32_t generation = 0; bool accepting = false; bool stop_pending = false; + bool host_active = false; bool lost = false; uint8_t head = 0, count = 0; Command queue[kCapacity]{}; @@ -40,10 +42,13 @@ struct SharedSlot { struct OutputSlot { uni_hid_device_t* device = nullptr; ControllerIdentity identity{}; + uint32_t generation = 0; bool approved = false, active = false, mono = false; bool neutral_needed = false, dirty = false, host_valid = false; bool feedback_active = false, pending_host = false, pending_trimmed = false; - bool permission_requested = false, permission_granted = false; + bool permission_requested = false, processing = false, urgent_stop = false; + bool requested_urgent_stop = false; + uint64_t deadline_us = 0, requested_deadline_us = 0; uint64_t feedback_until_us = 0, last_send_us = 0, retry_us = 0; uint64_t pending_since_us = 0; uint64_t pending_valid_until_us = UINT64_MAX; @@ -66,6 +71,8 @@ btstack_data_source_t g_data_source{}; btstack_timer_source_t g_timer{}; void poll(); +void process_slot(uint8_t slot); +bool granted(uni_hid_device_t* device, uint16_t cid, uint32_t generation); void increment(uint32_t& value, uint32_t amount = 1) { value = amount > UINT32_MAX - value ? UINT32_MAX : value + amount; } @@ -141,6 +148,23 @@ void ensure_runloop() { btstack_run_loop_set_timer_handler(&g_timer, timer); g_runloop_ready = true; } +uint64_t periodic_deadline(const OutputSlot& output) { + uint64_t deadline = UINT64_MAX; + if (output.host_valid || output.feedback_active) + deadline = output.last_send_us + kRefreshUs; + if (output.feedback_active && output.feedback_until_us < deadline) + deadline = output.feedback_until_us; + if (!output.feedback_active && output.host_valid && !output.host.stateful && + output.host.received_us + kHostExpiryUs < deadline) + deadline = output.host.received_us + kHostExpiryUs; + return deadline; +} +bool ready(const OutputSlot& output, uint64_t now) { + return output.active && now >= output.retry_us && + (output.neutral_needed || output.dirty || output.packets.count || + ((output.feedback_active || output.host_valid) && + now >= output.last_send_us + kRefreshUs)); +} void schedule() { if (g_timer_armed) { btstack_run_loop_remove_timer(&g_timer); @@ -150,6 +174,8 @@ void schedule() { const uint64_t now = time_us_64(); for (const auto& output : g_outputs) { if (!output.active) continue; + native_output_scheduler_reserve(output.device, output.generation, + periodic_deadline(output)); if ((output.neutral_needed || output.dirty || output.packets.count) && !output.permission_requested && output.retry_us < due) due = output.retry_us; if (output.feedback_active && output.feedback_until_us < due) @@ -157,7 +183,8 @@ void schedule() { if (output.host_valid && !output.host.stateful && output.host.received_us + kHostExpiryUs < due) due = output.host.received_us + kHostExpiryUs; - if ((output.feedback_active || output.host_valid) && + if (!output.neutral_needed && !output.dirty && !output.packets.count && + (output.feedback_active || output.host_valid) && output.last_send_us + kRefreshUs < due) due = output.last_send_us + kRefreshUs; } @@ -170,28 +197,36 @@ void schedule() { } bool permission(uint8_t slot) { OutputSlot& output = g_outputs[slot]; - if (output.permission_granted) return true; - if (!output.permission_requested) { + if (native_output_scheduler_granted(output.device)) return true; + if (!output.permission_requested || + output.requested_deadline_us != output.deadline_us || + output.requested_urgent_stop != output.urgent_stop) { + const bool waiting = output.permission_requested; output.permission_requested = true; // Callback may be synchronous. - if (l2cap_request_can_send_now_event(output.device->conn.interrupt_cid) != 0) { + output.requested_deadline_us = output.deadline_us; + output.requested_urgent_stop = output.urgent_stop; + if (native_output_scheduler_request(output.device, output.generation, + output.deadline_us, output.urgent_stop, granted) != 0) { output.permission_requested = false; output.retry_us = time_us_64() + 1000; } - if (!output.permission_granted) { + if (!waiting && !native_output_scheduler_granted(output.device)) { critical_section_enter_blocking(&g_lock); increment(g_shared[slot].diagnostics.congested_attempts); critical_section_exit(&g_lock); } } - return output.permission_granted; + return native_output_scheduler_granted(output.device); } bool send(uint8_t slot, const uint8_t* bytes) { OutputSlot& output = g_outputs[slot]; if (!permission(slot)) return false; - output.permission_granted = false; - const bool sent = uni_hid_parser_switch_native_send(output.device, bytes); + auto* const device = output.device; + const uint32_t generation = output.generation; + const bool sent = uni_hid_parser_switch_native_send(device, bytes); const uint64_t now = time_us_64(); + if (output.device != device || output.generation != generation) return false; critical_section_enter_blocking(&g_lock); auto& diagnostics = g_shared[slot].diagnostics; if (sent) { @@ -245,6 +280,10 @@ ControllerRumbleOutput current_host(const Command& command, uint64_t now, return result; } void restore_compatibility(OutputSlot& output, uint64_t now) { + if (native_output_scheduler_granted(output.device)) + native_output_scheduler_complete(output.device, output.generation); + native_output_scheduler_cancel(output.device); + output.permission_requested = false; uni_hid_parser_switch_native_release(output.device); output.active = false; if (output.device->report_parser.play_dual_rumble == nullptr) return; @@ -264,7 +303,7 @@ void restore_compatibility(OutputSlot& output, uint64_t now) { output.feedback_active = false; output.dirty = false; } -void process_slot(uint8_t slot) { +void process_slot_step(uint8_t slot) { OutputSlot& output = g_outputs[slot]; if (!output.active) return; const uint64_t now = time_us_64(); @@ -285,6 +324,7 @@ void process_slot(uint8_t slot) { shared.count = shared.head = 0; } critical_section_exit(&g_lock); + if (stop && output.host_valid && !output.feedback_active) output.urgent_stop = true; if (have_new && !loss && !output.feedback_active) { const bool pending_hold = output.pending_host && same_hold(output.host, newest, false); @@ -316,6 +356,8 @@ void process_slot(uint8_t slot) { if (loss || stop || output.packets.count) reset_pending(output); output.pending_host = true; output.pending_since_us = newest.first_received_us; + if (output.approved) + output.deadline_us = newest.first_received_us + kCommandWindowUs; output.dirty = true; } output.host = newest; @@ -329,12 +371,16 @@ void process_slot(uint8_t slot) { if (!output.feedback_active) { reset_pending(output); output.dirty = true; + output.deadline_us = output.host.received_us + kHostExpiryUs; + output.urgent_stop = true; } } if (output.feedback_active && now >= output.feedback_until_us) { output.feedback_active = false; reset_pending(output); output.dirty = true; + output.deadline_us = output.feedback_until_us; + output.urgent_stop = !output.host_valid && !silent(output.feedback); } if (output.packets.count && now >= output.pending_valid_until_us) { const bool host_command = output.pending_host; @@ -347,6 +393,7 @@ void process_slot(uint8_t slot) { if (!send(slot, kNeutral)) return; output.encoder.reset(); output.neutral_needed = false; + output.urgent_stop = false; critical_section_enter_blocking(&g_lock); increment(g_shared[slot].diagnostics.resynchronizations); critical_section_exit(&g_lock); @@ -356,6 +403,8 @@ void process_slot(uint8_t slot) { publish_flags(slot); return; } + // A baseline consumes its own grant, even when a codec packet follows. + if (output.approved) return; } if (!output.approved) { restore_compatibility(output, now); @@ -371,6 +420,7 @@ void process_slot(uint8_t slot) { ((output.host_valid || output.feedback_active) && now >= output.last_send_us + kRefreshUs))) { // Request actual credit availability before mutating the encoder model. // Timer polling misses short free-buffer windows behind HCI credit writes. + if (!output.dirty) output.deadline_us = output.last_send_us + kRefreshUs; if (!permission(slot)) return; ControllerRumbleOutput effective{}; output.pending_trimmed = false; @@ -400,15 +450,52 @@ void process_slot(uint8_t slot) { output.packet_index = 0; output.dirty = false; } - // Bounded legal schedule: at most baseline + one compressed command, never - // drain an obsolete command backlog into the controller. - for (uint8_t i = 0; i < 2 && output.packet_index < output.packets.count; ++i) { + // One legal wire packet per grant. The wrapper releases and reacquires + // through the shared arbiter before submitting a prepared tail. + if (output.packet_index < output.packets.count) { if (!send(slot, output.packets.bytes[output.packet_index])) return; ++output.packet_index; } if (output.packets.count && output.packet_index == output.packets.count) finish_command(slot); publish_flags(slot); } +void process_slot(uint8_t slot) { + OutputSlot& output = g_outputs[slot]; + if (output.processing) return; + output.processing = true; + auto* const device = output.device; + const uint32_t generation = output.generation; + // A neutral barrier plus the codec's two-packet schedule is the maximum + // immediate work. Each attempt releases its token before another request. + for (uint8_t attempt = 0; attempt < 3; ++attempt) { + process_slot_step(slot); + if (output.device != device || output.generation != generation) return; + const bool had_grant = native_output_scheduler_granted(output.device); + if (had_grant) native_output_scheduler_complete(device, generation); + if (!had_grant || !ready(output, time_us_64())) break; + } + output.processing = false; +} +bool granted(uni_hid_device_t* device, uint16_t cid, uint32_t generation) { + if (!g_prepared || device == nullptr) return false; + for (uint8_t slot = 0; slot < kSlots; ++slot) { + auto& output = g_outputs[slot]; + if (output.device != device || output.generation != generation || !output.active || + device->conn.interrupt_cid != cid || !output.permission_requested || + !native_output_scheduler_granted(device)) continue; + output.permission_requested = false; + output.retry_us = 0; + // Synchronous delivery resumes the caller's permission() immediately. + // A different slot can run here even while the outer poll is active. + if (!output.processing) { + process_slot(slot); + schedule(); + } + return false; // Nintendo's generic LED FIFO must still see the event. + } + native_output_scheduler_complete(device, generation); // Retired/no-send callback. + return false; +} void poll() { if (g_polling) return; g_polling = true; @@ -445,8 +532,11 @@ void switch_native_output_attach(uint8_t slot, uint32_t generation, (type == 2 && identity.product_id == 0x2007))) return; ensure_runloop(); OutputSlot& output = g_outputs[slot]; + if (output.device) native_output_scheduler_cancel(output.device); output = {}; output.device = device; + output.generation = generation; + output.deadline_us = time_us_64() + kCommandWindowUs; output.identity = identity; output.mono = type != 3; critical_section_enter_blocking(&g_lock); @@ -472,11 +562,13 @@ void switch_native_output_detach(uni_hid_device_t* device) { for (uint8_t i = 0; i < kSlots; ++i) { if (g_outputs[i].device != device) continue; // Parser teardown owns timer retirement; never transmit on a dead CID. + native_output_scheduler_cancel(device); g_outputs[i] = {}; critical_section_enter_blocking(&g_lock); g_shared[i].accepting = false; g_shared[i].count = g_shared[i].head = 0; g_shared[i].stop_pending = g_shared[i].lost = false; + g_shared[i].host_active = false; g_shared[i].diagnostics.flags = 0; critical_section_exit(&g_lock); } @@ -494,11 +586,17 @@ void switch_native_output_configure(const AdapterConfiguration& configuration, const bool approved = adapter_configuration_native_switch_approved(configuration, output.identity); if (approved == output.approved) continue; output.approved = approved; + native_output_scheduler_cancel(output.device); + output.permission_requested = false; + output.deadline_us = time_us_64() + kCommandWindowUs; + output.urgent_stop = !approved && output.active; if (approved && !output.active && uni_hid_parser_switch_native_acquire(output.device)) { output.active = true; reset_pending(output); } else if (!approved && output.active) { reset_pending(output); + output.deadline_us = time_us_64(); + output.urgent_stop = true; } publish_flags(i); } @@ -529,7 +627,9 @@ bool switch_native_output_submit(uint8_t slot, uint32_t generation, shared.queue[(shared.head + shared.count) % kCapacity] = update; ++shared.count; } - shared.stop_pending = shared.stop_pending || silent(rumble); + const bool active = !silent(rumble); + shared.stop_pending = shared.stop_pending || (shared.host_active && !active); + shared.host_active = active; increment(shared.diagnostics.received_commands); } critical_section_exit(&g_lock); @@ -541,19 +641,6 @@ bool switch_native_output_submit(uint8_t slot, uint32_t generation, } return accepted; } -bool switch_native_output_on_can_send_now(uni_hid_device_t* device, uint16_t cid) { - if (!g_prepared || device == nullptr) return false; - for (auto& output : g_outputs) { - if (output.device != device || !output.active || - device->conn.interrupt_cid != cid || !output.permission_requested) continue; - output.permission_requested = false; - output.permission_granted = true; - output.retry_us = 0; - poll(); - return true; - } - return false; -} bool switch_native_output_owns(const uni_hid_device_t* device) { if (!g_prepared || device == nullptr) return false; @@ -566,11 +653,18 @@ bool switch_native_output_feedback(uni_hid_device_t* device, uint8_t low, if (!g_prepared || device == nullptr) return false; for (auto& output : g_outputs) { if (output.device != device || !output.active) continue; + const uint64_t now = time_us_64(); + const bool stopped = output.feedback_active && !silent(output.feedback) && + (duration_ms == 0 || (low == 0 && high == 0)); output.feedback = magnitudes(low, high); - output.feedback_until_us = time_us_64() + uint64_t{duration_ms} * 1000; + output.feedback_until_us = now + uint64_t{duration_ms} * 1000; output.feedback_active = duration_ms != 0; reset_pending(output); output.dirty = true; + output.deadline_us = now + kCommandWindowUs; + if (duration_ms && output.feedback_until_us < output.deadline_us) + output.deadline_us = output.feedback_until_us; + output.urgent_stop = stopped; poll(); return true; } diff --git a/src/firmware/input/switch_native_output.h b/src/firmware/input/switch_native_output.h index 6c2ec47..faba599 100644 --- a/src/firmware/input/switch_native_output.h +++ b/src/firmware/input/switch_native_output.h @@ -47,7 +47,6 @@ bool switch_native_output_submit(uint8_t slot, uint32_t generation, const ControllerRumbleOutput& rumble, bool stateful); bool switch_native_output_owns(const uni_hid_device_s* device); -bool switch_native_output_on_can_send_now(uni_hid_device_s* device, uint16_t cid); bool switch_native_output_feedback(uni_hid_device_s* device, uint8_t low, uint8_t high, uint16_t duration_ms); void switch_native_output_snapshot(uint8_t slot, diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index 7926519..b7ac31d 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -499,11 +499,12 @@ int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) { uint64_t time_us_64() { return uint64_t{now_ms} * 1000; } uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t) { return 143; } +bool l2cap_can_send_packet_now(uint16_t) { return true; } +int hci_number_free_acl_slots_for_handle(uint16_t) { return 8; } uint8_t l2cap_request_can_send_now_event(uint16_t cid) { for (const auto& slot : g_slots) { if (slot.device != nullptr && slot.device->conn.interrupt_cid == cid) { - require(haptics_experiment_on_can_send_now(slot.device, cid), - "native send permission was not consumed"); + (void)uni_platform_on_l2cap_can_send_now(slot.device, cid); return ERROR_CODE_SUCCESS; } } diff --git a/tests/bluepad32_native_stubs/btstack.h b/tests/bluepad32_native_stubs/btstack.h index f01a815..3afd85b 100644 --- a/tests/bluepad32_native_stubs/btstack.h +++ b/tests/bluepad32_native_stubs/btstack.h @@ -6,3 +6,5 @@ uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid); uint8_t l2cap_request_can_send_now_event(uint16_t cid); uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size); +bool l2cap_can_send_packet_now(uint16_t cid); +int hci_number_free_acl_slots_for_handle(uint16_t handle); diff --git a/tests/haptics_experiment_native_stubs/btstack.h b/tests/haptics_experiment_native_stubs/btstack.h index 3219074..15cdd1d 100644 --- a/tests/haptics_experiment_native_stubs/btstack.h +++ b/tests/haptics_experiment_native_stubs/btstack.h @@ -20,5 +20,7 @@ void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ void btstack_run_loop_add_timer(btstack_timer_source_t* timer); int btstack_run_loop_remove_timer(btstack_timer_source_t* timer); uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid); +bool l2cap_can_send_packet_now(uint16_t cid); +int hci_number_free_acl_slots_for_handle(uint16_t handle); uint8_t l2cap_request_can_send_now_event(uint16_t cid); uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size); diff --git a/tests/haptics_experiment_native_stubs/uni.h b/tests/haptics_experiment_native_stubs/uni.h index 181ca3d..59a38b1 100644 --- a/tests/haptics_experiment_native_stubs/uni.h +++ b/tests/haptics_experiment_native_stubs/uni.h @@ -44,4 +44,5 @@ struct uni_hid_device_s { btstack_timer_source_t parser_timer; bool notification_pending = false; bool credit = true; + uint16_t native_ready_cid = 0; }; diff --git a/tests/haptics_experiment_test.cpp b/tests/haptics_experiment_test.cpp index e115fcb..31fa552 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 "input/native_output_scheduler.h" #include "usb/switch/switch_haptics.h" #include @@ -40,7 +41,6 @@ std::vector pcm; std::vector generic_sent; std::vector generic_queue; Delivery delivery = Delivery::kImmediate; -uni_hid_device_t* permission = nullptr; unsigned request_depth = 0; unsigned max_request_depth = 0; unsigned request_calls = 0; @@ -51,6 +51,7 @@ unsigned fail_sends = 0; uint32_t send_cost_us = 0; uint32_t request_cost_us = 0; bool reenter_send = false; +bool detach_during_send = false; void no_lock() { assert(native_haptics_lock_depth == 0); @@ -132,9 +133,11 @@ bool dispatch(uni_hid_device_t* device, uint16_t cid) { no_lock(); if (cid == device->conn.interrupt_cid) { device->notification_pending = false; + device->native_ready_cid = cid; } - permission = device->credit ? device : nullptr; - const bool consumed = haptics_experiment_on_can_send_now(device, cid); + const bool exclusive = haptics_experiment_blocks_generic(device); + const bool consumed = + native_output_scheduler_on_can_send_now(device, cid) || exclusive; if (!consumed && device->credit) { const auto it = std::find_if(generic_queue.begin(), generic_queue.end(), [device](const Generic& report) { @@ -146,9 +149,9 @@ bool dispatch(uni_hid_device_t* device, uint16_t cid) { generic_sent.push_back(report); generic_queue.erase(it); --device->outgoing_buffer.queued; + device->native_ready_cid = 0; } } - permission = nullptr; return consumed; } @@ -181,17 +184,18 @@ void reset(uint64_t at_us = 10000123) { haptics_experiment_poll(); } timers.clear(); + native_output_scheduler_prepare(); devices = {}; pcm.clear(); generic_sent.clear(); generic_queue.clear(); now_us = at_us; delivery = Delivery::kImmediate; - permission = nullptr; request_depth = max_request_depth = request_calls = send_calls = timer_calls = 0; fail_requests = fail_sends = 0; send_cost_us = request_cost_us = 0; reenter_send = false; + detach_during_send = false; for (unsigned slot = 0; slot < devices.size(); ++slot) { auto& device = devices[slot]; device.vendor_id = 0x054c; @@ -597,6 +601,48 @@ void timing_cost_reentrancy_and_wrap() { assert(wrapped.elapsed_us >= 6147000 && wrapped.elapsed_us < 6148000); assert(wrapped.max_send_gap_us <= 22000 && wrapped.sent_packets == 288); } +void synchronous_teardown_releases_admission() { + reset(); + detach_during_send = true; + assert(haptics_experiment_request(2, 0)); + haptics_experiment_poll(); + assert(snapshot().state == HapticsExperimentState::kDisconnected); + assert(!haptics_experiment_owns(&devices[0])); + assert(!native_output_scheduler_granted(&devices[0]) && timers.empty()); + const size_t sent = pcm.size(); + run_until(now_us + 100000); + assert(pcm.size() == sent); + + detach_during_send = false; + haptics_experiment_attach(0, 101, &devices[0]); + assert(haptics_experiment_request(2, 0)); + haptics_experiment_poll(); + run_until(now_us + 50000); + assert(snapshot().state == HapticsExperimentState::kRunning); + assert(snapshot().connection_generation == 101); + assert(snapshot().sent_packets >= 3); +} + + +void gameplay_led_yield_releases_admission() { + reset(); + assert(haptics_experiment_request(2, 0)); + haptics_experiment_poll(); + const uint64_t started = snapshot().start_us; + delivery = Delivery::kDeferred; + run_until(due(started, 1, snapshot().packet_frames) + 1000); + assert(devices[0].notification_pending); + devices[0].credit = false; + emit_generic(&devices[0], GenericKind::kLed); + devices[0].credit = true; + assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid)); + assert(pcm.size() == 1 && generic_queue.empty()); + assert(generic_sent.size() == 1 && generic_sent[0].kind == GenericKind::kLed); + delivery = Delivery::kImmediate; + run_until(now_us + 2000); + assert(snapshot().state == HapticsExperimentState::kRunning); + assert(pcm.size() == 2); +} void gameplay_timeline_and_lifecycle() { reset(); @@ -830,6 +876,17 @@ uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid) { return device_for_cid(cid)->remote_mtu; } +bool l2cap_can_send_packet_now(uint16_t cid) { + no_lock(); + const auto* device = device_for_cid(cid); + return device->credit && device->native_ready_cid == cid; +} + +int hci_number_free_acl_slots_for_handle(uint16_t handle) { + no_lock(); + return handle < devices.size() && devices[handle].credit ? 4 : 0; +} + uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer) { return buffer->queued == 0; } @@ -857,13 +914,15 @@ uint8_t l2cap_request_can_send_now_event(uint16_t cid) { uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) { no_lock(); auto* device = device_for_cid(cid); - assert(permission == device && device->credit); + assert(device->native_ready_cid == cid && device->credit); + assert(native_output_scheduler_granted(device)); + device->native_ready_cid = 0; assert(size == 143); ++send_calls; const uint64_t submitted_us = now_us; now_us += send_cost_us; if (reenter_send) { - assert(haptics_experiment_on_can_send_now(device, cid)); + assert(native_output_scheduler_on_can_send_now(device, cid)); const auto concurrent_snapshot = snapshot(); assert(concurrent_snapshot.state == HapticsExperimentState::kPending || concurrent_snapshot.state == HapticsExperimentState::kRunning); @@ -875,12 +934,16 @@ uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) { Pcm packet{submitted_us, cid, {}}; std::copy(data, data + size, packet.bytes.begin()); pcm.push_back(packet); + if (detach_during_send) { + haptics_experiment_detach(device); + } return ERROR_CODE_SUCCESS; } int main(int argc, char** argv) { assert(argc == 2); haptics_experiment_prepare(); + native_output_scheduler_prepare(); assert(snapshot().state == HapticsExperimentState::kIdle); nominal_run(argv[1]); stalled_deadlines(); @@ -890,6 +953,8 @@ int main(int argc, char** argv) { reconnect_and_pending_generation(); support_and_transport_errors(); timing_cost_reentrancy_and_wrap(); + synchronous_teardown_releases_admission(); + gameplay_led_yield_releases_admission(); gameplay_timeline_and_lifecycle(); gameplay_queued_start_and_command_overflow(); stateful_rumble_prepare_feedback_and_zero(); diff --git a/tests/native_output_scheduler_test.cpp b/tests/native_output_scheduler_test.cpp new file mode 100644 index 0000000..05d4aea --- /dev/null +++ b/tests/native_output_scheduler_test.cpp @@ -0,0 +1,187 @@ +#include "input/native_output_scheduler.h" +#include +#include +#include +#include +#include +#include +#include + +namespace { +uint64_t now = 1000; +int credits = 0; +bool available = true, synchronous = false, hold = false; +uint8_t request_error = 0; +unsigned resubmit = 0; +std::vector delivered; +std::vector timers; +uni_hid_device_t devices[4]; +void require(bool condition, const char* message) { + if (!condition) { std::cerr << message << '\n'; std::exit(1); } +} +bool send(uni_hid_device_t* device, uint16_t cid, uint32_t generation) { + require(native_output_scheduler_granted(device), "sender ran without its grant"); + delivered.push_back(cid); + --credits; + if (!hold) native_output_scheduler_complete(device, generation); + if (resubmit && device == &devices[0]) { + --resubmit; + native_output_scheduler_request(device, 1, 1000, false, send); + } + return true; +} +bool yield_generic(uni_hid_device_t* device, uint16_t cid, uint32_t generation) { + if (!uni_circular_buffer_is_empty(&device->outgoing_buffer)) { + native_output_scheduler_complete(device, generation); + return false; + } + return send(device, cid, generation); +} +void notify(unsigned index) { + const bool consumed = native_output_scheduler_on_can_send_now(&devices[index], devices[index].conn.interrupt_cid); + if (!consumed && devices[index].outgoing_buffer.queued) { + --devices[index].outgoing_buffer.queued; + --credits; + } +} +void request(unsigned index, uint64_t deadline, bool urgent = false) { + require(native_output_scheduler_request(&devices[index], 1, deadline, urgent, send) == 0, + "valid scheduler request rejected"); +} +void edf() { + request(0, 3000); request(1, 1000); request(2, 2000); + require(delivered.empty(), "scheduler sent without transport credits"); + credits = 3; notify(0); + require(delivered == std::vector({65, 66, 64}), "deadline ordering followed callback/slot order"); +} +void urgent() { + request(0, 1000); request(1, 90000, true); + credits = 2; notify(0); + require(delivered == std::vector({65, 64}), "stop did not precede ordinary overdue work"); +} +void reservation() { + native_output_scheduler_reserve(&devices[1], 1, 10000); + credits = 1; + request(0, 20000); + require(delivered.empty(), "future earlier stream lost the last available credit"); + request(1, 10000); + require(delivered == std::vector({65}), "reserved stream could not use its credit"); + native_output_scheduler_reserve(&devices[1], 1, UINT64_MAX); + credits = 1; notify(0); + require(delivered == std::vector({65, 64}), "retired reservation blocked remaining work"); +} +void reservation_roll_forward() { + native_output_scheduler_reserve(&devices[1], 1, 10000); + request(0, 20000); + credits = 1; + // A periodic writer announces its next interval immediately before + // registering its already-due current packet. + native_output_scheduler_reserve(&devices[1], 1, 30000); + request(1, 10000); + require(delivered == std::vector({65}), + "reservation update dispatched later work before the due request was registered"); +} + +void reservation_tie() { + native_output_scheduler_reserve(&devices[0], 1, 10000); + native_output_scheduler_reserve(&devices[1], 1, 10000); + request(0, 10000); request(1, 10000); + credits = 1; notify(0); + require(delivered.size() == 1, "equal periodic reservations deadlocked"); + credits = 1; notify(1); + require(delivered.size() == 2 && delivered[0] != delivered[1], "reservation ties starved a peer"); +} +void grant_lifetime() { + credits = 2; hold = true; + request(0, 1000); request(1, 500); + require(delivered.size() == 1 && native_output_scheduler_granted(&devices[0]), "another client stole a live grant"); + require(native_output_scheduler_on_can_send_now(&devices[0], 90), "reentrant generic event escaped live output ownership"); + hold = false; + native_output_scheduler_complete(&devices[0], 1); + require(delivered == std::vector({64, 65}), "grant completion did not unblock urgent peer"); +} +void reuse() { + request(0, 1000); + native_output_scheduler_cancel(&devices[0]); + devices[0].conn.interrupt_cid = 80; + require(native_output_scheduler_request(&devices[0], 2, 2000, false, send) == 0, "new generation rejected"); + credits = 1; + native_output_scheduler_on_can_send_now(&devices[0], 64); + require(delivered == std::vector({80}), "old generation/CID reached sender"); +} +void stale_completion() { + credits = 2; + hold = true; + request(0, 1000); + native_output_scheduler_cancel(&devices[0]); + require(native_output_scheduler_request(&devices[0], 2, 2000, false, send) == 0, + "replacement generation was rejected"); + native_output_scheduler_complete(&devices[0], 1); + require(native_output_scheduler_granted(&devices[0]), + "old completion released the replacement generation's grant"); + native_output_scheduler_complete(&devices[0], 2); + require(!native_output_scheduler_granted(&devices[0]), "current completion did not release its grant"); +} + +void bounded_reentry() { + credits = 100; resubmit = 50; + request(0, 1000); + require(delivered.size() <= 8 && !timers.empty(), "synchronous producer monopolized event loop"); + const size_t before = delivered.size(); + request(1, 1000); + require(delivered.size() > before && delivered[before] == 65, "equal deadline tie favored self-resubmitting client"); +} +void generic() { + credits = 1; synchronous = true; + devices[0].outgoing_buffer.queued = 1; + require(native_output_scheduler_request(&devices[0], 1, 1000, false, yield_generic) == 0, "generic-yield request rejected"); + require(delivered.empty() && devices[0].outgoing_buffer.queued == 0, + "generic FIFO yield stalled or emitted native output first"); + credits = 1; + require(native_output_scheduler_request(&devices[0], 1, 2000, false, yield_generic) == 0, "native retry rejected"); + require(delivered == std::vector({64}), "native output did not resume after generic FIFO drain"); +} +void error() { + available = false; request_error = 0x44; + require(native_output_scheduler_request(&devices[0], 1, 1000, false, send) == 0x44, + "immediate transport request error was hidden"); + require(delivered.empty() && !native_output_scheduler_granted(&devices[0]), "failed request granted output"); +} +} +uint64_t time_us_64() { return now; } +bool l2cap_can_send_packet_now(uint16_t) { return available && credits > 0; } +int hci_number_free_acl_slots_for_handle(uint16_t) { return credits; } +uint8_t l2cap_request_can_send_now_event(uint16_t cid) { + if (request_error) return request_error; + if (synchronous && available && credits > 0) + for (unsigned i = 0; i < 4; ++i) if (devices[i].conn.interrupt_cid == cid) notify(i); + return 0; +} +void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer, void (*callback)(btstack_timer_source_t*)) { timer->handler = callback; } +void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t delay) { timer->due_us = (now / 1000 + delay + 1) * 1000; } +void btstack_run_loop_add_timer(btstack_timer_source_t* timer) { timers.push_back(timer); } +bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer) { + const auto found = std::find(timers.begin(), timers.end(), timer); + if (found == timers.end()) return false; + timers.erase(found); return true; +} +int main(int argc, char** argv) { + require(argc == 2, "scenario required"); + for (uint16_t i = 0; i < 4; ++i) { + devices[i].conn.handle = 10 + i; + devices[i].conn.interrupt_cid = 64 + i; + } + native_output_scheduler_prepare(); + if (!std::strcmp(argv[1], "edf")) edf(); + else if (!std::strcmp(argv[1], "urgent")) urgent(); + else if (!std::strcmp(argv[1], "reservation")) reservation(); + else if (!std::strcmp(argv[1], "reservation-tie")) reservation_tie(); + else if (!std::strcmp(argv[1], "reservation-roll-forward")) reservation_roll_forward(); + else if (!std::strcmp(argv[1], "grant")) grant_lifetime(); + else if (!std::strcmp(argv[1], "reuse")) reuse(); + else if (!std::strcmp(argv[1], "stale-completion")) stale_completion(); + else if (!std::strcmp(argv[1], "bounded")) bounded_reentry(); + else if (!std::strcmp(argv[1], "generic")) generic(); + else if (!std::strcmp(argv[1], "error")) error(); + else require(false, "unknown scenario"); +} diff --git a/tests/switch_native_output_native_stubs/btstack.h b/tests/switch_native_output_native_stubs/btstack.h index 4698724..37d22fe 100644 --- a/tests/switch_native_output_native_stubs/btstack.h +++ b/tests/switch_native_output_native_stubs/btstack.h @@ -28,4 +28,6 @@ void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer, void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms); void btstack_run_loop_add_timer(btstack_timer_source_t* timer); bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer); +bool l2cap_can_send_packet_now(uint16_t cid); +int hci_number_free_acl_slots_for_handle(uint16_t handle); uint8_t l2cap_request_can_send_now_event(uint16_t cid); diff --git a/tests/switch_native_output_native_stubs/uni.h b/tests/switch_native_output_native_stubs/uni.h index b277514..214e755 100644 --- a/tests/switch_native_output_native_stubs/uni.h +++ b/tests/switch_native_output_native_stubs/uni.h @@ -10,13 +10,24 @@ using uni_play_dual_rumble_t = void (*)(uni_hid_device_t*, uint16_t, uint16_t, struct uni_report_parser_t { uni_play_dual_rumble_t play_dual_rumble = nullptr; }; +struct uni_circular_buffer_t { + unsigned queued = 0; +}; +inline bool uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer) { + return buffer->queued == 0; +} + // Only the parser boundary is faked. Native sends either enter the byte sink // synchronously or fail without retaining a packet; no hidden transmit queue. struct uni_hid_device_s { uni_report_parser_t report_parser{}; - struct { uint16_t interrupt_cid = 0; } conn; - bool connected = true; + struct { + uint16_t interrupt_cid = 0; + uint16_t handle = 0; + bool connected = true; + } conn; + uni_circular_buffer_t outgoing_buffer{}; bool info_ready = true; bool acquire_allowed = true; bool native_owned = false; diff --git a/tests/switch_native_output_test.cpp b/tests/switch_native_output_test.cpp index b29ff00..ce1503d 100644 --- a/tests/switch_native_output_test.cpp +++ b/tests/switch_native_output_test.cpp @@ -1,4 +1,5 @@ #include "input/switch_native_output.h" +#include "input/native_output_scheduler.h" #include #include @@ -72,7 +73,8 @@ void run_until(uint64_t target_us) { if (writable && !credit_event_only && !permission_requests.empty()) { const auto cid = permission_requests.front(); permission_requests.pop_front(); - switch_native_output_on_can_send_now(radio_devices[cid], cid); + require(!native_output_scheduler_on_can_send_now(radio_devices[cid], cid), + "Nintendo arbitration swallowed the generic LED FIFO event"); continue; } if (poll_requested) { @@ -86,6 +88,9 @@ void run_until(uint64_t target_us) { } continue; } + for (const auto& entry : radio_devices) + require(!native_output_scheduler_granted(entry.second), + "native owner held a grant across an event-loop wait"); const auto next = std::min_element(timers.begin(), timers.end(), [](const auto* a, const auto* b) { return a->due_us < b->due_us; }); if (next == timers.end() || (*next)->due_us > target_us) { @@ -106,7 +111,7 @@ void advance_ms(uint32_t milliseconds) { run_until(now_us + uint64_t{millisecond void conventional(uni_hid_device_t* device, uint16_t delay_ms, uint16_t duration_ms, uint8_t weak, uint8_t strong) { - require(device->connected && !device->native_owned, + require(device->conn.connected && !device->native_owned, "compatibility output ran before native ownership was released"); compatibility.push_back({device, delay_ms, duration_ms, weak, strong, wire.size(), now_us}); } @@ -311,7 +316,7 @@ void test_generation() { submit({255, 0}, true); flush(); submit(three_steps()); - old.connected = false; + old.conn.connected = false; const size_t old_count = frame_count(old); switch_native_output_detach(&old); switch_native_output_attach(0, kGeneration + 1, &replacement, identity()); @@ -544,7 +549,8 @@ void test_credit_driven_delivery() { const auto cid = permission_requests.front(); permission_requests.pop_front(); in_credit_event = true; - require(switch_native_output_on_can_send_now(&target, cid), "credit event was ignored"); + require(!native_output_scheduler_on_can_send_now(&target, cid), + "Nintendo arbitration swallowed the generic LED FIFO event"); in_credit_event = false; expect_bytes(last_frame(target), kSeed, "credit window did not submit current native state"); } @@ -595,6 +601,61 @@ void test_pending_hold_preserves_initial_latency() { diagnostics().max_latency_us == last_frame(target).submitted_us - first, "coalescing hid the initial wait or counted redundant commands as loss"); } +void test_stop_priority(bool stopping) { + auto quiet = device(); + auto periodic = device(); + switch_native_output_configure(persisted({identity(), identity(2)}), 1); + switch_native_output_attach(0, kGeneration, &quiet, identity()); + switch_native_output_attach(1, 21, &periodic, identity(2)); + if (stopping) { + submit({255, 0}, true); + flush(); + } + writable = false; + submit({0, 255}, true, 1, 21); + flush(); + advance_ms(1); + submit({}, true); // A true stop only when the first controller was active. + flush(); + const size_t before = wire.size(); + writable = true; + advance_ms(1); + require(wire.size() >= before + 2, + "one controller's grant prevented the other pending output"); + require(wire[before].device == (stopping ? &quiet : &periodic), + stopping ? "real stop failed to preempt an older vibration deadline" + : "already-silent host report hijacked urgent stop priority"); + if (stopping) require(is_neutral(wire[before]), "urgent stop was not physical neutral"); + expect_state(periodic, {}, {64, 64, 0, 17867}); + require(!native_output_scheduler_granted(&quiet) && + !native_output_scheduler_granted(&periodic), + "completed synchronous callbacks leaked a scheduler grant"); +} + +void test_reused_device_pending_credit() { + auto target = device(); + attach_approved(target); + writable = false; + submit({255, 0}, true); + flush(); + const uint16_t old_cid = target.conn.interrupt_cid; + switch_native_output_detach(&target); + target.native_owned = false; // Parser teardown, before its object is reused. + switch_native_output_attach(0, kGeneration + 1, &target, identity()); + require(target.conn.interrupt_cid == old_cid, "fixture did not reuse the physical CID"); + const size_t before = wire.size(); + writable = true; + advance_ms(1); + require(wire.size() == before + 1 && is_neutral(wire.back()), + "stale credit replayed output across a reused device generation"); + submit({0, 255}, true, 0, kGeneration + 1); + flush(); + expect_state(target, {}, {64, 64, 0, 17867}); + require(diagnostics().completed_commands == 1 && + !native_output_scheduler_granted(&target), + "reconnect lost its new command or retained a stale grant"); +} + } // namespace @@ -637,9 +698,16 @@ bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer) { return true; } +bool l2cap_can_send_packet_now(uint16_t) { + return writable && (!credit_event_only || in_credit_event); +} +int hci_number_free_acl_slots_for_handle(uint16_t) { + return writable ? 8 : 0; +} + uint8_t l2cap_request_can_send_now_event(uint16_t cid) { if (writable && !credit_event_only) - switch_native_output_on_can_send_now(radio_devices[cid], cid); + native_output_scheduler_on_can_send_now(radio_devices[cid], cid); else if (std::find(permission_requests.begin(), permission_requests.end(), cid) == permission_requests.end()) permission_requests.push_back(cid); @@ -650,6 +718,7 @@ bool uni_hid_parser_switch_native_info(uni_hid_device_t* target, uint8_t* type, uint8_t* firmware_hi, uint8_t* firmware_lo) { if (!target || !target->info_ready) return false; if (!target->conn.interrupt_cid) target->conn.interrupt_cid = next_cid++; + target->conn.handle = target->conn.interrupt_cid; radio_devices[target->conn.interrupt_cid] = target; if (type) *type = target->controller_type; if (firmware_hi) *firmware_hi = 5; @@ -657,15 +726,17 @@ bool uni_hid_parser_switch_native_info(uni_hid_device_t* target, uint8_t* type, return true; } bool uni_hid_parser_switch_native_acquire(uni_hid_device_t* target) { - if (!target->connected || !target->info_ready || !target->acquire_allowed) return false; + if (!target->conn.connected || !target->info_ready || !target->acquire_allowed) return false; require(!target->native_owned, "parser acquired twice without release"); target->native_owned = true; ++target->acquisitions; return true; } bool uni_hid_parser_switch_native_send(uni_hid_device_t* target, const uint8_t rumble[8]) { - require(target && target->connected && target->native_owned, + require(target && target->conn.connected && target->native_owned, "native send reached disconnected or unowned parser"); + require(native_output_scheduler_granted(target), + "native parser send bypassed the shared scheduler grant"); require(!credit_event_only || in_credit_event, "native sender polled outside the notified credit window"); bool accepted = writable; @@ -680,7 +751,7 @@ bool uni_hid_parser_switch_native_send(uni_hid_device_t* target, const uint8_t r return true; } void uni_hid_parser_switch_native_release(uni_hid_device_t* target) { - require(target && target->connected && target->native_owned, + require(target && target->conn.connected && target->native_owned, "parser released while disconnected or already unowned"); target->native_owned = false; ++target->releases; @@ -689,6 +760,7 @@ void uni_hid_parser_switch_native_release(uni_hid_device_t* target) { int main(int argc, char** argv) { require(argc == 2, "one regression scenario is required"); scenario = argv[1]; + native_output_scheduler_prepare(); switch_native_output_prepare(); if (std::strcmp(scenario, "approval") == 0) test_approval(); else if (std::strcmp(scenario, "model-gate") == 0) test_model_gate(); @@ -707,6 +779,9 @@ int main(int argc, char** argv) { else if (std::strcmp(scenario, "credit-driven") == 0) test_credit_driven_delivery(); else if (std::strcmp(scenario, "held-state") == 0) test_held_state_coalescing(); else if (std::strcmp(scenario, "pending-hold") == 0) test_pending_hold_preserves_initial_latency(); + else if (std::strcmp(scenario, "silent-priority") == 0) test_stop_priority(false); + else if (std::strcmp(scenario, "stop-priority") == 0) test_stop_priority(true); + else if (std::strcmp(scenario, "reused-credit") == 0) test_reused_device_pending_credit(); else require(false, "unknown regression scenario"); return 0; } diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 6af8a0d..ed25723 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -44,6 +44,13 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "-DSWITCH_PICO_HD_RUMBLE=1", "-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=0", str(root / "src" / "firmware" / "input" / "haptics_experiment.cpp"), + str( + root + / "src" + / "firmware" + / "input" + / "native_output_scheduler.cpp" + ), str( root / "src" diff --git a/tests/test_haptics_experiment_native.py b/tests/test_haptics_experiment_native.py index 4d1b28a..5057aa4 100644 --- a/tests/test_haptics_experiment_native.py +++ b/tests/test_haptics_experiment_native.py @@ -41,6 +41,7 @@ def test_haptics_experiment_native( 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" / "native_output_scheduler.cpp"), str(root / "src" / "firmware" / "input" / "switch_hd_rumble_synth.cpp"), str(root / "src" / "firmware" / "usb" / "switch" / "switch_haptics.cpp"), "-o", diff --git a/tests/test_native_output_scheduler_native.py b/tests/test_native_output_scheduler_native.py new file mode 100644 index 0000000..f8cdb59 --- /dev/null +++ b/tests/test_native_output_scheduler_native.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_native_output_scheduler_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 / "native_output_scheduler_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root / 'tests' / 'switch_native_output_native_stubs'}", + f"-I{root / 'tests' / 'bluepad32_native_stubs'}", + f"-I{root / 'src' / 'firmware'}", + str(root / "src" / "firmware" / "input" / "native_output_scheduler.cpp"), + str(root / "tests" / "native_output_scheduler_test.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + for scenario in ( + "edf", + "urgent", + "reservation", + "reservation-tie", + "reservation-roll-forward", + "grant", + "reuse", + "stale-completion", + "bounded", + "generic", + "error", + ): + subprocess.run([str(executable), scenario], check=True, cwd=root) diff --git a/tests/test_switch_native_output_native.py b/tests/test_switch_native_output_native.py index 9f790f0..4ec0565 100644 --- a/tests/test_switch_native_output_native.py +++ b/tests/test_switch_native_output_native.py @@ -23,6 +23,7 @@ def test_switch_native_output_native(tmp_path: Path) -> None: f"-I{root / 'tests' / 'bluepad32_native_stubs'}", f"-I{firmware}", str(firmware / "input" / "switch_native_output.cpp"), + str(firmware / "input" / "native_output_scheduler.cpp"), str(firmware / "usb" / "switch" / "switch_native_haptics.cpp"), str(firmware / "usb" / "switch" / "switch_haptics.cpp"), str(firmware / "configuration" / "adapter_configuration.cpp"), @@ -54,5 +55,8 @@ def test_switch_native_output_native(tmp_path: Path) -> None: "credit-driven", "held-state", "pending-hold", + "silent-priority", + "stop-priority", + "reused-credit", ): subprocess.run([str(executable), scenario], check=True, cwd=root)