diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index e17139f..df9c6f1 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -732,6 +732,23 @@ Set B delivery evidence: qualification remain separate from those software results. - Full operation and limitations: [README.md](README.md#switch-2-controller-input). +Switch 2 native HD output now preserves decoded stereo frequency/amplitude +and up to three subframes, using microphone-verified 10-bit frequency fields +and `0x50/0x60/0x70` sample-count headers. A 90-packet timing run measured +approximately 5.3 ms per frame; the sender guards 6/11/16 ms for complete +batches. Two bounded queue stages retain receipt timestamps; obsolete work +is counted and discarded rather than replayed or allowed to starve fresh work. +Keepalives/feedback resumption retain only the current final sample. + +The final Pro-only native run sustained 512 changing one-subframe commands at +125.14 Hz with zero queue drops. Stereo/sweep/three-subframe patterns also +recorded zero drops. Deliberate three-subframe saturation at 125.11 Hz +discarded/superseded 69 output-stage commands out of 128 host updates and +recovered to a clean stop. No mixed-radio lossless claim is made. +Verification is now 289 passing tests and all five firmware builds; all +40 stored profiles and metadata survived. See the +[native HD contract and measurements](README.md#switch-2-native-hd-rumble). + ### Native Switch-family HD rumble — Implemented, qualification incomplete Standalone agent handoff: [SWITCH_FAMILY_HD_RUMBLE_PLAN.md](SWITCH_FAMILY_HD_RUMBLE_PLAN.md). diff --git a/CMakeLists.txt b/CMakeLists.txt index 275f47f..ccc9b2c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -227,7 +227,8 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") ) target_sources(bluepad32 PRIVATE ${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_hid_parser_switch2.c - ${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_switch2_pairing.c) + ${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_switch2_pairing.c + ${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_switch2_haptics.c) endif() # Firmware sources live under one include root and are grouped by responsibility. diff --git a/README.md b/README.md index 6e11915..36b7bdc 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,12 @@ build services one CYW43 packet per poll and explicitly reschedules remaining input. Packet-level ring reads and bounded incoming-credit batching reduce bus work without disabling flow control. `haptics-experiment profile --json` adds transport timings, clock/voltage settings and packet-size diagnostics. +Switch 2 native output adds separate ingress and output-stage drop counters. +The management response extends from 32 to 40 bytes; the updated host tool +still reads older 32-byte responses and treats their missing counters as +unreported, not zero. These count firmware queue discards/rejections, not +physical actuator-delivery receipts. + ### Per-controller profiles @@ -333,15 +339,74 @@ The AIO firmware implements the proprietary BLE protocol for Nintendo `057E:2069 - **Pairing:** fresh SYNC pairing requires the existing bounded pairing window. A directed reconnect must target this adapter's Bluetooth address and match its persistent application-level authorization. These links are unencrypted and are **not authenticated SMP bonds**. No global Bluetooth security downgrade is made; automatic SMP requests for these devices fail closed while other controllers retain their existing policy. Public/static addresses can own profiles; transient private addresses are not promoted to persistent identities. - **Joy-Con ownership:** an opposite ready half automatically joins a solo half. Either connection order works; the first-ready player slot is retained, with the left controller's identity/profile owning the pair. The right half supplies motion. A disconnected half's inputs and pending effects are removed immediately; the surviving half returns to sideways solo operation and its own profile identity. A lone half has rotated controls and SL/SR shoulders. Two pairs exhaust the four physical Bluetooth connections. Pairing does not reopen discovery outside the existing connection policy. - **Protocol:** service, characteristic and CCCD UUIDs are discovered rather than trusting fixed ATT handles. Setup requires matching acknowledgements, reads user/factory stick calibration and gyro bias, and rejects malformed/failed transactions. Motion is normalized to the existing SDL-oriented units; sensor clock/range classification and physical axis accuracy still need wider model qualification. -- **Rumble:** strong/weak amplitudes feed fixed low/high carriers, repeated in three-frame HOLD packets on an approximately 13 ms cadence. Keepalives retain active effects instead of silencing them; finite effects expire, XInput held effects persist until replaced/stopped, and teardown cancels output. Pro output mirrors the same two-band effect to both actuators. This does **not** preserve Nintendo HD substeps or independent left/right HD effects and does not use the original Switch-native opt-in backend. +- **Rumble:** Switch HD commands now retain independent left/right frequency/amplitude fields and up to three ordered subframes through the native Switch 2 encoder and bounded queues described below. XInput and local feedback retain their conventional fixed-carrier behavior. This is separate from the original Switch-native opt-in backend. - **Not implemented:** Joy-Con mouse output, native GameChat signaling, NFC/IR and Switch 2 NSO GameCube support. C and back/rail inputs can instead be remapped to controls the selected USB mode supports. This is a scoped reimplementation informed by [Bluepad32 PR #219](https://github.com/ricardoquesada/bluepad32/pull/219), reviewed at `9c95e43a87d3bd8a68565da0836d8a758bd8d8af`, not a wholesale fork import. Protocol references: [ndeadly's research](https://github.com/ndeadly/switch2_controller_research), [Nadeflore](https://github.com/Nadeflore/switch2-controllers), [Switch2Connect](https://github.com/TommyWabg/Switch2Connect), and [SDL's Switch 2 sensor implementation](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch2.c). -**Verification:** 287 tests passed; AIO, XInput/feasibility, HD-rumble, haptics and UART firmware variants built. Native protocol tests use the SDK's real BTstack types/accessors and cover discovery, acknowledgement ordering, calibration, persistence, output deadlines and teardown. Lifecycle tests cover both Joy-Con connection orders, multiple pairs, detach/replacement and pairing-policy isolation. The editor's extra/Shift mappings were exercised in Chromium. +**Verification:** 289 tests passed; AIO, XInput/feasibility, HD-rumble, haptics and UART firmware variants built. Native protocol tests use the SDK's real BTstack types/accessors and cover discovery, acknowledgement ordering, calibration, persistence, output deadlines and teardown. Lifecycle tests cover both Joy-Con connection orders, multiple pairs, detach/replacement and pairing-policy isolation. The editor's extra/Shift mappings were exercised in Chromium. On the flashed Pico, a real Switch 2 Pro (`3C:A9:AB:65:73:12`) completed setup, appeared in persistent pairing/profile inventories, and delivered live sticks, accelerometer, gyro and independent C/GL/GR presses. A 100-report USB rumble exercise retained its connection while 3,033 controller reports arrived. Schema-7 extra mappings were written/read and restored on hardware; all 32 pre-existing profiles, metadata and active selections were compared against a pre-flash backup and preserved, with adapter configuration generation 13 / CRC `3af5ee18` unchanged. Physical rumble feel, Joy-Con 2 pair behavior, long-duration reconnect and mixed-controller transport remain hardware qualification items. Use schema-7-capable firmware after saving expanded profiles. +### Switch 2 native HD rumble + +Switch-mode host commands use decoded HD parameters, not the compatibility +strong/weak peak values. Pro output preserves two independent actuators; +paired Joy-Con 2 output routes each source side to its physical half. +A solo Joy-Con uses the louder source independently for each band, retaining +that band's frequency, with left winning ties and shorter sequences holding +their final sample. Profiles scale amplitudes before this conversion. + +Physical microphone characterization on the Pro Controller established: + +- Each five-byte sample contains two **10-bit frequency + 10-bit amplitude** + fields. The measured frequency model is + `Hz ~= 10 * 2^((code - 1) / 96)`. + Original low/high indices map to `193 + 3*index` / `289 + 3*index`; + index 64 therefore produces codes 385/481 (160/320 Hz). +- Block headers `0x50`, `0x60`, `0x70`, plus the four-bit sequence counter, + select **one, two or three** valid samples. Unused slots are zeroed. + Filling three slots under `0x50` does not play the later slots. +- A randomized 90-packet run using the other actuator as an acoustic timing + reference measured **5.27 ms/frame, ±0.16 ms statistical 95% interval**. + Acoustic/threshold systematic error is not included. The sender uses + conservative **6/11/16 ms** submission guards, not a claim of exact onset. + +Linear Q0.15 amplitudes use SDL's conservative `29000/65535` envelope, producing +native codes 0–453. This preserves a linear input curve but is not calibrated +physical-force equivalence; it can feel different from compatibility rumble. +Source frequency indices are bounded to 1–127. + +Each logical slot has a 16-command cross-core ingress FIFO; each physical +Switch 2 controller has a 16-command transport FIFO. Commands keep their +original receipt time and connection/output generation. Native commands expire +after 50 ms; a batch that cannot fit its complete playback guard before that +deadline is discarded rather than started halfway stale. Expiring unplayed +history does not interrupt current playback or force a useless HOLD ahead of +fresh work. Consecutive identical one-sample holds may refresh a pending +command; multi-sample sequences are never coalesced. + +Stops flush older host work, including under backpressure. Local feedback owns +a separate bounded override while host state advances underneath it; resuming +uses the current valid final sample, not a replay of masked history. +Keepalives likewise send only the final sample with count 1. Pending ATT +write-request buffers remain immutable; late completion cannot resurrect an +old epoch after stop, reconnect or Joy-Con topology change. + +Final single-Pro hardware runs: + +| Workload | Result | +|---|---| +| Stereo, frequency sweep, and three-subframe patterns (195 USB reports) | Zero ingress/output drops; 1,193 input reports continued | +| 512 changing one-subframe commands at 125.14 Hz | Zero ingress/output drops; clean stop | +| 128 changing three-subframe commands at 125.11 Hz | Zero ingress drops; 69 output-stage commands discarded/superseded; clean stop and empty ingress | + +Three-subframe commands at 125 Hz exceed the native playback budget. These +results do **not** establish lossless arbitrary workloads or mixed-controller +radio performance. Joy-Con hardware and perceptual equivalence remain separate +qualification items. All 40 profiles, names, active selections and adapter +configuration were preserved during this upgrade. + ### Rumble per controller Commands remain bound to a USB slot and Bluetooth connection generation. Compatibility output uses a latest-value mailbox; native output keeps a bounded timestamped command history instead of collapsing substeps. @@ -805,15 +870,15 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file: | Resource | Used or reserved | Device capacity | |---|---:|---:| -| Executable flash image | 806,872 bytes | 4 MiB | +| Executable flash image | 815,192 bytes | 4 MiB | | Indexed profile arenas | 256 KiB | 4 MiB flash | | Adapter configuration | 8 KiB | 4 MiB flash | | BTstack bonds and Switch 2 application authorizations | 8 KiB | 4 MiB flash | | RP2350 terminal sector | 4 KiB | 4 MiB flash | -| Allocated/reserved SRAM, including heap and stacks | 141,984 bytes | 520 KiB | +| Allocated/reserved SRAM, including heap and stacks | 150,824 bytes | 520 KiB | -The executable plus persistent reservations consume 1,089,496 bytes of flash, -leaving 3,104,808 bytes. Allocated SRAM sections leave 390,496 bytes of link-time +The executable plus persistent reservations consume 1,097,816 bytes of flash, +leaving 3,096,488 bytes. Allocated SRAM sections leave 381,656 bytes of link-time headroom; this is not a runtime heap high-water measurement. Core 0 has a 4 KiB stack, and Core 1 uses a dedicated 16 KiB stack in main SRAM for nested catalog migration/compaction rather than overflowing its 4 KiB scratch bank. diff --git a/bluepad32_config/parser/uni_hid_parser_switch2.c b/bluepad32_config/parser/uni_hid_parser_switch2.c index 5cab1d5..8d95f75 100644 --- a/bluepad32_config/parser/uni_hid_parser_switch2.c +++ b/bluepad32_config/parser/uni_hid_parser_switch2.c @@ -8,6 +8,7 @@ #include "parser/uni_hid_parser_switch2.h" #include +#include #include #include @@ -20,6 +21,7 @@ #define SW2_TIMEOUT_MS 2000 #define SW2_OUTPUT_INTERVAL_MS 13 +#define SW2_HAPTICS_CAPACITY 16 #define SW2_REPORT_SIZE 63 #define SW2_ACK 0x78 #define SW2_CCCD_UUID 0x2902 @@ -61,6 +63,16 @@ typedef struct { uint16_t positive[2]; uint16_t negative[2]; } sw2_stick_t; +typedef struct { + uni_switch2_haptics_frame_t frame; + uint32_t expires, serial; + bool held, native; +} sw2_host_command_t; +typedef struct { + uint8_t sample[5]; + uint32_t expires; + bool valid, held; +} sw2_host_side_t; typedef struct { uni_hid_device_t* device; bd_addr_t address; @@ -92,6 +104,15 @@ typedef struct { uint8_t rumble_id, weak, strong; bool rumble_scheduled, rumble_held; uint32_t rumble_start, rumble_end; + sw2_host_command_t host_queue[SW2_HAPTICS_CAPACITY]; + sw2_host_side_t host_sides[2]; + uint8_t host_head, host_count; + uint32_t host_serial, haptics_epoch, output_revision; + uint32_t pending_serial, pending_epoch, pending_revision; + uint32_t playback_until; + uint8_t pending_guard_ms; + bool pending_host, pending_barrier, barrier_pending, output_urgent; + bool feedback_active, playback_guard; uint32_t sensor_start, sensor_host_start, sensor_last; uint8_t sensor_warmup; int32_t gyro_full_scale; @@ -100,11 +121,14 @@ typedef struct { // Separate bounded storage: never squeeze transport resources into parser_data // (256 bytes). Retired buffers are not reused until their old BLE link is gone. static sw2_instance_t sw2_instances[CONFIG_BLUEPAD32_MAX_DEVICES]; +static atomic_uint_least32_t sw2_haptics_drops; static void sw2_gatt_handler(uint8_t packet_type, uint16_t channel, uint8_t* packet, uint16_t size); static void sw2_output_tick(btstack_timer_source_t* timer); static void sw2_continue(sw2_instance_t* ins); static void sw2_complete_command(sw2_instance_t* ins); +static void sw2_rumble_complete(sw2_instance_t* ins); +static void sw2_discard_host(sw2_instance_t* ins); static bool sw2_product(uint16_t pid) { return pid == UNI_SW2_PRO_PID || pid == UNI_SW2_JOYCON_L_PID || pid == UNI_SW2_JOYCON_R_PID; @@ -149,6 +173,8 @@ void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) { if (ins->input_listening) gatt_client_stop_listening_for_characteristic_value_updates(&ins->input_listener); ins->response_listening = ins->input_listening = false; + sw2_discard_host(ins); + ++ins->haptics_epoch; ins->command_pending = ins->rumble_scheduled = false; ins->extra_buttons = 0; ins->state = SW2_OFF; @@ -470,11 +496,13 @@ static void sw2_query_complete(sw2_instance_t* ins, uint8_t status) { return; } if (query == SW2_QUERY_RUMBLE) { - ++ins->rumble_id; + sw2_rumble_complete(ins); // A command queued behind this write has its own timeout already. if (!ins->command_pending) sw2_disarm_timeout(ins); sw2_try_command(ins); + if (sw2_live(ins)) + sw2_schedule_output(ins, 1); return; } sw2_disarm_timeout(ins); @@ -881,37 +909,301 @@ void uni_hid_parser_switch2_set_player_leds(uni_hid_device_t* d, uint8_t leds) { sw2_continue(ins); } -static void sw2_rumble_block(uint8_t* out, uint8_t id, uint8_t weak, uint8_t strong) { - // Three consecutive equal frames form a safe sustained HOLD. Weak controls - // the high-frequency amplitude, strong the low-frequency amplitude. - uint64_t frame = 0x0e1u | ((uint64_t)strong * 4 << 10) | ((uint64_t)0x1e1 << 20) | - ((uint64_t)weak * 4 << 30); - out[0] = 0x50 | (id & 15); - for (unsigned i = 0; i < 5; ++i) - out[1 + i] = (uint8_t)(frame >> (8 * i)); - memcpy(out + 6, out + 1, 5); - memcpy(out + 11, out + 1, 5); +uint32_t uni_hid_parser_switch2_haptics_dropped(void) { + return atomic_load_explicit(&sw2_haptics_drops, memory_order_relaxed); } -static void sw2_send_rumble(sw2_instance_t* ins, uint32_t now) { - if (ins->query != SW2_QUERY_NONE || ins->command_pending) - return; - uint8_t weak = 0, strong = 0; - if (ins->rumble_scheduled) { - if (!ins->rumble_held && (int32_t)(now - ins->rumble_end) >= 0) - ins->rumble_scheduled = false; - else if ((int32_t)(now - ins->rumble_start) >= 0) { - weak = ins->weak; - strong = ins->strong; +static void sw2_count_drops(unsigned count) { + atomic_fetch_add_explicit(&sw2_haptics_drops, count, memory_order_relaxed); +} + +static bool sw2_due(uint32_t now, uint32_t deadline) { + return (int32_t)(now - deadline) >= 0; +} + +static unsigned sw2_output_sides(const sw2_instance_t* ins) { + return ins->device->product_id == UNI_SW2_PRO_PID ? 2 : 1; +} + +static void sw2_pop_host(sw2_instance_t* ins) { + ins->host_head = (ins->host_head + 1) % SW2_HAPTICS_CAPACITY; + --ins->host_count; +} + +static void sw2_discard_host(sw2_instance_t* ins) { + sw2_count_drops(ins->host_count); + ins->host_head = ins->host_count = 0; + memset(ins->host_sides, 0, sizeof(ins->host_sides)); +} + +static void sw2_retain_host(sw2_instance_t* ins, const sw2_host_command_t* command) { + for (unsigned i = 0; i < sw2_output_sides(ins); ++i) { + const uni_switch2_haptics_side_t* side = &command->frame.sides[i]; + if (!side->count) + continue; + sw2_host_side_t* retained = &ins->host_sides[i]; + memcpy(retained->sample, side->samples[side->count - 1], sizeof(retained->sample)); + retained->expires = command->expires; + retained->held = command->held; + retained->valid = true; + } +} + +static bool sw2_local_active(const sw2_instance_t* ins, uint32_t now) { + return ins->rumble_scheduled && sw2_due(now, ins->rumble_start) && + (ins->rumble_held || !sw2_due(now, ins->rumble_end)); +} + +// Logical host time keeps advancing even when ATT or the local overlay owns +// the physical output. Masked sequences become final holds, never a replay log. +static void sw2_update_haptics(sw2_instance_t* ins, uint32_t now) { + bool active = sw2_local_active(ins, now); + bool masked = active || ins->feedback_active; + while (ins->host_count) { + sw2_host_command_t* command = &ins->host_queue[ins->host_head]; + if (!command->held && sw2_due(now, command->expires)) { + sw2_count_drops(1); + sw2_pop_host(ins); + // Unplayed history is not a reason to interrupt the current packet. + // A matching in-flight packet can still complete after its expiry. + if (!active && ins->pending_host && + ins->pending_epoch == ins->haptics_epoch && + ins->pending_serial == command->serial) { + ins->output_urgent = true; + ++ins->output_revision; + } + } else if (masked) { + sw2_retain_host(ins, command); + sw2_pop_host(ins); + } else { + break; } } - ins->rumble_data[0] = 0; - sw2_rumble_block(ins->rumble_data + 1, ins->rumble_id, weak, strong); - uint16_t length = 17; - if (ins->device->product_id == UNI_SW2_PRO_PID) { - memcpy(ins->rumble_data + 17, ins->rumble_data + 1, 16); - length = 33; + if (masked) + ins->barrier_pending = false; + for (unsigned i = 0; i < sw2_output_sides(ins); ++i) { + sw2_host_side_t* side = &ins->host_sides[i]; + if (side->valid && !side->held && sw2_due(now, side->expires)) { + side->valid = false; + if (!active) { + ins->output_urgent = true; + ++ins->output_revision; + } + } } + if (ins->feedback_active != active) { + if (!active) + ins->output_urgent = true; // Resume only the current per-side hold. + ++ins->output_revision; + } + ins->feedback_active = active; + if (ins->rumble_scheduled && !ins->rumble_held && sw2_due(now, ins->rumble_end)) + ins->rumble_scheduled = false; +} + +static void sw2_compat_side(uni_switch2_haptics_side_t* side, uint8_t weak, uint8_t strong) { + // Preserve conventional/local frequency and strength, but advertise exactly + // one sample. The other ten bytes are padding, not repeated substeps. + uint64_t frame = 0x0e1u | ((uint64_t)strong * 4 << 10) | ((uint64_t)0x1e1 << 20) | + ((uint64_t)weak * 4 << 30); + memset(side, 0, sizeof(*side)); + side->count = 1; + for (unsigned i = 0; i < 5; ++i) + side->samples[0][i] = (uint8_t)(frame >> (8 * i)); +} + +static void sw2_host_hold(const sw2_instance_t* ins, uni_switch2_haptics_frame_t* frame) { + uni_switch2_haptics_silence(frame); + for (unsigned i = 0; i < sw2_output_sides(ins); ++i) { + if (ins->host_sides[i].valid) + memcpy(frame->sides[i].samples[0], ins->host_sides[i].sample, 5); + } +} + +static void sw2_host_stop(sw2_instance_t* ins) { + sw2_update_haptics(ins, btstack_run_loop_get_time_ms()); + sw2_discard_host(ins); + ++ins->haptics_epoch; + ++ins->output_revision; + ins->barrier_pending = true; + ins->output_urgent = true; + sw2_schedule_output(ins, 1); +} + +static bool sw2_physical_stop(const sw2_instance_t* ins, const uni_switch2_haptics_frame_t* frame) { + for (unsigned i = 0; i < sw2_output_sides(ins); ++i) { + const uni_switch2_haptics_side_t* side = &frame->sides[i]; + if (!side->count) + return false; + for (unsigned j = 0; j < side->count; ++j) { + const uint8_t* sample = side->samples[j]; + // Amplitudes occupy bits10..19 and30..39 of each 40-bit sample. + if ((sample[1] & 0xfc) || (sample[2] & 0x0f) || (sample[3] & 0xc0) || sample[4]) + return false; + } + } + return true; +} + +static bool sw2_same_hold(const uni_switch2_haptics_frame_t* a, const uni_switch2_haptics_frame_t* b) { + for (unsigned i = 0; i < 2; ++i) { + if (a->sides[i].count > 1 || a->sides[i].count != b->sides[i].count) + return false; + if (a->sides[i].count && memcmp(a->sides[i].samples[0], b->sides[i].samples[0], 5) != 0) + return false; + } + return true; +} + +static bool sw2_queue_host(sw2_instance_t* ins, const uni_switch2_haptics_frame_t* frame, + uint32_t received_ms, uint16_t duration_ms, bool native) { + uint32_t now = btstack_run_loop_get_time_ms(); + sw2_update_haptics(ins, now); + bool held = duration_ms == UINT16_MAX; + uint32_t lifetime = duration_ms < UNI_SWITCH2_HAPTICS_WATCHDOG_MS ? + duration_ms : UNI_SWITCH2_HAPTICS_WATCHDOG_MS; + uint32_t expires = received_ms + lifetime; + if (!held && sw2_due(now, expires)) { + sw2_count_drops(1); + return true; + } + if (ins->host_count) { + unsigned previous = (ins->host_head + ins->host_count - 1) % SW2_HAPTICS_CAPACITY; + sw2_host_command_t* command = &ins->host_queue[previous]; + if (command->native == native && sw2_same_hold(&command->frame, frame)) { + // Same ordered hold, new source lifetime. Keep its serial and wire + // bytes intact even when ATT is still borrowing this queue head. + command->expires = expires; + command->held = held; + sw2_schedule_output(ins, 1); + return true; + } + } + if (ins->host_count == SW2_HAPTICS_CAPACITY) + return false; + unsigned tail = (ins->host_head + ins->host_count) % SW2_HAPTICS_CAPACITY; + ins->host_queue[tail] = (sw2_host_command_t){ + .frame = *frame, .expires = expires, .serial = ++ins->host_serial, .held = held, .native = native, + }; + ++ins->host_count; + sw2_schedule_output(ins, 1); + return true; +} + +bool uni_hid_parser_switch2_queue_haptics(uni_hid_device_t* d, + const uni_switch2_haptics_frame_t* frame, uint32_t received_ms) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_READY) + return false; + if (!uni_switch2_haptics_valid(frame) || + (sw2_output_sides(ins) == 1 && !frame->sides[0].count)) { + sw2_count_drops(1); + return true; + } + if (sw2_physical_stop(ins, frame)) { + sw2_host_stop(ins); // A stop is a barrier, including when full or stale. + return true; + } + return sw2_queue_host(ins, frame, received_ms, UNI_SWITCH2_HAPTICS_WATCHDOG_MS, true); +} + +bool uni_hid_parser_switch2_queue_rumble(uni_hid_device_t* d, uint8_t weak, uint8_t strong, + uint16_t duration_ms, uint32_t received_ms) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_READY) + return false; + if ((!weak && !strong) || !duration_ms) { + sw2_host_stop(ins); + return true; + } + uni_switch2_haptics_frame_t frame; + sw2_compat_side(&frame.sides[0], weak, strong); + frame.sides[1] = frame.sides[0]; + return sw2_queue_host(ins, &frame, received_ms, duration_ms, false); +} + +void uni_hid_parser_switch2_reset_haptics(uni_hid_device_t* d) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins) + return; + sw2_discard_host(ins); + ++ins->haptics_epoch; + ++ins->output_revision; + ins->rumble_scheduled = ins->feedback_active = false; + ins->barrier_pending = ins->output_urgent = true; + // Do not touch rumble_data or pending metadata: ATT can still borrow them. + if (ins->state == SW2_READY) + sw2_schedule_output(ins, 1); +} + +static void sw2_rumble_complete(sw2_instance_t* ins) { + uint32_t now = btstack_run_loop_get_time_ms(); + ++ins->rumble_id; // Only a successful write consumes the physical sequence. + ins->playback_until = now + ins->pending_guard_ms; + ins->playback_guard = true; + sw2_update_haptics(ins, now); + if (ins->pending_epoch != ins->haptics_epoch) + return; + if (ins->pending_host && ins->host_count) { + sw2_host_command_t* command = &ins->host_queue[ins->host_head]; + if (command->serial == ins->pending_serial) { + sw2_retain_host(ins, command); + sw2_pop_host(ins); + } + } + if (ins->pending_barrier) + ins->barrier_pending = false; + if (ins->pending_revision == ins->output_revision) + ins->output_urgent = false; +} + +static bool sw2_send_rumble(sw2_instance_t* ins, uint32_t now) { + // Never overwrite the persistent packet while a write request borrows it. + if (ins->query != SW2_QUERY_NONE || ins->command_pending) + return false; + if (ins->playback_guard && !sw2_due(now, ins->playback_until) && !ins->output_urgent) + return false; + uni_switch2_haptics_frame_t frame; + ins->pending_host = false; + ins->pending_guard_ms = 6; + unsigned sides = sw2_output_sides(ins); + if (ins->feedback_active) { + sw2_compat_side(&frame.sides[0], ins->weak, ins->strong); + frame.sides[1] = frame.sides[0]; + } else { + sw2_host_hold(ins, &frame); + while (!ins->barrier_pending && ins->host_count) { + const sw2_host_command_t* command = &ins->host_queue[ins->host_head]; + unsigned count = 1; + for (unsigned i = 0; i < sides; ++i) + if (command->frame.sides[i].count > count) count = command->frame.sides[i].count; + uint8_t guard_ms = (count * 16 + 2) / 3; + // Do not begin a native sequence that its original watchdog would + // cut off halfway through. Skip it and keep servicing fresh work. + if (command->native && !command->held && + (int32_t)(command->expires - now) < guard_ms) { + sw2_count_drops(1); + sw2_pop_host(ins); + continue; + } + for (unsigned i = 0; i < sides; ++i) { + if (command->frame.sides[i].count) + frame.sides[i] = command->frame.sides[i]; + } + ins->pending_guard_ms = guard_ms; + ins->pending_host = true; + ins->pending_serial = command->serial; + break; + } + } + ins->pending_epoch = ins->haptics_epoch; + ins->pending_revision = ins->output_revision; + ins->pending_barrier = ins->barrier_pending; + ins->rumble_data[0] = 0; + for (unsigned i = 0; i < sides; ++i) { + uni_switch2_haptics_write_block(ins->rumble_data + 1 + 16 * i, &frame.sides[i], ins->rumble_id); + } + uint16_t length = 1 + 16 * sides; bool no_response = (ins->rumble.properties & ATT_PROPERTY_WRITE_WITHOUT_RESPONSE) != 0; uint8_t status; if (no_response) { @@ -919,21 +1211,26 @@ static void sw2_send_rumble(sw2_instance_t* ins, uint32_t now) { length, ins->rumble_data); } else { ins->query = SW2_QUERY_RUMBLE; - if (!ins->command_pending) - sw2_arm_timeout(ins); + sw2_arm_timeout(ins); status = gatt_client_write_value_of_characteristic(sw2_gatt_handler, ins->handle, ins->rumble.value_handle, length, ins->rumble_data); } if (status == ERROR_CODE_SUCCESS) { if (no_response) - ++ins->rumble_id; - } else { - ins->query = SW2_QUERY_NONE; - if (!ins->command_pending) - sw2_disarm_timeout(ins); - if (!sw2_transient_write_error(status)) - sw2_fail(ins, "rumble write failed", status); + sw2_rumble_complete(ins); + return true; } + ins->query = SW2_QUERY_NONE; + if (!ins->command_pending) + sw2_disarm_timeout(ins); + if (!sw2_transient_write_error(status)) + sw2_fail(ins, "rumble write failed", status); + return false; +} + +static void sw2_next_boundary(uint32_t now, uint32_t boundary, uint32_t* next) { + if (!sw2_due(now, boundary) && boundary - now < *next) + *next = boundary - now; } static void sw2_output_tick(btstack_timer_source_t* timer) { @@ -947,15 +1244,24 @@ static void sw2_output_tick(btstack_timer_source_t* timer) { if (ins->state != SW2_READY) return; // A blocked setup command rescheduled itself, or awaits its ACK. uint32_t now = btstack_run_loop_get_time_ms(); - sw2_send_rumble(ins, now); + sw2_update_haptics(ins, now); + bool sent = sw2_send_rumble(ins, now); if (!ins->device) return; uint32_t next = SW2_OUTPUT_INTERVAL_MS; - if (ins->rumble_scheduled && (!ins->rumble_held || (int32_t)(now - ins->rumble_start) < 0)) { - uint32_t boundary = (int32_t)(now - ins->rumble_start) < 0 ? ins->rumble_start : ins->rumble_end; - if ((int32_t)(boundary - now) > 0 && boundary - now < next) - next = boundary - now; + if (ins->playback_guard && (ins->host_count || !sent)) + sw2_next_boundary(now, ins->playback_until, &next); + if (ins->rumble_scheduled) { + sw2_next_boundary(now, ins->rumble_start, &next); + if (!ins->rumble_held) + sw2_next_boundary(now, ins->rumble_end, &next); } + for (unsigned i = 0; i < sw2_output_sides(ins); ++i) { + if (ins->host_sides[i].valid && !ins->host_sides[i].held) + sw2_next_boundary(now, ins->host_sides[i].expires, &next); + } + if (ins->host_count && !ins->host_queue[ins->host_head].held) + sw2_next_boundary(now, ins->host_queue[ins->host_head].expires, &next); sw2_schedule_output(ins, next); } @@ -965,13 +1271,14 @@ void uni_hid_parser_switch2_play_dual_rumble(uni_hid_device_t* d, uint16_t delay if (!ins || ins->state != SW2_READY) return; uint32_t now = btstack_run_loop_get_time_ms(); + sw2_update_haptics(ins, now); ins->weak = weak; ins->strong = strong; ins->rumble_start = now + delay_ms; ins->rumble_end = ins->rumble_start + duration_ms; ins->rumble_held = duration_ms == UINT16_MAX; ins->rumble_scheduled = duration_ms != 0 && (weak != 0 || strong != 0); - // UINT16_MAX is the host's stateful/held sentinel; every other duration is - // finite. Keepalive preserves either effect until replacement or stop. + ++ins->output_revision; + sw2_update_haptics(ins, now); sw2_schedule_output(ins, 1); } diff --git a/bluepad32_config/parser/uni_hid_parser_switch2.h b/bluepad32_config/parser/uni_hid_parser_switch2.h index 607d3fe..e7632ca 100644 --- a/bluepad32_config/parser/uni_hid_parser_switch2.h +++ b/bluepad32_config/parser/uni_hid_parser_switch2.h @@ -3,6 +3,7 @@ #include #include +#include "parser/uni_switch2_haptics.h" #ifdef __cplusplus extern "C" { @@ -38,6 +39,16 @@ void uni_hid_parser_switch2_parse_input_report(struct uni_hid_device_s* d, const void uni_hid_parser_switch2_set_player_leds(struct uni_hid_device_s* d, uint8_t leds); void uni_hid_parser_switch2_play_dual_rumble(struct uni_hid_device_s* d, uint16_t delay_ms, uint16_t duration_ms, uint8_t weak, uint8_t strong); +// BTstack-core-only host queues. False means unavailable/full: nothing accepted. +// Invalid/stale input is consumed and counted. received_ms is the source clock, +// never a retry timestamp. The callback above is an independent local overlay. +bool uni_hid_parser_switch2_queue_haptics(struct uni_hid_device_s* d, + const uni_switch2_haptics_frame_t* frame, uint32_t received_ms); +bool uni_hid_parser_switch2_queue_rumble(struct uni_hid_device_s* d, uint8_t weak, uint8_t strong, + uint16_t duration_ms, uint32_t received_ms); +void uni_hid_parser_switch2_reset_haptics(struct uni_hid_device_s* d); +// Atomic cumulative transport losses; safe to read without looking up a device. +uint32_t uni_hid_parser_switch2_haptics_dropped(void); uint8_t uni_hid_parser_switch2_extra_buttons(const struct uni_hid_device_s* d); // Validated public/static-random advertisement address, never an RPA or SMP identity. bool uni_hid_parser_switch2_identity_address_type(const struct uni_hid_device_s* d, uint8_t* out); diff --git a/bluepad32_config/parser/uni_switch2_haptics.c b/bluepad32_config/parser/uni_switch2_haptics.c new file mode 100644 index 0000000..f243b66 --- /dev/null +++ b/bluepad32_config/parser/uni_switch2_haptics.c @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "parser/uni_switch2_haptics.h" +#include +#include + +static uint8_t frequency_index(uint8_t index) { + if (index < 1) return 1; + return index > 127 ? 127 : index; +} + +static uint16_t amplitude_code(uint16_t q15) { + if (q15 > 32767) q15 = 32767; + // Match SDL's conservative native envelope while retaining a linear input + // curve. This is transport gain, not a claim of calibrated physical force. + return (uint16_t)(((uint32_t)q15 * 29000u / 32767u) >> 6); +} + +static uint64_t load_sample(const uint8_t data[5]) { + uint64_t value = 0; + for (unsigned i = 0; i < 5; ++i) value |= (uint64_t)data[i] << (8 * i); + return value; +} + +static bool valid_side(const uni_switch2_haptics_side_t* side) { + if (side->count > UNI_SWITCH2_HAPTICS_MAX_SAMPLES) return false; + for (unsigned i = 0; i < side->count; ++i) { + uint64_t value = load_sample(side->samples[i]); + unsigned first_frequency = value & 1023u; + unsigned second_frequency = (value >> 20) & 1023u; + if (first_frequency == 0 || first_frequency > 670 || + second_frequency == 0 || second_frequency > 670 || + ((value >> 10) & 1023u) > UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE || + ((value >> 30) & 1023u) > UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE) + return false; + } + return true; +} + +void uni_switch2_haptics_encode_sample(uint8_t out[5], uint8_t low_index, uint8_t high_index, + uint16_t low_q15, uint16_t high_q15) { + if (!out) return; + // The two physical frequency fields share one measured logarithmic scale. + // Original Switch indices have32 steps/octave; Switch2 has96 steps/octave. + uint64_t value = 193u + 3u * frequency_index(low_index); + value |= (uint64_t)amplitude_code(low_q15) << 10; + value |= (uint64_t)(289u + 3u * frequency_index(high_index)) << 20; + value |= (uint64_t)amplitude_code(high_q15) << 30; + for (unsigned i = 0; i < 5; ++i) out[i] = (uint8_t)(value >> (8 * i)); +} + +void uni_switch2_haptics_silence(uni_switch2_haptics_frame_t* frame) { + if (!frame) return; + memset(frame, 0, sizeof(*frame)); + for (unsigned side = 0; side < 2; ++side) { + frame->sides[side].count = 1; + uni_switch2_haptics_encode_sample(frame->sides[side].samples[0], 64, 64, 0, 0); + } +} + +bool uni_switch2_haptics_valid(const uni_switch2_haptics_frame_t* frame) { + return frame && (frame->sides[0].count || frame->sides[1].count) && + valid_side(&frame->sides[0]) && valid_side(&frame->sides[1]); +} + +bool uni_switch2_haptics_is_stop(const uni_switch2_haptics_frame_t* frame) { + if (!uni_switch2_haptics_valid(frame) || !frame->sides[0].count || !frame->sides[1].count) + return false; + for (unsigned side = 0; side < 2; ++side) { + for (unsigned i = 0; i < frame->sides[side].count; ++i) { + uint64_t value = load_sample(frame->sides[side].samples[i]); + if (((value >> 10) & 1023u) || ((value >> 30) & 1023u)) return false; + } + } + return true; +} + +bool uni_switch2_haptics_write_block(uint8_t out[16], const uni_switch2_haptics_side_t* side, + uint8_t sequence) { + if (!out || !side || !side->count || side->count > UNI_SWITCH2_HAPTICS_MAX_SAMPLES) return false; + memset(out, 0, 16); + out[0] = (uint8_t)(0x40u | (side->count << 4) | (sequence & 15u)); + memcpy(out + 1, side->samples, 5u * side->count); + return true; +} diff --git a/bluepad32_config/parser/uni_switch2_haptics.h b/bluepad32_config/parser/uni_switch2_haptics.h new file mode 100644 index 0000000..5ec9395 --- /dev/null +++ b/bluepad32_config/parser/uni_switch2_haptics.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once +#include +#include +#ifdef __cplusplus +extern "C" { +#endif + +#define UNI_SWITCH2_HAPTICS_MAX_SAMPLES 3 +#define UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE 453 +#define UNI_SWITCH2_HAPTICS_WATCHDOG_MS 50 + +typedef struct { + uint8_t count; // Zero leaves this side's host state/watchdog untouched. + uint8_t samples[UNI_SWITCH2_HAPTICS_MAX_SAMPLES][5]; +} uni_switch2_haptics_side_t; +typedef struct { + uni_switch2_haptics_side_t sides[2]; // Physical left/right; Joy-Con uses side 0. +} uni_switch2_haptics_frame_t; + +// Source indices64 mean160Hz(low)/320Hz(high); amplitudes are linear Q0.15. +// Measured native frequency law: Hz ~=10*2^((code-1)/96), hence exact index mapping. +// Linear amplitude scaling uses SDL's29000/65535 safety envelope (10-bit max453). +void uni_switch2_haptics_encode_sample(uint8_t out[5], uint8_t low_index, uint8_t high_index, + uint16_t low_q15, uint16_t high_q15); +void uni_switch2_haptics_silence(uni_switch2_haptics_frame_t* frame); +bool uni_switch2_haptics_valid(const uni_switch2_haptics_frame_t* frame); +bool uni_switch2_haptics_is_stop(const uni_switch2_haptics_frame_t* frame); +// Native callers validate the host envelope separately; conventional feedback +// retains its existing full10-bit amplitude codes. +// Requires count1..3; unused slots are zero, header selects only the valid samples. +bool uni_switch2_haptics_write_block(uint8_t out[16], const uni_switch2_haptics_side_t* side, + uint8_t sequence); +#ifdef __cplusplus +} +#endif diff --git a/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index 547810c..b7ea9ab 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 158586b..cc6d2a4 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 cadea1e..3464cbb 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 6b16d3a..26b4749 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 dd01bc4..0372ab3 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -24,6 +24,7 @@ #include #include #include "parser/uni_hid_parser_switch2.h" +#include "parser/uni_switch2_haptics.h" #include "parser/uni_switch2_pairing.h" #ifdef SWITCH_PICO_USB_OUTPUT_MODES #include "adapter/adapter_usb_mode.h" @@ -112,10 +113,26 @@ struct RumbleEnvelope { uint32_t connection_generation; ControllerRumbleOutput rumble; uint16_t duration_ms; + uint32_t received_ms = 0; #if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE) uint64_t received_us = 0; #endif }; + +constexpr uint8_t kSwitch2IngressCapacity = 16; +struct Switch2HostCommand { + RumbleEnvelope envelope; + uint32_t generation; + uint8_t accepted_halves; +}; +struct Switch2Ingress { + Switch2HostCommand commands[kSwitch2IngressCapacity]; + uint32_t generation; + uint8_t host_mode; + uint8_t head; + uint8_t count; + bool reset_pending; +}; struct FeedbackEnvelope { uint32_t connection_generation; uint16_t duration_ms; @@ -176,6 +193,7 @@ struct BackendSlot { RumbleEnvelope pending_rumble; bool retained_host_rumble_valid; RumbleEnvelope retained_host_rumble; + Switch2Ingress switch2_ingress; FeedbackEnvelope pending_feedback; ProfileFeedbackEnvelope pending_profile_feedback[kProfileFeedbackQueueCapacity]; @@ -226,6 +244,7 @@ uint32_t g_controller_reports = 0; uint32_t g_host_rumble_requests = 0; uint32_t g_local_feedback_requests = 0; uint32_t g_rumble_dispatches = 0; +uint32_t g_switch2_ingress_drops = 0; #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT uint32_t g_seeded_native_run_id = 0; #endif @@ -239,6 +258,14 @@ uint16_t host_rumble_duration_ms() { return kSwitchHostRumbleDurationMs; } +uint8_t switch2_host_mode() { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + return static_cast(adapter_host_probe_mode()); +#else + return 0; +#endif +} + ControllerState make_neutral_state() { return controller_neutral_state(); } @@ -332,6 +359,154 @@ int joycon_side(const uni_hid_device_t* device) { return device->product_id == UNI_SW2_JOYCON_R_PID ? 1 : 0; } +// Caller holds the cross-core state lock. Only Core 1 resets parser state. +void clear_switch2_ingress(BackendSlot& slot) { + Switch2Ingress& ingress = slot.switch2_ingress; + __atomic_add_fetch(&g_switch2_ingress_drops, ingress.count, __ATOMIC_RELAXED); + ingress.head = 0; + ingress.count = 0; + ++ingress.generation; + ingress.reset_pending = true; +} + +void reset_switch2_outputs(BackendSlot& slot) { + uni_hid_device_t* targets[] = {slot.device, slot.companion}; + for (uni_hid_device_t* target : targets) { + if (uni_hid_parser_switch2_is_ble_device(target)) { + uni_hid_parser_switch2_reset_haptics(target); + } + } + slot.switch2_ingress.reset_pending = false; +} + +bool switch2_has_hd(const ControllerRumbleOutput& rumble) { + return rumble.hd.actuators[0].sample_count != 0 || + rumble.hd.actuators[1].sample_count != 0; +} + +bool switch2_host_stop(const ControllerRumbleOutput& rumble) { + if (!switch2_has_hd(rumble)) { + return (rumble.low_frequency_magnitude | rumble.high_frequency_magnitude) == 0; + } + for (const SwitchHapticsActuatorFrame& side : rumble.hd.actuators) { + if (side.sample_count == 0 || side.sample_count > 3) return false; + for (uint8_t index = 0; index < side.sample_count; ++index) { + if (side.samples[index].low_amplitude_q15 != 0 || + side.samples[index].high_amplitude_q15 != 0) return false; + } + } + return true; +} + +void encode_switch2_side(uni_switch2_haptics_side_t& output, + const SwitchHapticsActuatorFrame& input) { + output.count = input.sample_count; + for (uint8_t index = 0; index < input.sample_count; ++index) { + const SwitchHapticsSample& sample = input.samples[index]; + uni_switch2_haptics_encode_sample( + output.samples[index], sample.low_frequency_index, + sample.high_frequency_index, sample.low_amplitude_q15, + sample.high_amplitude_q15); + } +} + +uni_switch2_haptics_frame_t switch2_physical_frame( + const ControllerRumbleOutput& rumble, const uni_hid_device_t* target, + bool paired) { + uni_switch2_haptics_frame_t frame{}; + const SwitchHapticsActuatorFrame& left = rumble.hd.actuators[0]; + const SwitchHapticsActuatorFrame& right = rumble.hd.actuators[1]; + const int side = joycon_side(target); + if (side == 0) { + encode_switch2_side(frame.sides[0], left); + encode_switch2_side(frame.sides[1], right); + } else if (paired) { + encode_switch2_side(frame.sides[0], side < 0 ? left : right); + } else { + // Mono chooses each band's louder source independently. A short side + // holds its final substep; an absent side contributes no update. + frame.sides[0].count = + left.sample_count > right.sample_count ? left.sample_count : right.sample_count; + for (uint8_t index = 0; index < frame.sides[0].count; ++index) { + const SwitchHapticsSample* l = left.sample_count == 0 ? nullptr : + &left.samples[index < left.sample_count ? index : left.sample_count - 1]; + const SwitchHapticsSample* r = right.sample_count == 0 ? nullptr : + &right.samples[index < right.sample_count ? index : right.sample_count - 1]; + const SwitchHapticsSample* low = !r || (l && l->low_amplitude_q15 >= r->low_amplitude_q15) ? l : r; + const SwitchHapticsSample* high = !r || (l && l->high_amplitude_q15 >= r->high_amplitude_q15) ? l : r; + uni_switch2_haptics_encode_sample( + frame.sides[0].samples[index], low->low_frequency_index, + high->high_frequency_index, low->low_amplitude_q15, + high->high_amplitude_q15); + } + } + return frame; +} + +void drain_switch2_ingress(uint8_t slot_index, uint32_t now_ms) { + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (!slot.active || !uni_hid_parser_switch2_is_ble_device(slot.device)) { + critical_section_exit(&g_state_lock); + return; + } + Switch2Ingress& ingress = slot.switch2_ingress; + const uint16_t duration_ms = host_rumble_duration_ms(); + const uint8_t host_mode = switch2_host_mode(); + if (ingress.host_mode != host_mode) { + clear_switch2_ingress(slot); + ingress.host_mode = host_mode; + } + if (ingress.reset_pending) reset_switch2_outputs(slot); + for (uint8_t budget = 0; budget < kSwitch2IngressCapacity && ingress.count != 0; ++budget) { + Switch2HostCommand& command = ingress.commands[ingress.head]; + const RumbleEnvelope& envelope = command.envelope; + const bool hd = switch2_has_hd(envelope.rumble); + const bool stop = switch2_host_stop(envelope.rumble); + const bool stale = command.generation != ingress.generation || + envelope.connection_generation != slot.connection_generation || + envelope.duration_ms != duration_ms || + (!stop && (hd || duration_ms != kXInputHostRumbleDurationMs) && + static_cast(now_ms - envelope.received_ms) >= UNI_SWITCH2_HAPTICS_WATCHDOG_MS); + const bool invalid = envelope.rumble.hd.actuators[0].sample_count > 3 || + envelope.rumble.hd.actuators[1].sample_count > 3; + if (stale || invalid) { + __atomic_add_fetch(&g_switch2_ingress_drops, 1, __ATOMIC_RELAXED); + } else { + uni_hid_device_t* targets[] = {slot.device, slot.companion}; + const uint8_t target_mask = slot.companion == nullptr ? 1 : 3; + for (uint8_t half = 0; half < 2; ++half) { + const uint8_t bit = 1u << half; + if (!(target_mask & bit) || (command.accepted_halves & bit)) continue; + bool accepted; + if (hd) { + const uni_switch2_haptics_frame_t frame = + switch2_physical_frame(envelope.rumble, targets[half], slot.companion != nullptr); + if (frame.sides[0].count == 0 && frame.sides[1].count == 0) { + command.accepted_halves |= bit; + continue; + } + accepted = uni_hid_parser_switch2_queue_haptics( + targets[half], &frame, envelope.received_ms); + } else { + accepted = uni_hid_parser_switch2_queue_rumble( + targets[half], envelope.rumble.high_frequency_magnitude, + envelope.rumble.low_frequency_magnitude, + stop ? 0 : envelope.duration_ms, envelope.received_ms); + } + if (accepted) { + command.accepted_halves |= bit; + __atomic_add_fetch(&g_rumble_dispatches, 1, __ATOMIC_RELAXED); + } + } + if (command.accepted_halves != target_mask) break; + } + ingress.head = (ingress.head + 1u) % kSwitch2IngressCapacity; + --ingress.count; + } + critical_section_exit(&g_state_lock); +} + bool addresses_equal(const bd_addr_t first, const bd_addr_t second) { return memcmp(first, second, sizeof(bd_addr_t)) == 0; } @@ -621,6 +796,8 @@ void publish_device_state(uint8_t slot, uni_hid_device_t* device, void publish_all_neutral() { critical_section_enter_blocking(&g_state_lock); for (BackendSlot& slot : g_slots) { + clear_switch2_ingress(slot); + reset_switch2_outputs(slot); slot.state = make_neutral_state(); slot.pre_hotkey_button_mask = 0; slot.identity = controller_identity_global(); @@ -1030,6 +1207,8 @@ void reset_slot_hotkeys(BackendSlot& slot) { } void invalidate_slot(BackendSlot& slot) { + clear_switch2_ingress(slot); + reset_switch2_outputs(slot); reset_slot_hotkeys(slot); slot.rumble_pending = false; slot.pending_rumble = {}; @@ -1634,6 +1813,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) { #endif for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { + drain_switch2_ingress(slot_index, now_ms); RumbleEnvelope envelope{}; FeedbackEnvelope feedback{}; ProfileFeedbackEnvelope profile_feedback{}; @@ -2453,6 +2633,9 @@ void bluepad32_input_backend_diagnostics( __atomic_load_n(&g_local_feedback_requests, __ATOMIC_RELAXED); out->rumble_dispatches = __atomic_load_n(&g_rumble_dispatches, __ATOMIC_RELAXED); + out->switch2_ingress_drops = + __atomic_load_n(&g_switch2_ingress_drops, __ATOMIC_RELAXED); + out->switch2_output_drops = uni_hid_parser_switch2_haptics_dropped(); critical_section_enter_blocking(&g_state_lock); for (const BackendSlot& slot : g_slots) { @@ -2467,7 +2650,7 @@ void bluepad32_input_backend_diagnostics( slot.pending_profile_feedback_count != 0) { ++out->feedback_pending_slots; } - if (slot.rumble_pending) { + if (slot.rumble_pending || slot.switch2_ingress.count != 0) { ++out->rumble_pending_slots; } } @@ -2577,16 +2760,47 @@ void bluepad32_input_backend_queue_rumble( bool native_candidate = false; #endif const uint16_t duration_ms = host_rumble_duration_ms(); + const uint32_t received_ms = btstack_run_loop_get_time_ms(); critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; if (slot.active && slot.device != nullptr) { + if (uni_hid_parser_switch2_is_ble_device(slot.device)) { + Switch2Ingress& ingress = slot.switch2_ingress; + const uint8_t host_mode = switch2_host_mode(); + if (ingress.host_mode != host_mode) { + clear_switch2_ingress(slot); + ingress.host_mode = host_mode; + } + if (switch2_host_stop(rumble)) { + // Host stop is a barrier, not a local-feedback cancellation. + ingress.head = 0; + ingress.count = 0; + } else if (ingress.count == kSwitch2IngressCapacity) { + ingress.head = (ingress.head + 1u) % kSwitch2IngressCapacity; + --ingress.count; + __atomic_add_fetch(&g_switch2_ingress_drops, 1, __ATOMIC_RELAXED); + } + Switch2HostCommand& command = + ingress.commands[(ingress.head + ingress.count) % kSwitch2IngressCapacity]; + command = {}; + command.envelope.slot = slot_index; + command.envelope.connection_generation = slot.connection_generation; + command.envelope.rumble = rumble; + command.envelope.duration_ms = duration_ms; + command.envelope.received_ms = received_ms; + command.generation = ingress.generation; + ++ingress.count; + __atomic_add_fetch(&g_host_rumble_requests, 1, __ATOMIC_RELAXED); + critical_section_exit(&g_state_lock); + return; + } #if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE) native_generation = slot.connection_generation; native_candidate = true; #endif const RumbleEnvelope envelope{ slot_index, slot.connection_generation, rumble, - duration_ms + duration_ms, received_ms #if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE) , received_us #endif diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index 1f349b6..02a85eb 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -89,6 +89,8 @@ struct Bluepad32BackendDiagnostics { uint8_t rumble_capable_slots; uint8_t feedback_pending_slots; uint8_t rumble_pending_slots; + uint32_t switch2_ingress_drops; + uint32_t switch2_output_drops; }; diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index 681dc75..823013b 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -193,7 +193,7 @@ size_t encode_info(uint8_t* output, size_t output_size) { size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) { Bluepad32BackendDiagnostics diagnostics{}; bluepad32_input_backend_diagnostics(&diagnostics); - uint8_t payload[32]{}; + uint8_t payload[40]{}; write_u32(&payload[0], diagnostics.initialization_stage); write_u32(&payload[4], diagnostics.rumble_timer_ticks); write_u32(&payload[8], diagnostics.configuration_timer_ticks); @@ -205,6 +205,8 @@ size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) { payload[29] = diagnostics.rumble_capable_slots; payload[30] = diagnostics.feedback_pending_slots; payload[31] = diagnostics.rumble_pending_slots; + write_u32(&payload[32], diagnostics.switch2_ingress_drops); + write_u32(&payload[36], diagnostics.switch2_output_drops); return encode_response(Operation::kRuntimeDiagnostics, Status::kOk, 0, 0, 0, payload, sizeof(payload), output, output_size); diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 51c0e52..d2afe9a 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -374,6 +374,8 @@ class RuntimeDiagnostics: rumble_capable_slots: int feedback_pending_slots: int rumble_pending_slots: int + switch2_ingress_drops: int | None = None + switch2_output_drops: int | None = None @dataclass(frozen=True) @@ -2701,15 +2703,22 @@ def capture_macro_steps(page: MacroCapturePage) -> tuple[MacroStep, ...]: def read_runtime_diagnostics(device: UsbDevice) -> RuntimeDiagnostics: envelope = _control_in(device, OP_RUNTIME_DIAGNOSTICS) _raise_status(envelope) - if len(envelope.payload) != 32: + if len(envelope.payload) not in (32, 40): raise ConfigManagerError("invalid runtime-diagnostics payload") counters = struct.unpack_from("<7I", envelope.payload) + ingress_drops, output_drops = ( + struct.unpack_from("<2I", envelope.payload, 32) + if len(envelope.payload) == 40 + else (None, None) + ) return RuntimeDiagnostics( *counters, active_slots=envelope.payload[28], rumble_capable_slots=envelope.payload[29], feedback_pending_slots=envelope.payload[30], rumble_pending_slots=envelope.payload[31], + switch2_ingress_drops=ingress_drops, + switch2_output_drops=output_drops, ) @@ -4466,6 +4475,9 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"Rumble-capable slots: {diagnostics.rumble_capable_slots}") print(f"Feedback-pending slots: {diagnostics.feedback_pending_slots}") print(f"Rumble-pending slots: {diagnostics.rumble_pending_slots}") + if diagnostics.switch2_ingress_drops is not None: + print(f"Switch 2 ingress drops: {diagnostics.switch2_ingress_drops}") + print(f"Switch 2 output drops: {diagnostics.switch2_output_drops}") elif args.command == "haptics-experiment": _run_haptics_experiment_command(device, args) elif args.command == "reboot": diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index 29b0df0..5ae3ece 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -2,14 +2,15 @@ #include #include #include +#include #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT #include #include -#include #endif #include #include "parser/uni_hid_parser_switch2.h" +#include "parser/uni_switch2_haptics.h" #include "parser/uni_switch2_pairing.h" #include "platform/pico/controller_color_config.h" #include "input/switch2_wake.h" @@ -75,6 +76,18 @@ uint8_t switch2_pairing_types[UNI_SWITCH2_PAIRING_CAPACITY]{}; uint8_t switch2_pairing_count = 0; bool switch2_clear_succeeds = true; +struct Switch2HostEvent { + uni_hid_device_t* device; + uni_switch2_haptics_frame_t frame; + uint32_t received_ms; + uint16_t duration_ms; + uint8_t weak; + uint8_t strong; + bool hd; +}; +std::vector switch2_host_events; +uint32_t switch2_output_drops = 0; + struct CoreStopped {}; @@ -184,6 +197,45 @@ extern "C" bool uni_hid_parser_switch2_is_ble_device( device->product_id == UNI_SW2_JOYCON_R_PID); } +extern "C" bool uni_hid_parser_switch2_queue_haptics( + uni_hid_device_t* device, const uni_switch2_haptics_frame_t* frame, + uint32_t received_ms) { + require(uni_switch2_haptics_valid(frame), "backend emitted invalid physical HD frame"); + const bool stop = device->product_id == UNI_SW2_PRO_PID ? + uni_switch2_haptics_is_stop(frame) : [&]() { + uni_switch2_haptics_frame_t stereo = *frame; + stereo.sides[1] = stereo.sides[0]; + return uni_switch2_haptics_is_stop(&stereo); + }(); + if (device->switch2_host_blocked && !stop) return false; + ++device->switch2_host_calls; + switch2_host_events.push_back({device, *frame, received_ms, 0, 0, 0, true}); + return true; +} + +extern "C" bool uni_hid_parser_switch2_queue_rumble( + uni_hid_device_t* device, uint8_t weak, uint8_t strong, + uint16_t duration_ms, uint32_t received_ms) { + if (device->switch2_host_blocked && (weak | strong) != 0) return false; + ++device->switch2_host_calls; + device->last_high = weak; + device->last_low = strong; + device->last_rumble_duration_ms = duration_ms; + switch2_host_events.push_back({device, {}, received_ms, duration_ms, weak, strong, false}); + return true; +} + +extern "C" void uni_hid_parser_switch2_reset_haptics(uni_hid_device_t* device) { + ++device->switch2_haptics_resets; + device->last_high = 0; + device->last_low = 0; + device->last_rumble_duration_ms = 0; +} + +extern "C" uint32_t uni_hid_parser_switch2_haptics_dropped(void) { + return switch2_output_drops; +} + extern "C" uint8_t uni_hid_parser_switch2_extra_buttons( const uni_hid_device_t* device) { return uni_hid_parser_switch2_is_ble_device(device) @@ -859,6 +911,347 @@ Bluepad32SlotSnapshot slot_snapshot(uint8_t index) { return result; } +ControllerRumbleOutput ordered_switch2_hd(uint8_t frequency = 40) { + ControllerRumbleOutput rumble{17, 29}; + rumble.hd.actuators[0].sample_count = 3; + rumble.hd.actuators[1].sample_count = 2; + rumble.hd.actuators[0].samples[0] = {frequency, 61, 24000, 4000}; + rumble.hd.actuators[0].samples[1] = {41, 62, 5000, 25000}; + rumble.hd.actuators[0].samples[2] = {42, 63, 20000, 14000}; + rumble.hd.actuators[1].samples[0] = {81, 91, 3000, 21000}; + rumble.hd.actuators[1].samples[1] = {82, 92, 23000, 7000}; + return rumble; +} + +void require_switch2_sample(const uint8_t encoded[5], + const SwitchHapticsSample& source) { + uint64_t bits = 0; + for (unsigned index = 0; index < 5; ++index) { + bits |= uint64_t{encoded[index]} << (index * 8); + } + require((bits & 1023u) == 193u + 3u * source.low_frequency_index && + ((bits >> 20) & 1023u) == 289u + 3u * source.high_frequency_index && + ((bits >> 10) & 1023u) == + ((uint32_t{source.low_amplitude_q15} * 29000u / 32767u) >> 6) && + ((bits >> 30) & 1023u) == + ((uint32_t{source.high_amplitude_q15} * 29000u / 32767u) >> 6), + "native physical sample lost its measured frequency or independent band amplitude"); +} + +void test_switch2_hd_pro() { + start_pairing_backend(); + auto pro = switch2_device(0, UNI_SW2_PRO_PID); + ready_switch2(pro); + now_ms = 100; + const auto first = ordered_switch2_hd(40); + const auto second = ordered_switch2_hd(50); + bluepad32_input_backend_queue_rumble(0, first); + now_ms = 101; + bluepad32_input_backend_queue_rumble(0, second); + now_ms = 105; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 2 && pro.rumble_calls == 0 && + switch2_host_events[0].received_ms == 100 && + switch2_host_events[1].received_ms == 101, + "rapid HD commands must bypass the compatibility mailbox in original order and time"); + for (unsigned event = 0; event < 2; ++event) { + const auto& input = event == 0 ? first : second; + const auto& output = switch2_host_events[event].frame; + require(output.sides[0].count == 3 && output.sides[1].count == 2, + "Pro output must preserve both native side counts"); + for (unsigned side = 0; side < 2; ++side) { + for (unsigned step = 0; step < output.sides[side].count; ++step) { + require_switch2_sample(output.sides[side].samples[step], + input.hd.actuators[side].samples[step]); + } + } + } + ControllerRumbleOutput partial{}; + partial.hd.actuators[0].sample_count = 1; + bluepad32_input_backend_queue_rumble(0, first); + bluepad32_input_backend_queue_rumble(0, partial); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 4 && + switch2_host_events.back().frame.sides[1].count == 0, + "a silent partial update must neither flush older commands nor invent a right stop"); + + pro.switch2_host_blocked = true; + bluepad32_input_backend_queue_rumble(0, first); + ControllerRumbleOutput stop{}; + stop.hd.actuators[0].sample_count = 1; + stop.hd.actuators[1].sample_count = 1; + bluepad32_input_backend_queue_rumble(0, stop); + now_ms += 100; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 5 && + uni_switch2_haptics_is_stop(&switch2_host_events.back().frame), + "explicit native stop must clear older ingress and bypass age/full barriers"); + pro.switch2_host_blocked = false; + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{19, 23}); + process_rumble_timer(&g_rumble_timer); + require(!switch2_host_events.back().hd && switch2_host_events.back().strong == 19 && + switch2_host_events.back().weak == 23 && pro.rumble_calls == 0, + "both empty HD sides must use the dedicated conventional host API"); +} + +void test_switch2_hd_solo(bool right) { + start_pairing_backend(); + auto solo = switch2_device(0, right ? UNI_SW2_JOYCON_R_PID : UNI_SW2_JOYCON_L_PID); + ready_switch2(solo); + const auto input = ordered_switch2_hd(); + bluepad32_input_backend_queue_rumble(0, input); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 1 && + switch2_host_events[0].frame.sides[0].count == 3 && + switch2_host_events[0].frame.sides[1].count == 0, + "either solo half must receive a mono sequence in physical side zero"); + const SwitchHapticsSample expected[] = { + {40, 91, 24000, 21000}, {82, 62, 23000, 25000}, {82, 63, 23000, 14000}}; + for (unsigned index = 0; index < 3; ++index) { + require_switch2_sample(switch2_host_events[0].frame.sides[0].samples[index], + expected[index]); + } + auto tie = input; + tie.hd.actuators[0].sample_count = 1; + tie.hd.actuators[1].sample_count = 1; + tie.hd.actuators[1].samples[0].low_amplitude_q15 = 24000; + tie.hd.actuators[1].samples[0].high_amplitude_q15 = 4000; + bluepad32_input_backend_queue_rumble(0, tie); + process_rumble_timer(&g_rumble_timer); + require_switch2_sample(switch2_host_events.back().frame.sides[0].samples[0], + tie.hd.actuators[0].samples[0]); + auto one_side = input; + one_side.hd.actuators[0].sample_count = 0; + bluepad32_input_backend_queue_rumble(0, one_side); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.back().frame.sides[0].count == 2, + "solo absent source side must not fabricate substeps"); + require_switch2_sample(switch2_host_events.back().frame.sides[0].samples[1], + one_side.hd.actuators[1].samples[1]); +} + +void test_switch2_hd_pair() { + start_pairing_backend(); + auto right = switch2_device(0, UNI_SW2_JOYCON_R_PID); + auto left = switch2_device(1, UNI_SW2_JOYCON_L_PID); + ready_switch2(right); + ready_switch2(left); + right.switch2_host_blocked = true; + now_ms = 10; + const auto input = ordered_switch2_hd(); + bluepad32_input_backend_queue_rumble(0, input); + process_rumble_timer(&g_rumble_timer); + now_ms = 15; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 1 && switch2_host_events[0].device == &left, + "one blocked half must not duplicate acceptance on its ready partner"); + right.switch2_host_blocked = false; + now_ms = 20; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 2 && switch2_host_events[1].device == &right && + switch2_host_events[1].received_ms == 10, + "paired retry must retain the source timestamp and original missing half"); + for (unsigned half = 0; half < 2; ++half) { + const auto& output = switch2_host_events[half].frame; + require(output.sides[0].count == (half == 0 ? 3 : 2) && output.sides[1].count == 0, + "pair stereo must map selected source side onto each physical side zero"); + for (unsigned step = 0; step < output.sides[0].count; ++step) { + require_switch2_sample(output.sides[0].samples[step], + input.hd.actuators[half].samples[step]); + } + } + auto right_only = input; + right_only.hd.actuators[0].sample_count = 0; + bluepad32_input_backend_queue_rumble(0, right_only); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 3 && switch2_host_events.back().device == &right, + "count zero on one paired half must not submit an invented neutral update"); + right.switch2_host_blocked = true; + bluepad32_input_backend_queue_rumble(0, input); + process_rumble_timer(&g_rumble_timer); + now_ms += 50; + right.switch2_host_blocked = false; + process_rumble_timer(&g_rumble_timer); + Bluepad32BackendDiagnostics diagnostics{}; + bluepad32_input_backend_diagnostics(&diagnostics); + require(switch2_host_events.size() == 4 && diagnostics.switch2_ingress_drops == 1, + "an expired partly accepted pair must drop visibly without late or duplicate delivery"); + right.switch2_host_blocked = true; + bluepad32_input_backend_queue_rumble(0, input); + process_rumble_timer(&g_rumble_timer); + ControllerRumbleOutput stop{}; + stop.hd.actuators[0].sample_count = 1; + stop.hd.actuators[1].sample_count = 1; + bluepad32_input_backend_queue_rumble(0, stop); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 7 && + switch2_host_events[5].device == &left && + switch2_host_events[6].device == &right && + switch2_host_events[5].frame.sides[0].count == 1 && + switch2_host_events[6].frame.sides[0].count == 1, + "paired explicit stop must reach both halves even after partial acceptance and backpressure"); +} + +void test_switch2_hd_overflow() { + start_pairing_backend(); + auto pro = switch2_device(0, UNI_SW2_PRO_PID); + ready_switch2(pro); + now_ms = 100; + for (uint8_t command = 0; command < 17; ++command) { + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(40 + command)); + } + Bluepad32BackendDiagnostics diagnostics{}; + bluepad32_input_backend_diagnostics(&diagnostics); + require(diagnostics.switch2_ingress_drops == 1 && diagnostics.rumble_pending_slots == 1, + "bounded ingress overflow must expose one oldest-command drop"); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 16, + "one drain must preserve all sixteen surviving commands, not a latest-value mailbox"); + for (uint8_t index = 0; index < 16; ++index) { + const auto input = ordered_switch2_hd(41 + index); + require_switch2_sample(switch2_host_events[index].frame.sides[0].samples[0], + input.hd.actuators[0].samples[0]); + } + pro.switch2_host_blocked = true; + now_ms = UINT32_MAX - 20; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + now_ms = 28; // 49ms across wrap. + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_diagnostics(&diagnostics); + require(diagnostics.rumble_pending_slots == 1 && diagnostics.switch2_ingress_drops == 1, + "backpressure must retain an unexpired command across clock wrap"); + now_ms = 29; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_diagnostics(&diagnostics); + require(diagnostics.rumble_pending_slots == 0 && diagnostics.switch2_ingress_drops == 2 && + switch2_host_events.size() == 16, + "original 50ms age must release a backpressured ingress head exactly at expiry"); + pro.switch2_host_blocked = false; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + auto& stale = g_slots[0].switch2_ingress; + --stale.commands[stale.head].envelope.connection_generation; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_diagnostics(&diagnostics); + require(switch2_host_events.size() == 16 && diagnostics.switch2_ingress_drops == 3, + "stale logical generation must be rejected before physical submission"); + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + --stale.commands[stale.head].generation; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_diagnostics(&diagnostics); + require(switch2_host_events.size() == 16 && diagnostics.switch2_ingress_drops == 4, + "stale haptics epoch must not survive a same-connection mode reset"); + switch2_output_drops = 7; + bluepad32_input_backend_diagnostics(&diagnostics); + require(diagnostics.switch2_output_drops == 7, + "physical output loss must remain visible separately from ingress loss"); +} + +void test_switch2_hd_epochs() { + start_pairing_backend(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + ready_switch2(left); + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + process_rumble_timer(&g_rumble_timer); + const unsigned left_resets = left.switch2_haptics_resets; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + ready_switch2(right); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 1 && left.switch2_haptics_resets > left_resets && + right.switch2_haptics_resets != 0, + "merge must reset accepted physical output and drop queued solo commands"); + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + process_rumble_timer(&g_rumble_timer); + const unsigned pair_resets = left.switch2_haptics_resets; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + platform_on_device_disconnected(&right); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 3 && left.switch2_haptics_resets > pair_resets, + "survivor transition must flush both physical and ingress pair state"); + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + platform_on_device_disconnected(&left); + auto replacement = switch2_device(0, UNI_SW2_PRO_PID); + ready_switch2(replacement); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 3, + "reconnect must not inherit a former logical generation's host output"); +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{27, 35}); + process_rumble_timer(&g_rumble_timer); + const unsigned mode_resets = replacement.switch2_haptics_resets; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + test_adapter_mode = AdapterUsbMode::kSwitchProbe; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 4 && replacement.switch2_haptics_resets > mode_resets, + "mode boundary without new input must neutralize held output and queued old-mode HD"); + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(50)); + test_adapter_mode = AdapterUsbMode::kXInput; + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{45, 55}); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 5 && !switch2_host_events.back().hd && + switch2_host_events.back().duration_ms == UINT16_MAX && + switch2_host_events.back().strong == 45, + "producer-side mode transition must discard old epoch before accepting new host state"); +#endif +} + +void test_switch2_hd_feedback() { + start_pairing_backend(); + auto pro = switch2_device(0, UNI_SW2_PRO_PID); + ready_switch2(pro); + bluepad32_input_backend_queue_profile_feedback( + 0, slot_snapshot(0).connection_generation, 2, + ControllerProfileConfirmationPolicy::kRumble); + process_rumble_timer(&g_rumble_timer); + require(pro.rumble_calls == 1, "profile confirmation must retain local-feedback ownership"); + now_ms = 10; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + process_rumble_timer(&g_rumble_timer); + now_ms = 20; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(50)); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 2 && pro.rumble_calls == 1 && + switch2_host_events[0].received_ms == 10 && + switch2_host_events[1].received_ms == 20, + "host substeps must advance during feedback without compatibility dispatch or fresh timestamps"); + now_ms = 300; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 2, + "feedback completion must not replay historical host substeps"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{33, 44}); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 3 && !switch2_host_events.back().hd && + switch2_host_events.back().duration_ms == host_rumble_duration_ms(), + "conventional host vibration must share the bounded host queue, not local feedback"); + now_ms += 1000; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 3, + "stateful host output must not require backend periodic resubmission"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{}); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 4 && switch2_host_events.back().duration_ms == 0 && + switch2_host_events.back().weak == 0 && switch2_host_events.back().strong == 0, + "conventional all-zero host command must explicitly stop retained state"); + pro.switch2_host_blocked = true; + const uint32_t received_ms = now_ms; + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{65, 75}); + now_ms += 1000; + process_rumble_timer(&g_rumble_timer); + pro.switch2_host_blocked = false; + process_rumble_timer(&g_rumble_timer); + if (host_rumble_duration_ms() == UINT16_MAX) { + require(switch2_host_events.size() == 5 && + switch2_host_events.back().received_ms == received_ms && + switch2_host_events.back().strong == 65, + "held XInput host state must survive backpressure without retimestamping"); + } else { + Bluepad32BackendDiagnostics diagnostics{}; + bluepad32_input_backend_diagnostics(&diagnostics); + require(switch2_host_events.size() == 4 && diagnostics.switch2_ingress_drops == 1, + "finite conventional host state must expire under feedback/backpressure, not revive"); + } +} + void test_switch2_pair_lifecycle(bool right_first) { start_pairing_backend(); uni_hid_device_t left = switch2_device( @@ -3262,7 +3655,21 @@ int main(int argc, char** argv) { return 0; } #endif - if (scenario == "switch2-forward") { + if (scenario == "switch2-hd-pro") { + test_switch2_hd_pro(); + } else if (scenario == "switch2-hd-solo-left") { + test_switch2_hd_solo(false); + } else if (scenario == "switch2-hd-solo-right") { + test_switch2_hd_solo(true); + } else if (scenario == "switch2-hd-pair") { + test_switch2_hd_pair(); + } else if (scenario == "switch2-hd-overflow") { + test_switch2_hd_overflow(); + } else if (scenario == "switch2-hd-epochs") { + test_switch2_hd_epochs(); + } else if (scenario == "switch2-hd-feedback") { + test_switch2_hd_feedback(); + } else if (scenario == "switch2-forward") { test_switch2_pair_lifecycle(false); } else if (scenario == "switch2-reverse") { test_switch2_pair_lifecycle(true); diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index e4390b1..e0df424 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -166,6 +166,9 @@ struct uni_hid_device_s { uint8_t switch2_extra_buttons; bool switch2_identity_valid; uint8_t switch2_identity_address_type; + bool switch2_host_blocked; + unsigned switch2_host_calls; + unsigned switch2_haptics_resets; }; void uni_hid_parser_xboxone_play_dual_rumble( diff --git a/tests/switch2_haptics_test.c b/tests/switch2_haptics_test.c new file mode 100644 index 0000000..a7e9972 --- /dev/null +++ b/tests/switch2_haptics_test.c @@ -0,0 +1,70 @@ +#include +#include +#include "parser/uni_switch2_haptics.h" + +static uint64_t unpack(const uint8_t sample[5]) { + uint64_t result = 0; + for (unsigned byte = 0; byte < 5; ++byte) result |= (uint64_t)sample[byte] << (byte * 8); + return result; +} + +int main(void) { + uni_switch2_haptics_frame_t frame; + uni_switch2_haptics_silence(&frame); + assert(uni_switch2_haptics_valid(&frame)); + assert(uni_switch2_haptics_is_stop(&frame)); + uint64_t sample = unpack(frame.sides[0].samples[0]); + assert((sample & 1023) == 385); // Measured160Hz anchor. + assert(((sample >> 20) & 1023) == 481); // Measured320Hz anchor. + assert(((sample >> 10) & 1023) == 0 && ((sample >> 30) & 1023) == 0); + + uni_switch2_haptics_encode_sample(frame.sides[0].samples[0], 96, 96, 32767, 16384); + sample = unpack(frame.sides[0].samples[0]); + assert((sample & 1023) == 481); + assert(((sample >> 20) & 1023) == 577); // Upper frequency bit is frequency, not tone mode. + assert(((sample >> 10) & 1023) == 453 && ((sample >> 30) & 1023) == 226); + assert(!uni_switch2_haptics_is_stop(&frame)); + + frame.sides[0].count = 3; + uni_switch2_haptics_encode_sample(frame.sides[0].samples[1], 32, 64, 8192, 0); + uni_switch2_haptics_encode_sample(frame.sides[0].samples[2], 64, 127, 0, 32767); + uint8_t block[16]; + assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 0xab)); + assert(block[0] == 0x7b); + assert((unpack(block + 1) & 1023) == 481); + assert((unpack(block + 6) & 1023) == 289); + assert(((unpack(block + 6) >> 10) & 1023) == 113); + assert(((unpack(block + 11) >> 20) & 1023) == 670); + assert(((unpack(block + 11) >> 10) & 1023) == 0); + assert(((unpack(block + 11) >> 30) & 1023) == 453); + + frame.sides[0].count = 2; + assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 15)); + assert(block[0] == 0x6f); + for (unsigned i = 11; i < 16; ++i) assert(block[i] == 0); + frame.sides[0].count = 1; + assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 16)); + assert(block[0] == 0x50); + for (unsigned i = 6; i < 16; ++i) assert(block[i] == 0); + + uni_switch2_haptics_encode_sample(frame.sides[0].samples[0], 0, 255, 65535, 65535); + sample = unpack(frame.sides[0].samples[0]); + assert((sample & 1023) == 196 && ((sample >> 20) & 1023) == 670); + assert(((sample >> 10) & 1023) == 453 && ((sample >> 30) & 1023) == 453); + assert(uni_switch2_haptics_valid(&frame)); + // A malformed internal native frame cannot bypass the amplitude envelope. + frame.sides[0].samples[0][2] |= 0x0f; + assert(!uni_switch2_haptics_valid(&frame)); + + uni_switch2_haptics_silence(&frame); + frame.sides[1].count = 0; + assert(uni_switch2_haptics_valid(&frame)); + assert(!uni_switch2_haptics_is_stop(&frame)); // A side-only mute must not clear its partner. + frame.sides[0].count = 0; + assert(!uni_switch2_haptics_valid(&frame)); + assert(!uni_switch2_haptics_write_block(block, &frame.sides[0], 0)); + frame.sides[0].count = 4; + assert(!uni_switch2_haptics_valid(&frame)); + assert(!uni_switch2_haptics_write_block(block, &frame.sides[0], 0)); + return 0; +} diff --git a/tests/switch2_parser_native_stubs/protocol_fixture.h b/tests/switch2_parser_native_stubs/protocol_fixture.h index 1995458..e4f9d2d 100644 --- a/tests/switch2_parser_native_stubs/protocol_fixture.h +++ b/tests/switch2_parser_native_stubs/protocol_fixture.h @@ -6,6 +6,7 @@ // The parser uses the SDK's actual GATT types/accessors. Only the asynchronous // radio, run loop, platform admission and persistence endpoints are simulated. enum fixture_query { QUERY_NONE, QUERY_SERVICE, QUERY_CHARACTERISTICS, QUERY_DESCRIPTORS, QUERY_CCCD, QUERY_WRITE }; +#define FIXTURE_RUMBLE_HISTORY 128 struct fixture_peer { uni_hid_device_t device; bool used, link_alive; @@ -16,7 +17,10 @@ struct fixture_peer { uint16_t command_length; const uint8_t* pending_write; uint16_t pending_length; + uint8_t pending_snapshot[33]; uint8_t rumble[33]; uint16_t rumble_length; unsigned commands, rumbles; + uint8_t rumble_history[FIXTURE_RUMBLE_HISTORY][33]; + uint32_t rumble_times[FIXTURE_RUMBLE_HISTORY]; }; diff --git a/tests/switch2_parser_native_test.c b/tests/switch2_parser_native_test.c index e6c2aab..df03e77 100644 --- a/tests/switch2_parser_native_test.c +++ b/tests/switch2_parser_native_test.c @@ -220,6 +220,9 @@ static uint8_t capture_write(hci_con_handle_t handle, uint16_t value_handle, uin assert(value_handle == RUMBLE_HANDLE && length <= sizeof(peer->rumble)); memcpy(peer->rumble, value, length); peer->rumble_length = length; + unsigned slot = peer->rumbles % FIXTURE_RUMBLE_HISTORY; + memcpy(peer->rumble_history[slot], value, length); + peer->rumble_times[slot] = now_ms; ++peer->rumbles; } return 0; @@ -235,6 +238,7 @@ uint8_t gatt_client_write_value_of_characteristic(btstack_packet_handler_t callb struct fixture_peer* peer = peer_for_handle(handle); peer->pending_write = value; peer->pending_length = length; + memcpy(peer->pending_snapshot, value, length); return begin_query(callback, handle, QUERY_WRITE); } @@ -269,6 +273,8 @@ static void query_done(struct fixture_peer* peer, uint8_t status) { uint8_t data[9] = {GATT_EVENT_QUERY_COMPLETE}; if (peer->query == QUERY_CCCD) assert(peer->pending_length == 2 && peer->pending_write[0] == 1 && peer->pending_write[1] == 0); + if (peer->query == QUERY_WRITE) + assert(memcmp(peer->pending_write, peer->pending_snapshot, peer->pending_length) == 0); peer->query = QUERY_NONE; data[8] = status; event(peer, data, sizeof(data)); @@ -617,7 +623,7 @@ static void test_rumble_delay_expiry_retry_and_teardown(void) { advance(24); assert(!rumble_active(peer)); uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 12, 34); - advance(70000); // Stateful host rumble does not expire at 65.535 seconds. + advance(70000); // Held local feedback does not expire at65.535 seconds. assert(((rumble_frame(peer) >> 10) & 1023) == 136 && ((rumble_frame(peer) >> 30) & 1023) == 48); uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 100, 255, 255); advance(1); @@ -664,6 +670,360 @@ static void test_write_request_buffers_and_failed_completion(void) { assert(peer->rumbles == writes); } +static struct fixture_peer* ready_peer(uint16_t pid, bool requests) { + reset(); + request_writes = requests; + struct fixture_peer* peer = connect_peer(pid, false); + discover(peer); + subscribe_response(peer); + finish_setup(peer); + return peer; +} + +static uni_switch2_haptics_frame_t native_frame(unsigned left, unsigned right, unsigned seed) { + uni_switch2_haptics_frame_t frame = {0}; + frame.sides[0].count = left; + frame.sides[1].count = right; + for (unsigned side = 0; side < 2; ++side) { + for (unsigned i = 0; i < frame.sides[side].count; ++i) { + uni_switch2_haptics_encode_sample(frame.sides[side].samples[i], + 10 + seed + 5 * i + side, 80 + seed + i + side, + 1000 + seed * 100 + i * 200 + side * 3000, + 5000 + seed * 100 + i * 700 + side * 500); + } + } + return frame; +} + +static uni_switch2_haptics_side_t final_hold(const uni_switch2_haptics_side_t* source) { + uni_switch2_haptics_side_t side = {.count = 1}; + memcpy(side.samples[0], source->samples[source->count - 1], 5); + return side; +} + +static void assert_block(const struct fixture_peer* peer, unsigned side, + const uni_switch2_haptics_side_t* expected) { + assert(peer->rumble_length >= 17 + side * 16); + const uint8_t* block = peer->rumble + 1 + side * 16; + assert((block[0] & 0xf0) == (0x40 | (expected->count << 4))); + assert(memcmp(block + 1, expected->samples, 5 * expected->count) == 0); + for (unsigned i = 1 + 5 * expected->count; i < 16; ++i) + assert(block[i] == 0); +} + +static void assert_silent(const struct fixture_peer* peer, unsigned side) { + const uint8_t* block = peer->rumble + 1 + side * 16; + assert((block[0] & 0xf0) == 0x50); + assert(!(block[2] & 0xfc) && !(block[3] & 0x0f) && !(block[4] & 0xc0) && !block[5]); + for (unsigned i = 6; i < 16; ++i) + assert(block[i] == 0); +} + +static void test_native_fifo_stereo_counts_and_retry(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false); + uni_switch2_haptics_frame_t a = native_frame(3, 2, 1); + uni_switch2_haptics_frame_t b = native_frame(2, 0, 2); + uni_switch2_haptics_frame_t c = native_frame(1, 3, 3); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms)); + next_write_error = BTSTACK_ACL_BUFFERS_FULL; + advance(1); + assert(peer->rumbles == 0); + advance(13); + assert(peer->rumbles == 1 && peer->rumble_length == 33); + assert(peer->rumble[1] == 0x70 && peer->rumble[17] == 0x60); + assert_block(peer, 0, &a.sides[0]); + assert_block(peer, 1, &a.sides[1]); + // C arrives while A plays, with enough source lifetime for all three frames. + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &c, now_ms)); + advance(15); + assert(peer->rumbles == 1); // Three samples cannot be interrupted at13ms. + advance(1); + assert(peer->rumbles == 2 && peer->rumble[1] == 0x61); + assert_block(peer, 0, &b.sides[0]); + uni_switch2_haptics_side_t right = final_hold(&a.sides[1]); + assert_block(peer, 1, &right); + advance(10); + assert(peer->rumbles == 2); + advance(1); + assert(peer->rumbles == 3 && peer->rumble[1] == 0x52 && peer->rumble[17] == 0x72); + assert_block(peer, 0, &c.sides[0]); + assert_block(peer, 1, &c.sides[1]); + assert(peer->rumble_times[1] - peer->rumble_times[0] == 16); + assert(peer->rumble_times[2] - peer->rumble_times[1] == 11); + assert(uni_hid_parser_switch2_haptics_dropped() == drops); + advance(23); // C expires at receipt14 +50, not transmission41 +50. + assert_silent(peer, 0); + assert_silent(peer, 1); +} + +static void test_native_hold_and_absent_side_watchdogs(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false); + uni_switch2_haptics_frame_t a = native_frame(3, 2, 4); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + assert_block(peer, 0, &a.sides[0]); + advance(15); + assert(peer->rumbles == 1); + advance(1); + uni_switch2_haptics_side_t left = final_hold(&a.sides[0]); + uni_switch2_haptics_side_t right = final_hold(&a.sides[1]); + assert(peer->rumbles == 2); + assert_block(peer, 0, &left); + assert_block(peer, 1, &right); + advance(3); // t20; only the left actuator receives a fresh source update. + uni_switch2_haptics_frame_t b = native_frame(1, 0, 5); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms)); + advance(3); // Last count1 hold guarded until t23. + assert_block(peer, 0, &b.sides[0]); + assert_block(peer, 1, &right); + advance(27); + assert_block(peer, 0, &b.sides[0]); + assert_silent(peer, 1); // Absent side must not gain a new50ms watchdog. + advance(20); + assert_silent(peer, 0); + assert_silent(peer, 1); +} + +static void test_feedback_advances_host_and_resumes_only_final_samples(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + uni_switch2_haptics_frame_t a = native_frame(2, 3, 6); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 40, 20, 30); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + assert(((rumble_frame(peer) >> 10) & 1023) == 120); + assert((peer->rumble[1] & 0xf0) == 0x50); + advance(9); + uni_switch2_haptics_frame_t b = native_frame(3, 0, 7); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms)); + advance(1); + assert(((rumble_frame(peer) >> 10) & 1023) == 120); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0); + advance(1); + uni_switch2_haptics_side_t left = final_hold(&b.sides[0]); + uni_switch2_haptics_side_t right = final_hold(&a.sides[1]); + assert_block(peer, 0, &left); + assert_block(peer, 1, &right); + for (unsigned i = 0; i < peer->rumbles; ++i) { + assert((peer->rumble_history[i][1] & 0xf0) == 0x50); + assert((peer->rumble_history[i][17] & 0xf0) == 0x50); + } + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 100, 50, 60); + advance(48); // Both original host watchdogs elapse under feedback. + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0); + advance(1); + assert_silent(peer, 0); + assert_silent(peer, 1); + assert(uni_hid_parser_switch2_haptics_dropped() == drops); +} + +static void test_full_fifo_stop_barrier_and_async_completion(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_R_PID, true); + uni_switch2_haptics_frame_t a = native_frame(3, 0, 8); + uni_switch2_haptics_frame_t b = native_frame(2, 0, 9); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + assert(peer->query == QUERY_WRITE && peer->rumble_length == 17); + const uint8_t* borrowed = peer->pending_write; + uint8_t snapshot[17]; + memcpy(snapshot, borrowed, sizeof(snapshot)); + for (unsigned i = 1; i < 16; ++i) + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + assert(uni_hid_parser_switch2_haptics_dropped() == drops); + uni_switch2_haptics_frame_t stop; + uni_switch2_haptics_silence(&stop); + stop.sides[1].count = 0; // Physical Joy-Con stop is not a logical stereo stop. + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stop, now_ms - 100)); + assert(uni_hid_parser_switch2_haptics_dropped() == drops + 16); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms)); + advance(1); + assert(memcmp(snapshot, borrowed, sizeof(snapshot)) == 0); + query_done(peer, 0); // Completion of old epoch must not consume b/the stop. + advance(1); + assert(peer->rumbles == 2 && peer->rumble[1] == 0x51); + assert_silent(peer, 0); + query_done(peer, 0); + advance(6); + assert(peer->rumbles == 3 && peer->rumble[1] == 0x62); + assert_block(peer, 0, &b.sides[0]); + query_done(peer, 0); + advance(11); + uni_switch2_haptics_side_t hold = final_hold(&b.sides[0]); + assert_block(peer, 0, &hold); + query_done(peer, 0); +} + +static void test_async_expiry_and_topology_reset_do_not_revive_state(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, true); + uni_switch2_haptics_frame_t a = native_frame(3, 2, 10); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + advance(49); // Write request is still borrowing the original batch. + assert(peer->rumbles == 1 && uni_hid_parser_switch2_haptics_dropped() == drops + 1); + query_done(peer, 0); + advance(1); + assert_silent(peer, 0); + assert_silent(peer, 1); + query_done(peer, 0); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 40, 50); + advance(6); + assert(rumble_active(peer) && peer->query == QUERY_WRITE); + uni_switch2_haptics_frame_t b = native_frame(2, 1, 11); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + uni_hid_parser_switch2_reset_haptics(&peer->device); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms)); + advance(1); + query_done(peer, 0); // Old local overlay must not clear the reset neutral. + advance(1); + assert_silent(peer, 0); + assert_silent(peer, 1); + query_done(peer, 0); + advance(6); + assert_block(peer, 0, &b.sides[0]); + assert_block(peer, 1, &b.sides[1]); + query_done(peer, 0); +} + +static void test_host_stop_does_not_cancel_local_feedback(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 100, 200); + uni_switch2_haptics_frame_t a = native_frame(3, 3, 12); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 0, 0, 0, now_ms)); + advance(1); + assert(((rumble_frame(peer) >> 10) & 1023) == 800); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0); + advance(1); + assert_silent(peer, 0); + assert_silent(peer, 1); +} + +static void test_conventional_host_lifetimes_and_invalid_native_input(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_L_PID, false); + assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 255, 200, UINT16_MAX, now_ms)); + advance(70000); + assert((rumble_frame(peer) & 1023) == 0xe1); + assert(((rumble_frame(peer) >> 20) & 1023) == 0x1e1); + assert(((rumble_frame(peer) >> 10) & 1023) == 800); + assert(((rumble_frame(peer) >> 30) & 1023) == 1020); + assert((peer->rumble[1] & 0xf0) == 0x50); + for (unsigned i = 7; i < 17; ++i) + assert(peer->rumble[i] == 0); + assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 1, 2, 20, now_ms - 10)); + advance(9); + assert(((rumble_frame(peer) >> 10) & 1023) == 8); + advance(1); + assert_silent(peer, 0); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + uni_switch2_haptics_frame_t invalid = native_frame(1, 0, 13); + invalid.sides[0].count = 4; + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &invalid, now_ms)); + uni_switch2_haptics_frame_t stale = native_frame(1, 0, 14); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stale, now_ms - 50)); + assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 10, 20, 100, now_ms - 50)); + assert(uni_hid_parser_switch2_haptics_dropped() == drops + 3); + advance(13); + assert_silent(peer, 0); +} + +static void test_native_watchdog_clock_wrap(void) { + reset(); + now_ms = UINT32_MAX - 20; + struct fixture_peer* peer = connect_peer(UNI_SW2_JOYCON_L_PID, false); + discover(peer); + subscribe_response(peer); + finish_setup(peer); + uni_switch2_haptics_frame_t frame = native_frame(1, 0, 15); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + advance(49); + assert_block(peer, 0, &frame.sides[0]); + advance(1); + assert_silent(peer, 0); +} + +static void test_identical_hold_coalescing_refreshes_borrowed_head(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_L_PID, true); + uni_switch2_haptics_frame_t frame = native_frame(1, 0, 16); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + advance(1); + const uint8_t* borrowed = peer->pending_write; + uint8_t snapshot[17]; + memcpy(snapshot, borrowed, sizeof(snapshot)); + advance(39); + for (unsigned i = 0; i < 32; ++i) + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + assert(memcmp(snapshot, borrowed, sizeof(snapshot)) == 0); + advance(10); // The original t0 hold would expire now without source refresh. + assert(uni_hid_parser_switch2_haptics_dropped() == drops); + query_done(peer, 0); + advance(39); + assert_block(peer, 0, &frame.sides[0]); + query_done(peer, 0); + advance(1); // Refreshed deadline remains t40+50, not ATT completion+50. + assert_silent(peer, 0); + assert(uni_hid_parser_switch2_haptics_dropped() == drops); + query_done(peer, 0); +} + +static void test_hold_coalescing_requires_identical_samples_and_side_masks(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, true); + uni_switch2_haptics_frame_t frame; + for (unsigned i = 0; i < 16; ++i) { + frame = native_frame(1, 1, i + 1); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + } + // An identical tail hold can refresh even at capacity. A partial-side update + // is a different command, as is a changed sample; neither may erase history. + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + frame.sides[1].count = 0; + assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + frame = native_frame(1, 1, 17); + assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + advance(1); + uni_switch2_haptics_frame_t first = native_frame(1, 1, 1); + assert_block(peer, 0, &first.sides[0]); + assert_block(peer, 1, &first.sides[1]); +} + +static void test_expired_history_does_not_starve_fresh_sequences(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false); + uni_switch2_haptics_frame_t a = native_frame(3, 3, 20); + uni_switch2_haptics_frame_t stale = native_frame(3, 3, 21); + uni_switch2_haptics_frame_t fresh = native_frame(3, 3, 22); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + unsigned sent = peer->rumbles; + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stale, now_ms - 40)); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms)); + advance(10); + assert(peer->rumbles == sent); // Unplayed stale history must not interrupt A. + advance(6); + assert_block(peer, 0, &fresh.sides[0]); + assert_block(peer, 1, &fresh.sides[1]); + + peer = ready_peer(UNI_SW2_PRO_PID, false); + a = native_frame(1, 1, 23); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms - 43)); + advance(1); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms)); + advance(6); // A's source watchdog expires as its playback guard ends. + assert_block(peer, 0, &fresh.sides[0]); // No unnecessary silent/HOLD packet. + + peer = ready_peer(UNI_SW2_PRO_PID, false); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms - 40)); + advance(1); + assert_silent(peer, 0); // Nine ms cannot contain a complete three-frame batch. + assert(uni_hid_parser_switch2_haptics_dropped() == drops + 1); +} + int main(void) { test_connected_callback_rejection(); test_advertisement_bounds_and_admission(); @@ -672,6 +1032,17 @@ int main(void) { test_calibration_physical_inputs_and_sensor_units(); test_rumble_delay_expiry_retry_and_teardown(); test_write_request_buffers_and_failed_completion(); + test_native_fifo_stereo_counts_and_retry(); + test_native_hold_and_absent_side_watchdogs(); + test_feedback_advances_host_and_resumes_only_final_samples(); + test_full_fifo_stop_barrier_and_async_completion(); + test_async_expiry_and_topology_reset_do_not_revive_state(); + test_host_stop_does_not_cancel_local_feedback(); + test_conventional_host_lifetimes_and_invalid_native_input(); + test_native_watchdog_clock_wrap(); + test_identical_hold_coalescing_refreshes_borrowed_head(); + test_hold_coalescing_requires_identical_samples_and_side_masks(); + test_expired_history_does_not_starve_fresh_sequences(); reset(); puts("Switch2 protocol boundaries, setup failure, pairing, calibration, physical input, motion and rumble passed"); return 0; diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index e84653e..a333994 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -83,6 +83,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: f"-I{root / 'src' / 'firmware'}", f"-I{root / 'bluepad32_config'}", str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"), + str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"), str( root / "src" / "firmware" / "input" / "controller_macro_capture.cpp" ), @@ -98,6 +99,13 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "switch2-multiple-pairs", "switch2-admission", "switch2-pairing-inventory", + "switch2-hd-pro", + "switch2-hd-solo-left", + "switch2-hd-solo-right", + "switch2-hd-pair", + "switch2-hd-overflow", + "switch2-hd-epochs", + "switch2-hd-feedback", ): subprocess.run([str(executable), scenario], check=True, cwd=root) if native: diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index a2c5776..747701b 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -311,7 +311,8 @@ class FakeDevice: if request == config_manager.OP_RUNTIME_DIAGNOSTICS: return make_response( request, - struct.pack("<7I4B", 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1), + struct.pack("<7I4B", 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1) + + getattr(self, "runtime_diagnostics_tail", b""), ) if request == config_manager.OP_NATIVE_SWITCH_RUMBLE: return make_response( @@ -1399,6 +1400,20 @@ def test_reenumeration_reports_missing_disappearance_and_return( config_manager._wait_for_reenumeration(snapshot, 0) +def test_runtime_diagnostics_distinguishes_unreported_from_zero_drops() -> None: + device = FakeDevice() + legacy = config_manager.read_runtime_diagnostics(device) + assert legacy.switch2_ingress_drops is None + assert legacy.switch2_output_drops is None + device.runtime_diagnostics_tail = struct.pack(" None: device = FakeDevice() device.configuration = struct.pack( diff --git a/tests/test_switch2_haptics_native.py b/tests/test_switch2_haptics_native.py new file mode 100644 index 0000000..1de129d --- /dev/null +++ b/tests/test_switch2_haptics_native.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_switch2_haptics_encoding(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("cc") or shutil.which("gcc") + assert compiler is not None, "a host C compiler is required" + executable = tmp_path / "switch2_haptics" + subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root / 'bluepad32_config'}", + str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"), + str(root / "tests" / "switch2_haptics_test.c"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_switch2_parser_native.py b/tests/test_switch2_parser_native.py index 6d4407f..89549a8 100644 --- a/tests/test_switch2_parser_native.py +++ b/tests/test_switch2_parser_native.py @@ -46,6 +46,7 @@ def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None: f"-I{btstack.parent / '3rd-party' / 'yxml'}", str(root / "tests" / "switch2_parser_native_test.c"), str(root / "bluepad32_config" / "parser" / "uni_hid_parser_switch2.c"), + str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"), str(btstack / "btstack_util.c"), "-Wl,--gc-sections", "-o", diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index d803eb0..51ded66 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -228,7 +228,7 @@ void test_vendor_requests() { "pairing read did not use the versioned envelope"); current_diagnostics = { - 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1, + 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1, 3, UINT32_MAX, }; request = setup_request( Operation::kRuntimeDiagnostics, TUSB_DIR_IN, @@ -241,7 +241,9 @@ void test_vendor_requests() { read_u32(control_payload, kResponseHeaderSize) == 6 && read_u32(control_payload, kResponseHeaderSize + 4) == 1200 && control_payload[kResponseHeaderSize + 28] == 2 && - control_payload[kResponseHeaderSize + 31] == 1, + control_payload[kResponseHeaderSize + 31] == 1 && + read_u32(control_payload, kResponseHeaderSize + 32) == 3 && + read_u32(control_payload, kResponseHeaderSize + 36) == UINT32_MAX, "runtime diagnostics did not expose backend counters"); perform_out(Operation::kPairingRefresh, {});