feat: preserve native HD rumble on Switch 2 controllers

This commit is contained in:
Joey Yakimowich-Payne 2026-09-07 08:19:53 -06:00
commit 006b573ee8
25 changed files with 1722 additions and 60 deletions

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@
#include "parser/uni_hid_parser_switch2.h"
#include <math.h>
#include <stdatomic.h>
#include <string.h>
#include <btstack.h>
@ -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);
}

View file

@ -3,6 +3,7 @@
#include <stdbool.h>
#include <stdint.h>
#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);

View file

@ -0,0 +1,84 @@
// SPDX-License-Identifier: Apache-2.0
#include "parser/uni_switch2_haptics.h"
#include <stddef.h>
#include <string.h>
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;
}

View file

@ -0,0 +1,36 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <stdbool.h>
#include <stdint.h>
#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

Binary file not shown.

Binary file not shown.

View file

@ -24,6 +24,7 @@
#include <pico/stdlib.h>
#include <uni.h>
#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<uint8_t>(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<uint32_t>(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

View file

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

View file

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

View file

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

View file

@ -2,14 +2,15 @@
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#include <algorithm>
#include <array>
#include <vector>
#endif
#include <uni.h>
#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<Switch2HostEvent> 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);

View file

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

View file

@ -0,0 +1,70 @@
#include <assert.h>
#include <stdint.h>
#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;
}

View file

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

View file

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

View file

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

View file

@ -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("<II", 0, 0xFFFFFFFF)
current = config_manager.read_runtime_diagnostics(device)
assert current.switch2_ingress_drops == 0
assert current.switch2_output_drops == 0xFFFFFFFF
device.runtime_diagnostics_tail = bytes(4)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_runtime_diagnostics(device)
def test_requested_and_active_mode_response_validation() -> None:
device = FakeDevice()
device.configuration = struct.pack(

View file

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

View file

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

View file

@ -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, {});