Fix DualSense native audio initialization and default to accepted 32-frame format

This commit is contained in:
Joey Yakimowich-Payne 2026-09-08 10:15:07 -06:00
commit 46f42a26dc
19 changed files with 304 additions and 176 deletions

View file

@ -854,6 +854,18 @@ The requested fast-link default is not a claim of reliable mixed native PCM,
lossless transport or qualified endurance. DualSense buffer/cadence work remains
separate; no hidden gain or compatibility fallback change is included.
DualSense native startup/format correction now defaults to the physically
accepted 32-frame/3-kHz path: a state-only AudioControl-enable report followed by
the full control header and one 64-byte PCM block. Captures verified independent
left/right PCM peaks, and the user confirmed strong output and clean stops at
96/127 without a global gain change. The compact 64-frame/buffer-16 candidate
felt worse despite zero skips and remains an explicit unqualified experiment.
The accepted trial still skipped ten slots across its strong-pulse run, with
no send failures. Mixed-controller and endurance qualification remain open.
The default finite fixture retains its 6.144-second timeline using 576 reports;
host metadata follows the reported packet size. Temporary tuning controls and
wire-capture hooks are not included in production.
The native-HD checkpoint passed 289 tests and all five firmware builds; its
40 pre-existing profiles and metadata survived. Its inventory then contained
56 profiles across seven identities. Temporary measurement hooks were removed,

View file

@ -29,9 +29,11 @@ option(SWITCH_PICO_ADAPTER_FEASIBILITY
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
set(SWITCH_PICO_NATIVE_DEFAULT ON)
set(SWITCH_PICO_CLOCK_DEFAULT 300)
set(SWITCH_PICO_PACKET_FRAMES_DEFAULT 32)
else()
set(SWITCH_PICO_NATIVE_DEFAULT OFF)
set(SWITCH_PICO_CLOCK_DEFAULT 150)
set(SWITCH_PICO_PACKET_FRAMES_DEFAULT 64)
endif()
option(SWITCH_PICO_HAPTICS_EXPERIMENT
"Enable native DualSense haptics and transport diagnostics" ${SWITCH_PICO_NATIVE_DEFAULT})
@ -62,16 +64,17 @@ option(SWITCH_PICO_HCI_CREDIT_BATCH
if(SWITCH_PICO_HD_RUMBLE)
set(SWITCH_PICO_HAPTICS_EXPERIMENT ON)
endif()
set(SWITCH_PICO_HD_PACKET_FRAMES "64" CACHE STRING
"Native gameplay frames per packet: 64 standard, 32 single-controller experiment")
set_property(CACHE SWITCH_PICO_HD_PACKET_FRAMES PROPERTY STRINGS 64 32)
set(SWITCH_PICO_HD_PACKET_FRAMES "${SWITCH_PICO_PACKET_FRAMES_DEFAULT}" CACHE STRING
"Native stereo frames per packet: 32 default, 64 unqualified experiment")
set_property(CACHE SWITCH_PICO_HD_PACKET_FRAMES PROPERTY STRINGS 32 64)
if(NOT SWITCH_PICO_HD_PACKET_FRAMES MATCHES "^(32|64)$")
message(FATAL_ERROR "SWITCH_PICO_HD_PACKET_FRAMES must be 32 or 64")
endif()
if(SWITCH_PICO_HD_PACKET_FRAMES STREQUAL "32")
if(NOT SWITCH_PICO_HAPTICS_EXPERIMENT OR NOT SWITCH_PICO_CYW43_PACKET_READ
OR NOT SWITCH_PICO_HCI_CREDIT_BATCH OR SWITCH_PICO_SYS_CLOCK_MHZ LESS 300)
message(FATAL_ERROR "32-frame qualification requires native transport, packet reads, credit batching and at least 300 MHz")
if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32"
AND SWITCH_PICO_HAPTICS_EXPERIMENT AND SWITCH_PICO_HD_PACKET_FRAMES STREQUAL "32")
if(NOT SWITCH_PICO_CYW43_PACKET_READ OR NOT SWITCH_PICO_HCI_CREDIT_BATCH
OR SWITCH_PICO_SYS_CLOCK_MHZ LESS 300)
message(FATAL_ERROR "32-frame native transport requires packet reads, credit batching and at least 300 MHz")
endif()
endif()
set(SWITCH_PICO_INPUT_BACKEND "UART" CACHE STRING "Controller input backend")

View file

@ -25,15 +25,33 @@ Sources:
- Reconstructed substep reference: https://github.com/HandHeldLegend/NS-LIB-HID/blob/becc24f0841bbb875da24ea622cc1ada00cb8492/docs/hd-rumble-implementation-guide.md
- Eight-millisecond playback-window reference: https://github.com/HandHeldLegend/HOJA-LIB-RP2040/blob/238f66d1c4aae87fc320d94d8abd38229e7da2d0/src/utilities/pcm.c
## Current accepted native format
The current default is **32 stereo frames at 3 kHz**, with explicit state-only
audio initialization and the full SAxense-style control header described below.
Isolated hardware capture verified independent left/right PCM peaks of 32, 63
and 96, with the opposite channel zero and no compatibility-selector reports
during the tones. The user confirmed strong, distinct sides and clean stops at
peak 96/127. The gain curve was not increased to obtain this result.
The compact 64-frame/buffer-16 candidate felt worse despite zero skipped packets.
It is retained only as an explicit, physically unqualified experiment. That
comparison changed the control header and buffer field as well as frame count;
it does not establish that batching alone caused the difference. The accepted
32-frame trial had ten skipped slots across its strong-pulse run and no send
failures, so mixed-radio and long-duration qualification remain outstanding.
Earlier results below are historical transport measurements, not approval of
the current or rejected formats' physical fidelity.
## Implementation contract
1. AIO/XInput defaults enable `SWITCH_PICO_HAPTICS_EXPERIMENT`, `SWITCH_PICO_HD_RUMBLE`, packet-level CYW43 reads and bounded HCI credit batching at 300 MHz/1.3 V. UART is unchanged. Preserve wake identity, pairing storage and USB modes. Incoming flow control and FIFO capacities remain unchanged; the controller's advertised outgoing capacity is eight ACL packets on this hardware.
2. One selected Sony DualSense/DualSense Edge, Bluetooth Classic, sufficient negotiated MTU. Auto-arm chooses the first eligible ready controller, not necessarily slot 0, and later controllers do not steal an active stream. The fixture requires explicit start. Idle native output remains silent. Other devices use compatibility output unless explicitly approved for the separate Nintendo-native backend described in `SWITCH_FAMILY_HD_RUMBLE_PLAN.md`.
3. Report 0x32 plus A2 remains a 143-byte L2CAP SDU. The first report selects native mode with sized state block 0x90/63 and one silent 0x92/64 haptic block. Subsequent reports use compact controls `{0x91,3,0x62,16,counter}`. Standard gameplay and the fixture carry two blocks (64 stereo frames, descriptor 0xd2). Explicit `SWITCH_PICO_HD_PACKET_FRAMES=32` carries one 64-byte block (descriptor 0x92) for single-controller qualification only. The counter advances by the number of blocks. Padding and Bluetooth CRC remain deterministic. No speaker, microphone, USB audio endpoint, Opus or resampler.
3. Report 0x32 plus A2 remains a 143-byte L2CAP SDU. The first report is state-only: sequence/tag byte 0x10, sized state block 0x90/63, and valid flag0 0x80 to write AudioControl with default route/MicSelect. Other state validity flags stay clear: no volume, preamp, mute, trigger or LED change. It carries no PCM. Default subsequent controls are `{0x91,7,0xfe,0,0,0,0,0xff,counter}`, followed by `{0x92,64}` and one 64-byte PCM block (32 stereo frames). The data counter begins at zero after initialization and advances by one. The 0xff field is a reference parameter, not an established millisecond duration. Explicit 64-frame mode retains compact controls `{0x91,3,0x62,16,counter}`, two blocks under 0xd2, and a counter advancing by two; it is not the accepted default. Padding and Bluetooth CRC remain deterministic. No speaker/microphone stream, USB audio endpoint, Opus or resampler is added.
4. At 3 kHz, 32/64 stereo frames require 93.75/46.875 reports/s. Absolute rational deadlines preserve fractional time and skip obsolete packets after stalls rather than burst-replaying them. Timer wakeups account for SDK +1 tick. Can-send permission and audio deadlines remain separate; flags are armed before requests and synchronous callbacks cannot recursively generate a stream.
5. The deterministic fixture remains a finite 288-report / 6.144-second sequence: 48 priming intervals, four cycles of left 100 Hz / silence / right 200 Hz / silence (12 reports = 256 ms per phase), then 48 trailing-silence reports. Its peak remains 32/127. Gameplay is continuous, has no one-second priming pattern, and uses timestamped Switch commands instead. Stop restores compatibility output; disconnect cancels without stale-pointer use.
5. The default deterministic fixture is 576 reports over 6.144 seconds: 96 priming slots, four cycles of left 100 Hz / silence / right 200 Hz / silence (24 reports = 256 ms per phase), then 96 trailing-silence reports. The state-only initialization occupies the first priming slot and counts as one report, with zero PCM frames. Explicit 64-frame mode preserves the same timeline with 288 total reports, 48 priming/trailing slots and 12 reports per phase. Peak remains 32/127, not full-strength rumble or a calibrated physical-force percentage. Gameplay has no one-second priming pattern and uses timestamped Switch commands. Stop restores compatibility output; disconnect cancels without stale-pointer use.
6. No historical PCM FIFO. Generate only the current due block when transmission is permitted; bounded control mailbox across cores. Record packet counts, skipped blocks, failed sends, synchronous callbacks, generation cost, send gaps, lateness, request wait and first-tone timestamps. HCI submission is not physical actuator onset.
7. Host `haptics-experiment start`, `gameplay`, `status`, `stop`, and `profile` retain USB management framing. AIO builds enable these operations; explicitly disabled/UART builds do not. Operation 0x40 uses schema 5 and transport profiling uses schema 3. Update firmware and host tools together.
7. Host `haptics-experiment start`, `gameplay`, `status`, `stop`, and `profile` retain USB management framing. AIO builds enable these operations; explicitly disabled/UART builds do not. Operation 0x40 uses schema 5 and transport profiling uses schema 3. Both fixture and gameplay diagnostics report the actual configured frame count; host metadata derives packet counts and timing from it. Update firmware and host tools together.
8. Regression coverage must include synchronous callback delivery, rational clock and late wakeups, reference packet interpretation, finite completion/stop, disconnect/reconnect and compatibility restoration. Native probes cannot prove controller acceptance or physical latency.
## Gameplay mode
@ -46,7 +64,7 @@ cmake -S . -B build-hd-rumble -DPICO_BOARD=pico2_w \
-DSWITCH_PICO_HD_RUMBLE=ON \
-DSWITCH_PICO_SYS_CLOCK_MHZ=300 -DSWITCH_PICO_OVERCLOCK_MV=1300 \
-DSWITCH_PICO_CYW43_PACKET_READ=ON -DSWITCH_PICO_HCI_CREDIT_BATCH=ON \
-DSWITCH_PICO_HD_PACKET_FRAMES=64 \
-DSWITCH_PICO_HD_PACKET_FRAMES=32 \
-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=ON -DSWITCH_PICO_LOG=OFF
cmake --build build-hd-rumble
```
@ -57,7 +75,7 @@ The decoder preserves each actuator's one-to-three ordered substeps and frequenc
The synthesizer has independent left/right low/high phase accumulators. Frequencies are `40 * 2^(index/32)` and `80 * 2^(index/32)` Hz. Each Switch command occupies an 8 ms window, split into 24/12/8 PCM samples per substep for counts 1/2/3. New reports supersede unplayed old substeps; identical compressed words hold final state rather than replaying deltas. Each Switch-updated side expires after 50 ms, matching the existing conservative timeout policy.
Standard gameplay uses 21.333 ms causal lookback; the explicit 32-frame experiment uses 10.667 ms. Fixed 16-entry cross-core and synthesis histories contain commands, not PCM. Overflow is counted and obsolete sample intervals are not replayed. XInput holds use a distinct persistent command: strong/low magnitude drives the left 160 Hz band, weak/high magnitude drives the right 320 Hz band, until a new command or zero stop. They do not fake refreshes to evade the 50 ms Switch watchdog. Retained XInput state is seeded once per native run, including manual re-arming after compatibility output.
Standard gameplay uses 10.667 ms causal lookback. The explicitly selected, unqualified 64-frame experiment uses 21.333 ms. Fixed 16-entry cross-core and synthesis histories contain commands, not PCM. Overflow is counted and obsolete sample intervals are not replayed. XInput holds use a distinct persistent command: strong/low magnitude drives the left 160 Hz band, weak/high magnitude drives the right 320 Hz band, until a new command or zero stop. They do not fake refreshes to evade the 50 ms Switch watchdog. Retained XInput state is seeded once per native run, including manual re-arming after compatibility output.
Native gameplay uses balanced **2x low/high gain after profile scaling**, followed by a gentle **0.8-power curve** on the combined amplitude. This lifts quiet and medium effects while retaining their low/high ratio. The curve is a 257-entry lookup with integer interpolation, not per-sample floating-point math. Combined weights are capped at 65535 to avoid overflow and clipping. Zero remains zero. The amplitude curve does not alter carrier frequencies or local-confirmation gain; packet timing follows the transport configuration above. This response replaced the initial 1.5x and low-band-only experiments after user comparison.
@ -289,7 +307,7 @@ profiles were compared with the pre-migration backup; the temporary editor
profile and name were restored. No configuration, bond, or wake-identity reset
was part of the transport work.
### Mixed-controller cadence limit
### Historical mixed-controller cadence limit
Final testing with a Switch Pro plus a DualSense and continuous USB motion
reads changed the cadence decision. Rumble commands targeted only the
@ -299,8 +317,9 @@ host commands but skipped **80 audio slots in 16.6 s**. Maximum permission
wait reached **17,180 us**, exceeding its 10,667 us interval, with all eight
outgoing credits observed in use. CPU clock remained 300 MHz/1.3 V.
The standard build therefore uses **64 frames / 46.875 reports per second**,
without reverting its CPU or transport improvements. The same 2,050-command
Those measurements led the earlier standard build to use **64 frames / 46.875
reports per second**, without reverting its CPU or transport improvements.
The same 2,050-command
mixed-controller comparison passed with zero drops/skips/send failures and
778 audio reports. Its worst observed report gap was 26,588 us. A subsequent
roughly 65-second mixed-controller stress run received all **8,194 commands**
@ -308,15 +327,15 @@ and submitted **3,082 audio reports**, with **zero drops, skipped audio slots,
or send failures**. It processed 41,738 input reports and its worst observed
audio report gap was 26,655 us.
The 32-frame path remains an explicit single-controller experiment and is
covered by the same native protocol/lifecycle tests; it is not advertised as
sustainable for mixed/four-controller operation.
That earlier transport-only choice is superseded by the current accepted
32-frame format and explicit audio initialization above. Neither configuration
is advertised as qualified for mixed/four-controller physical fidelity.
The later Nintendo-native implementation adds output traffic that was absent
from this cadence comparison. Its Pro-only controlled run delivered all 1,025
commands at 125 Hz, but early mixed Pro/DualSense runs exposed shared-radio
congestion. Nintendo can-send-driven delivery and held-state coalescing are
separate from the unchanged DualSense 64-frame policy. Consult
separate from that earlier DualSense 64-frame policy. Consult
[SWITCH_FAMILY_HD_RUMBLE_PLAN.md](SWITCH_FAMILY_HD_RUMBLE_PLAN.md) for measured
results and outstanding qualification; do not treat the DualSense-only output
benchmark above as proof that simultaneous native streams are lossless.

View file

@ -547,7 +547,9 @@ The standard AIO and XInput builds use **300 MHz at 1.3 V**, packet-level CYW43
In Switch mode, that stream preserves decoded left/right, low/high-band HD commands. In XInput mode, strong/low magnitude drives the left 160 Hz carrier and weak/high drives the right 320 Hz carrier; these commands stay active until changed or stopped. XInput does not supply Nintendo frequency/substep detail. USB reset, unmount, and suspend stop held host rumble. Auto-mode XInput additionally reboots to Switch probe after unmount, by the existing one-attachment policy; manual XInput is exempt.
Standard native gameplay uses **64 stereo frames at 3 kHz** per Bluetooth report (46.875 reports/s), with 21.333 ms causal lookback. The 32-frame mode passed single-controller tests but skipped audio slots under mixed Pro/DualSense load, so it is an explicit experiment: `SWITCH_PICO_HD_PACKET_FRAMES=32` requires the optimized transport and at least 300 MHz. It uses 93.75 reports/s and 10.667 ms lookback but is not the mixed-controller default. Native streaming continues silence while idle; no physical actuator-onset bound is claimed. See [HAPTICS_EXPERIMENT.md](HAPTICS_EXPERIMENT.md).
Standard native gameplay uses **32 stereo frames at 3 kHz** per Bluetooth report (93.75 reports/s), with 10.667 ms causal lookback. Startup first writes a state-only AudioControl-enable report, then sends the full control header and one 64-byte PCM block used by the physically accepted reference. This requires packet-level reads, credit batching and at least 300 MHz; the normal AIO/XInput defaults already provide them. The user confirmed strong, distinct left/right native output and clean stops. The gain curve is unchanged.
`SWITCH_PICO_HD_PACKET_FRAMES=64` retains the compact two-block format only as an explicit, physically unqualified experiment: it felt worse despite clean transport counters. Mixed-controller and long-duration fidelity still need qualification. Native streaming continues silence while idle; no physical actuator-onset bound or lossless-radio claim is made. See [HAPTICS_EXPERIMENT.md](HAPTICS_EXPERIMENT.md) for exact initialization, packet formats, and the distinction between current acceptance and historical measurements.
400 MHz is an explicit experiment: use `SWITCH_PICO_SYS_CLOCK_MHZ=400` and `SWITCH_PICO_OVERCLOCK_MV=1400`. This board did not boot at 400 MHz/1.3 V; 1.4 V booted and passed a short run but did not outperform 300 MHz in the comparison. USB stays at 48 MHz and flash/radio bus dividers remain bounded. UART builds are unchanged; a stock-clock AIO build is an explicit recovery/compatibility option, not the normal default.
@ -998,15 +1000,15 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file:
| Resource | Used or reserved | Device capacity |
|---|---:|---:|
| Executable flash image | 833,672 bytes | 4 MiB |
| Executable flash image | 833,696 bytes | 4 MiB |
| Indexed profile arenas | 256 KiB | 4 MiB flash |
| Adapter configuration | 8 KiB | 4 MiB flash |
| BTstack bonds and Switch 2 application authorizations | 8 KiB | 4 MiB flash |
| RP2350 terminal sector | 4 KiB | 4 MiB flash |
| Allocated/reserved SRAM, including heap and stacks | 151,820 bytes | 520 KiB |
| Allocated/reserved SRAM, including heap and stacks | 151,844 bytes | 520 KiB |
The executable plus persistent reservations consume 1,116,296 bytes of flash,
leaving 3,078,008 bytes. Allocated SRAM sections leave 380,660 bytes of link-time
The executable plus persistent reservations consume 1,116,320 bytes of flash,
leaving 3,077,984 bytes. Allocated SRAM sections leave 380,636 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

@ -14,14 +14,14 @@ Start with a genuine original Switch Pro Controller, then original standalone Jo
- `44a474e`: roadmap plan for native Switch-family HD rumble.
- `3c2d9fa`: Set B profiles, catalog migration, recording, optimized transport and native DualSense/XInput work.
- Other transport qualification/artifact work may still be in progress. Coordinate with the active agent before editing shared files; do not reset, stash, or overwrite its changes. Re-read current code rather than relying on line numbers here.
- Standard AIO/XInput builds use 300 MHz/1.3 V and optimized CYW43 transport. DualSense retains its separate 64-frame PCM stream. Nintendo native output requires explicit approval of the stable physical Bluetooth identity; unapproved devices keep compatibility output.
- Standard AIO/XInput builds use 300 MHz/1.3 V and optimized CYW43 transport. DualSense now defaults to its physically accepted 32-frame PCM stream with explicit AudioControl initialization and full controls; compact 64-frame mode is an unqualified experiment. Nintendo native output requires explicit approval of the stable physical Bluetooth identity; unapproved devices keep compatibility output.
- Profiles are schema 6 / 384 bytes; catalog 2 keeps a 512-byte record stride and two 128 KiB arenas. Preserve migration, identity keys, names, active indices and atomic publication.
- Adapter configuration is now schema 3 / 232 bytes: up to 16 physical Nintendo approvals, independent of profiles. Old schemas 1/2 migrate with no approvals and preserve their existing settings. No controller is approved merely by its name, VID/PID or parser.
- Preserve Bluetooth bonds, calibration, the UART wire protocol and the private `src/firmware/platform/pico/switch2_wake_config.h`. Do not expose that file's contents or change the configured wake identity.
### Important timing qualification caveat
Do not generalize single-controller DualSense results to mixed-controller loads. A 32-frame/93.75-packet-per-second run passed roughly 65 seconds with one controller, but a later Switch Pro + DualSense test with continuous USB motion reads recorded **80 skipped audio slots over 16.6 seconds**, despite receiving all 2,050 USB commands with no command drops or send failures. Maximum permission wait was 17,180 us and the eight outgoing ACL credits were observed exhausted. The standard native cadence is consequently **64 frames / 46.875 packets per second** at the same 300 MHz/1.3 V, with 32 frames an explicit experiment. Preserve the current cadence choice and coordinate before changing it as part of this Nintendo backend task.
Do not generalize single-controller DualSense results to mixed-controller loads. An earlier 32-frame/93.75-packet-per-second run passed roughly 65 seconds with one controller, but a later Switch Pro + DualSense test with continuous USB motion reads recorded **80 skipped audio slots over 16.6 seconds**, despite receiving all 2,050 USB commands with no command drops or send failures. Maximum permission wait was 17,180 us and the eight outgoing ACL credits were observed exhausted. That led to an earlier 64-frame default based on transport counters. Subsequent physical tests rejected the compact 64-frame format and accepted the 32-frame full-control format with explicit audio initialization, strong separated output and clean stops. Preserve that current choice; mixed-radio physical fidelity is still unqualified.
Nintendo uses no PCM stream. Its small commands still contend for radio scheduling and HCI credits; payload byte rate alone did not predict the measured mixed-controller limit.
@ -284,8 +284,8 @@ Useful next experiments/design directions:
- Adapt slowly using smoothed demand and hysteresis, rather than issuing
radio-parameter updates on every 8 ms rumble change. EMBLEM's illustrated
adaptation takes about four seconds, not a per-haptic-frame response.
- The application output periods share a **64 ms hyperperiod**: eight 8 ms
Pro updates and three 64-frame/3-kHz DualSense reports. A small deadline/phase
- The default application output periods share a **32 ms hyperperiod**: four 8 ms
Pro updates and three 32-frame/3-kHz DualSense reports. A small deadline/phase
analysis is sufficient; the paper's 1,023-node bitmap tree is unnecessary
for four slots. This application calendar is not a Bluetooth radio calendar.

View file

@ -409,7 +409,7 @@ def build(
f"-DSWITCH_PICO_HD_RUMBLE={native}",
f"-DSWITCH_PICO_SYS_CLOCK_MHZ={300 if native == 'ON' else 150}",
"-DSWITCH_PICO_OVERCLOCK_MV=1300",
"-DSWITCH_PICO_HD_PACKET_FRAMES=64",
f"-DSWITCH_PICO_HD_PACKET_FRAMES={32 if native == 'ON' else 64}",
f"-DSWITCH_PICO_CYW43_PACKET_READ={native}",
f"-DSWITCH_PICO_HCI_CREDIT_BATCH={native}",
]

Binary file not shown.

Binary file not shown.

View file

@ -18,16 +18,13 @@
namespace {
#ifndef SWITCH_PICO_HD_PACKET_FRAMES
#define SWITCH_PICO_HD_PACKET_FRAMES 64
#endif
constexpr uint32_t kGameplayFrames = SWITCH_PICO_HD_PACKET_FRAMES;
static_assert(kGameplayFrames == 32 || kGameplayFrames == 64);
constexpr uint32_t kPacketFrames = SWITCH_PICO_HD_PACKET_FRAMES;
static_assert(kPacketFrames == 32 || kPacketFrames == 64);
constexpr uint32_t kPacketDenominator = 3;
constexpr uint32_t kPackets = 288;
constexpr uint32_t kPrimingPackets = 48;
constexpr uint32_t kToneEndPacket = 240;
constexpr uint32_t kPhasePackets = 12;
constexpr uint32_t kPackets = 18432 / kPacketFrames;
constexpr uint32_t kPrimingPackets = 3072 / kPacketFrames;
constexpr uint32_t kToneEndPacket = 15360 / kPacketFrames;
constexpr uint32_t kPhasePackets = 768 / kPacketFrames;
constexpr uint32_t kDrainTimeoutUs = 100000;
// Briefly retain ownership while compatibility output drains. Its parser timer
// is canceled synchronously; it must not survive a device disconnect/reuse.
@ -133,7 +130,7 @@ bool gameplay() {
}
uint32_t packet_numerator_us() {
return (gameplay() ? kGameplayFrames : 64u) * 1000u;
return kPacketFrames * 1000u;
}
void drain_host_updates() {
@ -448,23 +445,35 @@ bool HAPTICS_HOT(generate_packet)(uint8_t* report, uint32_t packet,
uint16_t sample_offset;
uint32_t frames;
if (g_diagnostics.sent_packets == 0) {
// Explicitly leave compatibility mode with a sized 0x10 state block.
// All other state-write flags remain clear, preserving other outputs.
// Enable the controller's audio path before submitting PCM. A zero
// SetState block does not write AudioControl. Keep volume, preamp,
// microphone mute, triggers and lighting validity flags untouched.
report[2] = 0x10;
report[3] = 0x90;
report[4] = 63;
report[68] = 0x92;
report[69] = 64;
sample_offset = 70;
frames = 32; // Initial mode handoff occupies the first silent interval.
report[5] = 0x80; // AllowAudioControl; default route/MicSelect.
sample_offset = 68;
frames = 0; // State-only setup, not an audio sample interval.
} else if (kPacketFrames == 32) {
// Preserve the physically qualified full-control, single-block format.
frames = 32;
report[3] = 0x91;
report[4] = 7;
report[5] = 0xfe;
report[10] = 0xff;
report[11] = static_cast<uint8_t>(g_diagnostics.sent_packets - 1);
report[12] = 0x92;
report[13] = 64;
sample_offset = 14;
} else {
// Compact controls leave room for either one or two 64-byte blocks.
frames = gameplay() ? kGameplayFrames : 64u;
// Retained only as an explicit, physically unqualified experiment.
frames = 64;
report[3] = 0x91;
report[4] = 3;
report[5] = 0x62;
report[6] = 16;
report[7] = static_cast<uint8_t>(g_diagnostics.sent_packets * (frames / 32));
report[8] = frames == 64 ? 0xd2 : 0x92;
report[8] = 0xd2;
report[9] = 64;
sample_offset = 10;
}
@ -488,14 +497,14 @@ bool HAPTICS_HOT(generate_packet)(uint8_t* report, uint32_t packet,
}
}
}
} else if (!gameplay() && !silence && packet >= kPrimingPackets &&
} else if (frames != 0 && !gameplay() && !silence && packet >= kPrimingPackets &&
packet < kToneEndPacket) {
const uint32_t relative = packet - kPrimingPackets;
const uint32_t phase = (relative / kPhasePackets) % 4;
if (phase == 0 || phase == 2) {
tone = true;
const uint32_t stride = phase == 0 ? 1 : 2;
uint32_t wave = ((relative % kPhasePackets) * 64 * stride) % 30;
uint32_t wave = ((relative % kPhasePackets) * kPacketFrames * stride) % 30;
const uint32_t channel = phase == 0 ? 0 : 1;
for (uint32_t frame = 0; frame < frames; ++frame) {
report[sample_offset + frame * 2 + channel] =
@ -760,7 +769,7 @@ void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output) {
const bool waiting = g_snapshot_waiting;
const uint32_t requested_us = g_snapshot_request_us;
critical_section_exit(&g_lock);
output->packet_frames = output->mode == 1 ? kGameplayFrames : 64;
output->packet_frames = kPacketFrames;
if (output->state == HapticsExperimentState::kRunning) {
const uint32_t now_us = static_cast<uint32_t>(time_us_64());
output->elapsed_us = now_us - output->start_us;

View file

@ -2,6 +2,10 @@
#include <stdint.h>
#ifndef SWITCH_PICO_HD_PACKET_FRAMES
#define SWITCH_PICO_HD_PACKET_FRAMES 32
#endif
struct uni_hid_device_s;
typedef struct uni_hid_device_s uni_hid_device_t;
struct SwitchHapticsFrame;
@ -41,7 +45,7 @@ struct HapticsExperimentDiagnostics {
uint8_t mode = 0;
uint32_t host_updates = 0;
uint32_t dropped_updates = 0;
uint8_t packet_frames = 64;
uint8_t packet_frames = SWITCH_PICO_HD_PACKET_FRAMES;
bool last_packet_nonzero = false;
};

View file

@ -235,8 +235,6 @@ HAPTICS_GAMEPLAY_ARMING_NOTE = (
)
HAPTICS_GAMEPLAY_TIMING = {
"sample_rate_hz": 3000,
"stereo_frames_per_packet": 64,
"lookback_us": 64000000 / 3000,
"switch_command_window_us": 8000,
"switch_watchdog_us": 50000,
"xinput_command_policy": "held_until_changed_or_stopped",
@ -257,23 +255,6 @@ HAPTICS_TRANSPORT_PROBE_EVIDENCE_NOTE = (
"exact occupancy timeline; completion counters select this connection "
"handle. Running snapshots are correlated to one run, not one instant."
)
HAPTICS_EXPERIMENT_PATTERN = {
"sample_rate_hz": 3000,
"stereo_frames_per_packet": 64,
"peak_amplitude": 32,
"priming_silence_packets": 48,
"cycles": 4,
"phases": [
{"channel": "left", "frequency_hz": 100, "packets": 12},
{"channel": "silence", "packets": 12},
{"channel": "right", "frequency_hz": 200, "packets": 12},
{"channel": "silence", "packets": 12},
],
"trailing_silence_packets": 48,
"total_packets": 288,
"initial_mode_packet_stereo_frames": 32,
"duration_us": 6144000,
}
LOGICAL_BUTTONS = (
"south",
@ -457,7 +438,7 @@ class HapticsExperimentDiagnostics:
mode: int
host_updates: int
dropped_updates: int
packet_frames: int = 64
packet_frames: int
last_packet_nonzero: bool = False
@property
@ -503,7 +484,25 @@ class HapticsExperimentDiagnostics:
values["evidence_note"] = HAPTICS_GAMEPLAY_EVIDENCE_NOTE
values["arming_note"] = HAPTICS_GAMEPLAY_ARMING_NOTE
else:
values["pattern"] = HAPTICS_EXPERIMENT_PATTERN
phase_packets = 768 // self.packet_frames
values["pattern"] = {
"sample_rate_hz": 3000,
"stereo_frames_per_packet": self.packet_frames,
"packet_interval_us": self.packet_frames * 1000000 / 3000,
"peak_amplitude": 32,
"priming_silence_packets": 3072 // self.packet_frames,
"cycles": 4,
"phases": [
{"channel": "left", "frequency_hz": 100, "packets": phase_packets},
{"channel": "silence", "packets": phase_packets},
{"channel": "right", "frequency_hz": 200, "packets": phase_packets},
{"channel": "silence", "packets": phase_packets},
],
"trailing_silence_packets": 3072 // self.packet_frames,
"total_packets": 18432 // self.packet_frames,
"initial_mode_packet_stereo_frames": 0,
"duration_us": 6144000,
}
values["evidence_note"] = HAPTICS_EXPERIMENT_EVIDENCE_NOTE
return values
@ -2926,7 +2925,7 @@ def parse_haptics_experiment(envelope: Envelope) -> HapticsExperimentDiagnostics
state, slot, last_error, reserved = struct.unpack_from("<4B", envelope.payload, 68)
mode = envelope.payload[72]
packet_frames = envelope.payload[73]
if packet_frames not in (32, 64) or (mode == 0 and packet_frames != 64):
if packet_frames not in (32, 64):
raise ConfigManagerError("invalid haptics packet size")
host_updates, dropped_updates = struct.unpack_from("<2I", envelope.payload, 76)
if envelope.payload[74] not in (0, 1):
@ -3139,20 +3138,25 @@ def _print_haptics_experiment(
)
if snapshot.mode == 1:
print(
"Gameplay: continuous 3 kHz, 64 stereo frames/packet; "
"21333.333 us lookback, 8000 us command window, "
f"Gameplay: continuous 3 kHz, {snapshot.packet_frames} stereo frames/packet; "
f"{values['gameplay']['lookback_us']:.3f} us lookback, 8000 us command window, "
"50000 us host-effect watchdog; balanced 2x gameplay gain with a "
"0.8-power response curve, jointly headroom-limited. "
"Silence continues without commands."
)
print(HAPTICS_GAMEPLAY_ARMING_NOTE)
else:
pattern = values["pattern"]
print(
"Pattern: 3 kHz, 64 stereo frames/packet, peak 32/127; "
"48 packets priming silence (1.024 s), 4 cycles of "
f"Pattern: 3 kHz, {pattern['stereo_frames_per_packet']} stereo frames/packet, "
f"peak {pattern['peak_amplitude']}/127; "
f"{pattern['priming_silence_packets']} packets priming silence (1.024 s), "
f"{pattern['cycles']} cycles of "
"left 100 Hz / silence / right 200 Hz / silence "
"(12 packets = 256 ms each), 48 packets trailing silence (1.024 s); "
"288 packets / 6.144 s total. Initial mode handoff carries 32 silent frames."
f"({pattern['phases'][0]['packets']} packets = 256 ms each), "
f"{pattern['trailing_silence_packets']} packets trailing silence (1.024 s); "
f"{pattern['total_packets']} packets / 6.144 s total. "
"Initial state-only mode handoff carries no PCM frames and counts as one packet."
)
print(
"Timestamp fields are low 32-bit Pico uptime microseconds; "

View file

@ -5237,10 +5237,18 @@ void require_native_channels(bool left, bool right) {
require(status.state == HapticsExperimentState::kRunning &&
status.mode == 1,
"stateful host rumble lost native gameplay ownership");
require(status.packet_frames == SWITCH_PICO_HD_PACKET_FRAMES,
"native gameplay ignored the configured packet frame count");
const unsigned sample_offset = status.packet_frames == 32 ? 14 : 10;
require(last_native_packet[3] == 0x91 &&
last_native_packet[sample_offset - 2] ==
(status.packet_frames == 32 ? 0x92 : 0xd2) &&
last_native_packet[sample_offset - 1] == 64,
"stateful channel inspection requires a native PCM block");
unsigned active[2]{};
for (unsigned frame = 0; frame < status.packet_frames; ++frame) {
active[0] += last_native_packet[10 + frame * 2] != 0;
active[1] += last_native_packet[11 + frame * 2] != 0;
active[0] += last_native_packet[sample_offset + frame * 2] != 0;
active[1] += last_native_packet[sample_offset + frame * 2 + 1] != 0;
}
require((left ? active[0] > status.packet_frames / 2u : active[0] == 0) &&
(right ? active[1] > status.packet_frames / 2u : active[1] == 0),

View file

@ -21,6 +21,14 @@ namespace {
enum class Delivery { kImmediate, kDeferred, kNever };
enum class GenericKind { kCompatibility, kLed };
constexpr uint32_t kPacketFrames = SWITCH_PICO_HD_PACKET_FRAMES;
static_assert(kPacketFrames == 32 || kPacketFrames == 64);
constexpr uint32_t kPackets = 18432 / kPacketFrames;
constexpr uint32_t kPrimingPackets = 3072 / kPacketFrames;
constexpr uint32_t kToneEndPacket = 15360 / kPacketFrames;
constexpr uint32_t kPhasePackets = 768 / kPacketFrames;
constexpr unsigned kSampleOffset = kPacketFrames == 32 ? 14 : 10;
struct Pcm {
uint64_t at_us;
uint16_t cid;
@ -227,41 +235,68 @@ uint64_t start(uint8_t slot = 0) {
return now_us;
}
uint64_t due(uint64_t started, uint32_t packet, uint32_t frames = 64) {
return started + (static_cast<uint64_t>(packet) * frames * 1000 + 2) / 3;
uint64_t due(uint64_t started, uint32_t packet) {
return started + (static_cast<uint64_t>(packet) * kPacketFrames * 1000 + 2) / 3;
}
void verify_block(const Pcm& packet, uint32_t index, bool forced_silence = false) {
const uint8_t* samples(const Pcm& packet) {
const auto& b = packet.bytes;
assert(b[0] == 0xa2 && b[1] == 0x32 && b[2] == 0);
unsigned sample_offset = 10;
unsigned frames = 64;
if (b[3] == 0x90) {
assert(b[4] == 63);
for (unsigned i = 5; i < 68; ++i) assert(b[i] == 0);
assert(b[68] == 0x92 && b[69] == 64);
sample_offset = 70;
frames = 32;
} else {
assert(b[3] == 0x91 && b[4] == 3 && b[5] == 0x62);
assert(b[6] == 16 && b[8] == 0xd2 && b[9] == 64);
assert(b[3] == 0x91);
assert(b[kSampleOffset - 2] == (kPacketFrames == 32 ? 0x92 : 0xd2));
assert(b[kSampleOffset - 1] == 64);
return b.data() + kSampleOffset;
}
void verify_report(const Pcm& packet, uint32_t sent_index) {
const auto& b = packet.bytes;
assert(b[0] == 0xa2 && b[1] == 0x32);
if (sent_index == 0) {
// Enable only AudioControl, with its route/MicSelect byte left zero.
// Initialization carries no PCM, volume, preamp, mute, trigger or LED writes.
assert(b[2] == 0x10 && b[3] == 0x90 && b[4] == 63 && b[5] == 0x80);
for (unsigned i = 6; i < 139; ++i) assert(b[i] == 0);
return;
}
for (unsigned i = sample_offset + frames * 2; i < 139; ++i) {
assert(b[2] == 0 && b[3] == 0x91);
if (kPacketFrames == 32) {
assert(b[4] == 7 && b[5] == 0xfe);
for (unsigned i = 6; i < 10; ++i) assert(b[i] == 0);
assert(b[10] == 0xff && b[11] == static_cast<uint8_t>(sent_index - 1));
} else {
assert(b[4] == 3 && b[5] == 0x62 && b[6] == 16);
assert(b[7] == static_cast<uint8_t>(sent_index * 2));
}
samples(packet);
for (unsigned i = kSampleOffset + kPacketFrames * 2; i < 139; ++i) {
assert(b[i] == 0);
}
const bool pattern_tone = index >= 48 && index < 240 &&
((index - 48) / 12) % 2 == 0;
}
void verify_silence(const Pcm& packet) {
const auto* block = samples(packet);
for (unsigned byte = 0; byte < kPacketFrames * 2; ++byte) assert(block[byte] == 0);
}
void verify_block(const Pcm& packet, uint32_t index, uint32_t sent_index,
bool forced_silence = false) {
verify_report(packet, sent_index);
if (sent_index == 0) return; // State-only initialization has no PCM block.
const auto* block = samples(packet);
const bool pattern_tone = index >= kPrimingPackets && index < kToneEndPacket &&
((index - kPrimingPackets) / kPhasePackets) % 2 == 0;
const bool tone = pattern_tone && !forced_silence;
const unsigned phase = tone ? ((index - 48) / 12) % 4 : 0;
const unsigned phase =
tone ? ((index - kPrimingPackets) / kPhasePackets) % 4 : 0;
const unsigned side = phase == 0 ? 0 : 1;
const double hz = phase == 0 ? 100.0 : 200.0;
for (unsigned frame = 0; frame < frames; ++frame) {
for (unsigned frame = 0; frame < kPacketFrames; ++frame) {
for (unsigned channel = 0; channel < 2; ++channel) {
const int value = static_cast<int8_t>(b[sample_offset + frame * 2 + channel]);
const int value = static_cast<int8_t>(block[frame * 2 + channel]);
if (!tone || channel != side) {
assert(value == 0);
} else {
const unsigned sample = ((index - 48) % 12) * 64 + frame;
const unsigned sample =
((index - kPrimingPackets) % kPhasePackets) * kPacketFrames + frame;
const int expected = static_cast<int>(std::lround(
32.0 * std::sin(2.0 * 3.14159265358979323846 * hz * sample / 3000.0)));
assert(std::abs(value - expected) <= 1);
@ -284,16 +319,17 @@ void nominal_run(const char* corpus_path) {
run_until(started + 6148000);
const auto done = snapshot();
assert(done.state == HapticsExperimentState::kCompleted);
assert(done.sent_packets == 288 && done.generated_packets == 288);
assert(done.packet_frames == kPacketFrames);
assert(done.sent_packets == kPackets && done.generated_packets == kPackets);
assert(done.skipped_packets == 0 && done.send_failures == 0);
assert(done.can_send_requests == 288 && done.synchronous_callbacks == 288);
assert(max_request_depth == 1 && request_calls == 288);
assert(done.can_send_requests == kPackets && done.synchronous_callbacks == kPackets);
assert(max_request_depth == 1 && request_calls == kPackets);
assert(timer_calls < 1250); // No permanent 1 ms poll for this 6.144 s run.
assert(pcm.size() == 288);
assert(pcm.size() == kPackets);
assert(done.first_tone_due_us == static_cast<uint32_t>(started + 1024000));
assert(done.first_tone_sent_us == static_cast<uint32_t>(pcm[48].at_us));
assert(done.first_tone_sent_us == static_cast<uint32_t>(pcm[kPrimingPackets].at_us));
assert(done.last_sent_us == static_cast<uint32_t>(pcm.back().at_us));
assert(done.max_send_gap_us <= 22000 && done.max_lateness_us < 1000);
assert(done.max_send_gap_us <= due(0, 1) + 1000 && done.max_lateness_us < 1000);
assert(done.elapsed_us >= 6147000 && done.elapsed_us < 6148000);
assert(done.max_generate_us == 0);
assert(!haptics_experiment_owns(&devices[0]) && timers.empty());
@ -309,37 +345,34 @@ void nominal_run(const char* corpus_path) {
for (uint32_t i = 0; i < pcm.size(); ++i) {
assert(pcm[i].at_us >= due(started, i));
assert(pcm[i].at_us - due(started, i) < 1000);
if (i != 0) assert(pcm[i].bytes[7] == static_cast<uint8_t>(i * 2));
verify_block(pcm[i], i);
verify_block(pcm[i], i, i);
corpus.write(reinterpret_cast<const char*>(pcm[i].bytes.data()), 143);
}
// Reference-sized 0x10 native-mode state followed by a silent PCM block.
// Independent known answer computed with Python zlib over the A2 prefix.
assert(pcm[0].bytes[3] == 0x90);
assert(pcm[0].bytes[139] == 0x00 && pcm[0].bytes[140] == 0x41 &&
pcm[0].bytes[141] == 0xfd && pcm[0].bytes[142] == 0x53);
corpus.close();
run_until(now_us + 200000);
assert(snapshot().elapsed_us == done.elapsed_us && pcm.size() == 288);
assert(snapshot().elapsed_us == done.elapsed_us && pcm.size() == kPackets);
}
void stalled_deadlines() {
reset();
const uint64_t started = start();
run_until(due(started, 102) + 1000);
// Stall midway through the second left phase, crossing into its silence.
const uint32_t tone_packet = kPrimingPackets + 4 * kPhasePackets + kPhasePackets / 2;
run_until(due(started, tone_packet) + 1000);
const unsigned before = static_cast<unsigned>(pcm.size());
const uint32_t next = snapshot().sent_packets;
now_us += 250000; // The main loop did not run at all during this stall.
const uint32_t current = static_cast<uint32_t>((now_us - started) * 3 / 64000);
const uint32_t current =
static_cast<uint32_t>((now_us - started) * 3 / (kPacketFrames * 1000));
run_until(now_us);
assert(pcm.size() == before + 1); // No catch-up replay burst.
assert(snapshot().skipped_packets == current - next);
verify_block(pcm.back(), current);
verify_block(pcm.back(), current, before);
assert(snapshot().max_send_gap_us >= 250000);
run_until(started + 6148000);
const auto done = snapshot();
assert(done.state == HapticsExperimentState::kCompleted);
assert(done.sent_packets + done.skipped_packets == 288);
assert(done.sent_packets + done.skipped_packets == kPackets);
assert(done.elapsed_us < 6148000);
}
@ -357,12 +390,13 @@ void deferred_and_missing_callbacks() {
assert(pcm.size() == 1);
assert(snapshot().max_request_wait_us == 2300000);
assert(snapshot().synchronous_callbacks == 0);
assert(snapshot().skipped_packets == 107);
verify_block(pcm.back(), 107);
const uint32_t current = 2300000u * 3 / (kPacketFrames * 1000);
assert(snapshot().skipped_packets == current);
verify_block(pcm.back(), current, 0); // Even a late first report is state-only.
delivery = Delivery::kImmediate;
run_until(started + 6148000);
assert(snapshot().state == HapticsExperimentState::kCompleted);
assert(snapshot().sent_packets + snapshot().skipped_packets == 288);
assert(snapshot().sent_packets + snapshot().skipped_packets == kPackets);
reset();
delivery = Delivery::kNever;
@ -371,7 +405,7 @@ void deferred_and_missing_callbacks() {
const auto missing = snapshot();
assert(missing.state == HapticsExperimentState::kError);
assert(missing.last_error == 4 && missing.send_failures == 1);
assert(missing.sent_packets == 0 && missing.skipped_packets == 288);
assert(missing.sent_packets == 0 && missing.skipped_packets == kPackets);
assert(missing.max_request_wait_us >= 6244000);
assert(request_calls == 1 && timer_calls < 10 && timers.empty());
assert(!haptics_experiment_owns(&devices[0]));
@ -382,9 +416,10 @@ void deferred_and_missing_callbacks() {
void stop_preemption_and_restore() {
reset();
const uint64_t started = start();
run_until(due(started, 98) + 1000);
const uint32_t tone_packet = kPrimingPackets + 4 * kPhasePackets + kPhasePackets / 4;
run_until(due(started, tone_packet - 1) + 1000);
delivery = Delivery::kDeferred;
run_until(due(started, 99) + 1000);
run_until(due(started, tone_packet) + 1000);
assert(devices[0].notification_pending);
const auto before = snapshot();
assert(!haptics_experiment_request(0, 1));
@ -394,7 +429,7 @@ void stop_preemption_and_restore() {
const unsigned sent_before = static_cast<unsigned>(pcm.size());
assert(dispatch(&devices[0], devices[0].conn.interrupt_cid));
assert(pcm.size() == sent_before + 1);
verify_block(pcm.back(), 99, true); // Pending tone permission now sends silence.
verify_block(pcm.back(), tone_packet, sent_before, true); // Pending tone becomes silence.
assert(!haptics_experiment_request(1, 0)); // Compatibility is still settling.
run_until(now_us + 4000);
assert(snapshot().state == HapticsExperimentState::kStopped);
@ -556,13 +591,13 @@ void support_and_transport_errors() {
reset();
fail_requests = 1;
start();
const uint64_t retry_start = start();
assert(pcm.empty());
run_until(now_us + 24000);
run_until(due(retry_start, 1) + 1000);
assert(pcm.size() == 1 && snapshot().send_failures == 1);
assert(snapshot().skipped_packets == 1);
fail_sends = 1;
run_until(now_us + 24000);
run_until(due(retry_start, 2) + 1000);
assert(snapshot().send_failures == 2);
run_until(static_cast<uint64_t>(snapshot().start_us) + 6148000);
assert(snapshot().state == HapticsExperimentState::kError);
@ -581,7 +616,7 @@ void timing_cost_reentrancy_and_wrap() {
run_until(started + 6148000);
const auto done = snapshot();
assert(done.state == HapticsExperimentState::kCompleted);
assert(done.sent_packets == 288 && send_calls == 288);
assert(done.sent_packets == kPackets && send_calls == kPackets);
assert(done.max_generate_us == 0); // Neither request nor send is generation.
assert(done.max_request_wait_us == 200 && max_request_depth == 1);
for (unsigned i = 0; i < pcm.size(); ++i) {
@ -596,10 +631,12 @@ void timing_cost_reentrancy_and_wrap() {
assert(wrapped.state == HapticsExperimentState::kCompleted);
assert(wrapped.start_us == static_cast<uint32_t>(wrap_start));
assert(wrapped.first_tone_due_us == static_cast<uint32_t>(wrap_start + 1024000));
assert(wrapped.first_tone_sent_us == static_cast<uint32_t>(pcm[48].at_us));
assert(wrapped.first_tone_sent_us ==
static_cast<uint32_t>(pcm[kPrimingPackets].at_us));
assert(wrapped.last_sent_us == static_cast<uint32_t>(pcm.back().at_us));
assert(wrapped.elapsed_us >= 6147000 && wrapped.elapsed_us < 6148000);
assert(wrapped.max_send_gap_us <= 22000 && wrapped.sent_packets == 288);
assert(wrapped.max_send_gap_us <= due(0, 1) + 1000 &&
wrapped.sent_packets == kPackets);
}
void synchronous_teardown_releases_admission() {
reset();
@ -630,7 +667,7 @@ void gameplay_led_yield_releases_admission() {
haptics_experiment_poll();
const uint64_t started = snapshot().start_us;
delivery = Delivery::kDeferred;
run_until(due(started, 1, snapshot().packet_frames) + 1000);
run_until(due(started, 1) + 1000);
assert(devices[0].notification_pending);
devices[0].credit = false;
emit_generic(&devices[0], GenericKind::kLed);
@ -666,19 +703,19 @@ void gameplay_timeline_and_lifecycle() {
haptics_experiment_poll();
assert(snapshot().mode == 1 && snapshot().state == HapticsExperimentState::kRunning);
assert(haptics_experiment_gameplay_owns(&devices[0]));
assert(snapshot().packet_frames == kPacketFrames);
verify_report(pcm.front(), 0);
now_us = started + 8000;
feed(false);
const uint32_t frames = snapshot().packet_frames;
run_until(due(started, 1, frames) + 1000);
run_until(due(started, 1) + 1000);
assert(pcm.size() == 2 && snapshot().host_updates == 2);
assert(pcm[1].bytes[7] == frames / 32);
assert(pcm[1].bytes[8] == (frames == 32 ? 0x92 : 0xd2));
for (unsigned byte = 10 + frames * 2; byte < 139; ++byte)
assert(pcm[1].bytes[byte] == 0);
verify_report(pcm[1], 1);
const auto* block = samples(pcm[1]);
unsigned left_nonzero = 0, right_nonzero = 0;
for (unsigned frame = 0; frame < frames; ++frame) {
const auto left = pcm[1].bytes[10 + frame * 2];
const auto right = pcm[1].bytes[11 + frame * 2];
const auto left = block[frame * 2];
const auto right = block[frame * 2 + 1];
if (frame < 24) {
assert(right == 0);
left_nonzero += left != 0;
@ -694,8 +731,8 @@ void gameplay_timeline_and_lifecycle() {
assert(!haptics_experiment_submit(0, 101, now_us, stale));
run_until(started + 6300000);
assert(snapshot().state == HapticsExperimentState::kRunning);
assert(snapshot().sent_packets > 288 && snapshot().skipped_packets == 0);
for (unsigned byte = 10; byte < 138; ++byte) assert(pcm.back().bytes[byte] == 0);
assert(snapshot().sent_packets > kPackets && snapshot().skipped_packets == 0);
verify_silence(pcm.back());
assert(snapshot().dropped_updates == 0 && generic_sent.empty());
assert(haptics_experiment_feedback(&devices[0], 100, 60, 30));
run_until(now_us + 22000);
@ -764,9 +801,10 @@ void stateful_rumble_prepare_feedback_and_zero() {
const auto assert_channels = [](bool left, bool right) {
const uint32_t frames = snapshot().packet_frames;
unsigned active[2]{};
const auto* block = samples(pcm.back());
for (uint32_t frame = 0; frame < frames; ++frame) {
active[0] += pcm.back().bytes[10 + frame * 2] != 0;
active[1] += pcm.back().bytes[11 + frame * 2] != 0;
active[0] += block[frame * 2] != 0;
active[1] += block[frame * 2 + 1] != 0;
}
assert(left ? active[0] > frames / 2 : active[0] == 0);
assert(right ? active[1] > frames / 2 : active[1] == 0);
@ -793,7 +831,7 @@ void stateful_rumble_prepare_feedback_and_zero() {
assert(haptics_experiment_request(1, 0));
assert(!haptics_experiment_submit_rumble(0, 100, now_us, 255, 255));
haptics_experiment_poll();
assert(snapshot().mode == 0 && snapshot().packet_frames == 64);
assert(snapshot().mode == 0 && snapshot().packet_frames == kPacketFrames);
}
void stateful_rumble_generation_and_overflow() {
@ -808,16 +846,14 @@ void stateful_rumble_generation_and_overflow() {
assert(haptics_experiment_request(2, 0));
haptics_experiment_poll();
run_until(now_us + 100000);
for (unsigned byte = 10; byte < 138; ++byte)
assert(pcm.back().bytes[byte] == 0);
verify_silence(pcm.back());
for (unsigned command = 0; command < 17; ++command) {
assert(haptics_experiment_submit_rumble(
0, 101, now_us, command == 16 ? 0 : 255, 0));
}
run_until(now_us + 80000);
assert(snapshot().host_updates == 17 && snapshot().dropped_updates == 1);
for (unsigned byte = 10; byte < 138; ++byte)
assert(pcm.back().bytes[byte] == 0);
verify_silence(pcm.back());
assert(generic_sent.empty());
}

View file

@ -43,6 +43,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"-DSWITCH_PICO_HAPTICS_EXPERIMENT=1",
"-DSWITCH_PICO_HD_RUMBLE=1",
"-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=0",
f"-DSWITCH_PICO_HD_PACKET_FRAMES={32 if short_packets else 64}",
str(root / "src" / "firmware" / "input" / "haptics_experiment.cpp"),
str(
root
@ -74,7 +75,6 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"-DSWITCH_PICO_CYW43_PACKET_READ=1",
"-DSWITCH_PICO_HCI_CREDIT_BATCH=1",
"-DSWITCH_PICO_SYS_CLOCK_MHZ=300",
"-DSWITCH_PICO_HD_PACKET_FRAMES=32",
]
)
command.extend(

View file

@ -3184,17 +3184,6 @@ def test_haptics_arming_waits_for_firmware_not_usb_ack(
assert haptics_clock[0] >= 0.1
if mode == 1:
assert "pattern" not in row
assert row["gameplay"] == {
"sample_rate_hz": 3000,
"stereo_frames_per_packet": 64,
"lookback_us": pytest.approx(21333.333333333),
"switch_command_window_us": 8000,
"switch_watchdog_us": 50000,
"xinput_command_policy": "held_until_changed_or_stopped",
"xinput_carrier_hz": {"left_low": 160, "right_high": 320},
"band_gains": {"low": 2.0, "high": 2.0},
"response_exponent": 0.8,
}
assert row["first_tone_submission_delay_us"] is None
else:
assert row["pattern"]["duration_us"] == 6144000
@ -3999,3 +3988,46 @@ def test_haptics_reports_actual_short_packet_lookback() -> None:
]
assert gameplay["stereo_frames_per_packet"] == 32
assert gameplay["lookback_us"] == pytest.approx(10666.6666667)
@pytest.mark.parametrize(
("packet_frames", "total_packets", "silence_packets", "phase_packets"),
[(32, 576, 96, 24), (64, 288, 48, 12)],
)
def test_haptics_fixture_metadata_follows_reported_frame_count(
packet_frames: int,
total_packets: int,
silence_packets: int,
phase_packets: int,
) -> None:
snapshot = config_manager.read_haptics_experiment(
HapticsDevice([haptics_response(2, slot=0, packet_frames=packet_frames)])
)
pattern = snapshot.to_json_object()["pattern"]
assert pattern["stereo_frames_per_packet"] == packet_frames
assert pattern["packet_interval_us"] == pytest.approx(packet_frames * 1000000 / 3000)
assert pattern["total_packets"] == total_packets
assert pattern["priming_silence_packets"] == silence_packets
assert pattern["trailing_silence_packets"] == silence_packets
assert pattern["phases"] == [
{"channel": "left", "frequency_hz": 100, "packets": phase_packets},
{"channel": "silence", "packets": phase_packets},
{"channel": "right", "frequency_hz": 200, "packets": phase_packets},
{"channel": "silence", "packets": phase_packets},
]
assert pattern["cycles"] == 4
assert pattern["duration_us"] == pytest.approx(
total_packets * pattern["packet_interval_us"]
)
assert pattern["duration_us"] == 6144000
assert pattern["initial_mode_packet_stereo_frames"] == 0
@pytest.mark.parametrize("mode", [0, 1])
@pytest.mark.parametrize("packet_frames", [0, 48])
def test_haptics_rejects_unadvertised_frame_sizes(mode: int, packet_frames: int) -> None:
device = HapticsDevice(
[haptics_response(2, slot=0, mode=mode, packet_frames=packet_frames)]
)
with pytest.raises(config_manager.ConfigManagerError, match="packet size"):
config_manager.read_haptics_experiment(device)

View file

@ -7,9 +7,9 @@ import pytest
@pytest.mark.parametrize("ram", [0, 1], ids=["flash", "sram"])
@pytest.mark.parametrize("short_packets", [False, True], ids=["64frames", "32frames"])
@pytest.mark.parametrize("packet_frames", [32, 64], ids=["32frames", "64frames"])
def test_haptics_experiment_native(
tmp_path: Path, ram: int, short_packets: bool
tmp_path: Path, ram: int, packet_frames: int
) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
@ -27,14 +27,14 @@ def test_haptics_experiment_native(
"-pedantic",
"-DSWITCH_PICO_HAPTICS_EXPERIMENT=1",
f"-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM={ram}",
f"-DSWITCH_PICO_HD_PACKET_FRAMES={packet_frames}",
*(
[
"-DSWITCH_PICO_CYW43_PACKET_READ=1",
"-DSWITCH_PICO_HCI_CREDIT_BATCH=1",
"-DSWITCH_PICO_SYS_CLOCK_MHZ=300",
"-DSWITCH_PICO_HD_PACKET_FRAMES=32",
]
if short_packets
if packet_frames == 32
else []
),
f"-I{root / 'tests' / 'haptics_experiment_native_stubs'}",
@ -52,7 +52,7 @@ def test_haptics_experiment_native(
)
subprocess.run([str(executable), str(corpus)], check=True, cwd=root)
reports = corpus.read_bytes()
assert len(reports) == 288 * 143
assert len(reports) == (18432 // packet_frames) * 143
# Independent standard-library CRC across real module-generated packets:
# A2 is covered once, CRC itself excluded, and stored little-endian.
for offset in range(0, len(reports), 143):

View file

@ -810,13 +810,10 @@ void test_haptics_experiment_requests() {
0, CONTROL_STAGE_SETUP, &request),
"malformed experiment control size was accepted");
}
std::vector<uint8_t> expected(84, 0);
expected[69] = 0xff;
expected[73] = 64;
#ifndef SWITCH_PICO_HAPTICS_EXPERIMENT
expected[68] = 6;
require(read_haptics_payload() == expected,
"disabled firmware must expose only the unsupported snapshot");
const auto unsupported = read_haptics_payload();
require(unsupported[68] == 6 && unsupported[69] == 0xff,
"disabled firmware must expose an unsupported unbound snapshot");
for (uint8_t action : {0, 1, 2}) {
next_out_payload = make_request(Operation::kHapticsExperiment, {action, 0});
tusb_control_request_t request = setup_request(
@ -827,6 +824,8 @@ void test_haptics_experiment_requests() {
"disabled firmware accepted experiment control");
}
#else
std::vector<uint8_t> expected(84, 0);
expected[73] = 64;
perform_haptics_out(3, 0, false);
perform_haptics_out(1, 4, false);
perform_haptics_out(0, 0xff, false);
@ -862,7 +861,7 @@ void test_haptics_experiment_requests() {
current_haptics = {
1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4,
123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210,
1100000, HapticsExperimentState::kRunning, 2, 0, 1, 0x89abcdef, 0x12345678,
1100000, HapticsExperimentState::kRunning, 2, 0, 1, 0x89abcdef, 0x12345678, 64, false,
};
const uint32_t fields[] = {
1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4,