Compare commits

..

2 commits

36 changed files with 3307 additions and 233 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

@ -177,6 +177,7 @@ exact pre-capture firmware and persistent state.
- DualShock 4: hold Share + PS.
- Switch Pro: press its sync button.
- Switch 2 Pro / Joy-Con 2: hold SYNC while the Pico pairing window is open; do not pair through the PC's Bluetooth settings.
- Wii Remote / Remote Plus: press the red SYNC button with any desired extension already attached. SYNC creates a remembered pairing; 1 + 2 is temporary discovery.
- Xbox Bluetooth controller: hold its pair button.
- 8BitDo: use a Bluetooth mode supported by Bluepad32; use Switch/S mode when motion is required.
5. Wait for the controller's player light to settle. Repeat step 4 for additional controllers while the window remains open. Holding BOOTSEL again extends the deadline by 60 seconds from that point.
@ -298,13 +299,15 @@ Switch 2's **C, GL, GR, Left SL/SR and Right SL/SR** are additional source-only
Controller Studio uses the supplied lightweight SVGs for Switch 2 Pro, Joy-Con 2 left/right solo and paired layouts, original Switch Pro, DualSense, Xbox, and Wii Remote/Nunchuk views. Hotspots follow the artwork's actual coordinates; solo Joy-Con views rotate with their firmware input mappings. Rear buttons and rails are labeled below the front view rather than drawn in fictitious positions. On narrow screens, pan the diagram or use the **Source control** menu.
**Auto** uses matching-owner live metadata to distinguish a Joy-Con pair from a solo half. **Preview** changes only the editor's diagram and source labels; it does not pair controllers or change saved mappings, and physical highlighting is disabled. Source choices reflect the layout while stored unavailable mappings are retained. Wii orientation is not reported, so horizontal/vertical views require an explicit preview.
**Auto** uses matching-owner live metadata to distinguish Joy-Con pairs/solo halves and Wii horizontal, vertical, and Nunchuk layouts. **Preview** changes only the editor's diagram and source labels; it does not pair controllers or change saved mappings, and physical highlighting is disabled. Source choices reflect the layout while stored unavailable mappings are retained. Legacy Wii metadata without orientation uses a visibly labeled horizontal reference with physical highlighting disabled.
**Wii orientation** is separate from layout preview. Select a connected Wii Remote owner, choose **Horizontal** or **Vertical**, and click **Apply orientation**. Studio waits for firmware confirmation before reporting success. This changes the current connection's physical button mapping, not saved profiles or adapter configuration. Nunchuk mappings remain vertical while attached; unplugging restores the selected standalone orientation. Changing orientation clears old held-input/macro/capture state and advances the logical input generation without reconnecting Bluetooth. Reconnect uses the horizontal default, or vertical if + is held while connecting.
**Joy-Con 2 pair profiles:** the first successful join of an L + R combination creates a separate **Nintendo Joy-Con 2 (L + R)** owner with eight profiles. It initially copies the left bank's profiles, names, and active selection; its alias starts empty. Both solo banks stay unchanged. Pair edits, names, and active selections are independent thereafter. Splitting or losing a half restores solo banks; joining the same members again restores their existing pair bank without copying. Different member combinations have different banks. Select the L + R owner—not either solo owner—to edit paired settings.
Pair keys contain both complete Bluetooth addresses and address types, in canonical L/R order. They retain the 14-byte identity size: transport byte 1 is 3; byte 0 contains the stable bit plus the left/right static-random flags in bits 1/2; bytes 2–7 and 8–13 contain the left and right addresses. Pair keys are profile identities, not Bluetooth peers or native-output approvals. Update host tools with firmware when using this identity kind.
The read-only playtest endpoint (`0x39`) uses schema 4, 56 bytes: byte 55 identifies unspecified (0), Joy-Con 2 left solo (1), right solo (2), pair (3), Wii Remote (4), or Wii Remote + Nunchuk (5). Host tools still read schema 2/54-byte and schema 3/55-byte payloads; older firmware cannot confirm Joy-Con topology. This metadata does not change profile records, input mapping, or rumble.
The read-only playtest endpoint (`0x39`) uses schema 5, 56 bytes: byte 55 identifies unspecified (0), Joy-Con 2 left solo (1), right solo (2), pair (3), legacy Wii Remote with unknown orientation (4), Wii Remote + Nunchuk (5), Wii horizontal (6), or Wii vertical (7). Host tools still read schema 2/54-byte, schema 3/55-byte and schema 4/56-byte payloads. Orientation requests (`0x3d`) contain the 14-byte controller identity, four-byte little-endian connection generation, and orientation byte (0 horizontal, 1 vertical); stale/replaced/non-Wii/extension targets are rejected before Bluetooth-core dispatch. These operations do not change profile records or persistent configuration.
Profile names and controller aliases are stored as independently checksummed
catalog metadata. Runtime profiles use schema 7 and unchanged 384-byte records; schemas 1–6 migrate with extra inputs unmapped and existing settings preserved. Names remain separate. The
@ -387,12 +390,24 @@ When a controller becomes ready, RGB-capable devices such as DualSense and DualS
| Switch 2 Pro | Yes, including remappable C/GL/GR | Yes, amplitude translation | Yes |
| Joy-Con 2 solo / merged pair | Implemented; hardware qualification pending | Implemented | Implemented |
| PS Move ZCM1/ZCM2 | Buttons/trigger | Yes | Yes, after calibration |
| Wii Remote | Mode-dependent | Yes | Accelerometer |
| Wii Remote / Remote Plus | Orientation-dependent buttons; Nunchuk stick/C/Z | Yes | Factory-calibrated accelerometer; gyro with MotionPlus |
| 8BitDo in Switch-compatible Bluetooth mode | Yes | Model-dependent | Yes when the mode exposes IMU |
| Xbox Bluetooth controller | Yes | Grip + impulse-trigger motors (Microsoft Xbox parser) | No hardware IMU |
Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and 8192 units per g in SDL-oriented axes before conversion to Nintendo samples. PS Move motion remains neutral until all model-specific calibration blocks have been received and validated; buttons and rumble remain available while calibration is pending or unavailable. The latest normalized sample is duplicated across the report's three nominal 5 ms slots and remains pending until a regular `0x30` USB report successfully consumes it.
### Wii Remote and MotionPlus input
The AIO firmware enables motion automatically for original Wii Remotes with an external MotionPlus and Remote Plus models with integrated MotionPlus, including `RVL-CNT-01-TR` (`057E:0330`). Holding A while connecting is no longer required. MotionPlus is probed independently of the status report's extension-present bit; integrated units can return an `A4` identifier from the inactive `A600FA` address.
- **Buttons:** standalone remotes retain horizontal mappings by default: 1/2/A/B → south/east/west/north, with the D-pad rotated for sideways use. Hold **+ while connecting** for vertical mappings: B/A/1/2 → south/east/west/north and upright D-pad. With a Nunchuk, B/A → south/east, 1/2 → L/R, C/Z → west/north, and the Nunchuk supplies the **left stick**; the right stick stays neutral. Minus/Plus/Home remain Select/Start/Home. Profiles can remap these sources.
- **Nunchuk stick travel:** the parser reads the 16-byte extension calibration at `A40020` before activating MotionPlus. It validates both checksum bytes and the X/Y min/center/max ordering, then scales each side of each axis independently into the full normalized range, clamps overshoot, and inverts Y. It preserves the notched gate rather than expanding diagonals into square corners. Unreadable/invalid calibration uses documented nominal travel (center 128, ±96), not the full 0–255 byte range. Replacement Nunchuks reload their own calibration. Existing left-stick profile tuning applies; saved right-stick tuning is not copied or overwritten.
- **Motion:** ordinary remotes use continuous `0x31`; MotionPlus and Nunchuk use combined `0x35` reports. The parser checks primary/backup accelerometer factory calibration and both fast/slow gyro calibration blocks, including checksums. Invalid calibration disables only the affected sensor rather than inventing readings. MotionPlus selects sensitivity independently for each axis; factory-only zero correction can retain temperature-dependent gyro drift.
- **Nunchuk passthrough:** MotionPlus and Nunchuk samples alternate. Held Nunchuk controls persist across gyro packets, moved C/Z bits are decoded, and the remote's accelerometer remains the motion source. Attaching or removing a Nunchuk triggers serialized extension discovery, updates Studio's layout, and clears detached stick/C/Z state. Extension identifiers accept the upstream-compatible type suffix rather than requiring a vendor-specific prefix; initializing extensions get bounded reply-paced retries. Periodic discovery of an inactive external MotionPlus attached alone and Classic Controller MotionPlus passthrough remain unimplemented. Existing ordinary Classic Controller, Wii U Pro, Balance Board and uDraw paths remain separate.
- **Hardware verification:** Remote Plus `B8:AE:6E:21:8F:C2` reconnected after flashing and delivered 100 real USB `0x30` reports with changing accelerometer and gyro values on all axes. Identify dispatched rumble without losing the link. All 72 profile records, names/aliases, active selections, nine pairings and adapter configuration generation 21 / CRC `b58672ac` were unchanged. The subsequent Studio fix detects the attached Nunchuk with live stick and gyro data and renders the matching layout and vertical lock. Native tests cover integrated/external detection, calibration/error boundaries, hotplug ordering, interleaved controls, orientation changes and stale-request rejection. External MotionPlus combinations still need physical qualification.
Protocol references: [WiiBrew Wiimote](https://wiibrew.org/wiki/Wiimote), [MotionPlus registers/calibration](https://wiibrew.org/wiki/Wiimote/Extension_Controllers/Wii_Motion_Plus), [Dolphin MotionPlus calibration](https://github.com/dolphin-emu/dolphin/blob/master/Source/Core/Core/HW/WiimoteEmu/MotionPlus.cpp), and [SDL Wii sensor axes](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_wii.c).
### Switch 2 controller input
The AIO firmware implements the proprietary BLE protocol for Nintendo `057E:2069` (Pro), `057E:2067` (left Joy-Con 2), and `057E:2066` (right Joy-Con 2). This is controller **input** support, distinct from the existing Switch 2 console-wake feature and from emulating a native Switch 2 USB controller.
@ -547,7 +562,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 +1015,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

@ -229,14 +229,3 @@ static inline bool uni_psmove_normalize_imu(
}
return true;
}
static inline void uni_imu_normalize_wii_accel(int32_t x, int32_t y,
int32_t z,
int32_t output[3]) {
if (output == NULL) {
return;
}
output[0] = uni_imu_scale(-x, 100, UNI_IMU_ACCEL_RES_PER_G);
output[1] = uni_imu_scale(z, 100, UNI_IMU_ACCEL_RES_PER_G);
output[2] = uni_imu_scale(y, 100, UNI_IMU_ACCEL_RES_PER_G);
}

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.

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,9 @@
#include <pico/multicore.h>
#include <pico/stdlib.h>
#include <uni.h>
extern "C" {
#include "parser/uni_hid_parser_wii.h"
}
#include "parser/uni_hid_parser_switch2.h"
#include "parser/uni_switch2_haptics.h"
#include "parser/uni_switch2_pairing.h"
@ -162,6 +165,12 @@ struct ProfileFeedbackSequence {
bool rumble_enabled;
bool led_enabled;
};
struct WiiOrientationRequest {
ControllerIdentity identity;
uint32_t connection_generation;
bool vertical;
};
// Security Manager identity events arrive before Bluepad32 publishes a ready
@ -192,6 +201,8 @@ struct BackendSlot {
uint32_t state_generation;
uint32_t connection_generation;
bool active;
bool wii_orientation_pending;
WiiOrientationRequest pending_wii_orientation;
bool rumble_pending;
bool motion_enabled;
bool feedback_pending;
@ -1097,6 +1108,8 @@ void publish_all_neutral() {
slot.extra_buttons = 0;
slot.companion_extra_buttons = 0;
slot.active = false;
slot.wii_orientation_pending = false;
slot.pending_wii_orientation = {};
slot.rumble_pending = false;
slot.retained_host_rumble_valid = false;
slot.retained_host_rumble = {};
@ -1490,6 +1503,8 @@ void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms,
void reset_slot_hotkeys(BackendSlot& slot) {
g_macro_capture.disconnect(static_cast<uint8_t>(&slot - g_slots),
slot.connection_generation, time_us_32());
slot.wii_orientation_pending = false;
slot.pending_wii_orientation = {};
slot.motion_enabled = kDefaultMotionEnabled;
slot.pre_hotkey_button_mask = 0;
slot.feedback_pending = false;
@ -1528,6 +1543,21 @@ void release_slot(BackendSlot& slot) {
slot.active = false;
}
bool is_solo_wii_remote(const BackendSlot& slot) {
if (!slot.active || slot.device == nullptr || slot.companion != nullptr ||
slot.device->controller_type != CONTROLLER_TYPE_WiiController) {
return false;
}
switch (slot.device->controller_subtype) {
case CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL:
case CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL:
case CONTROLLER_SUBTYPE_WIIMOTE_ACCEL:
return true;
default:
return false;
}
}
HotkeyDecision update_controller_hotkeys(
uint8_t slot_index, uni_hid_device_t* device) {
@ -2033,6 +2063,44 @@ void dispatch_rumble(uni_hid_device_t* device, uint16_t duration_ms,
device->report_parser.play_dual_rumble(device, 0, duration_ms, weak, strong);
}
// Core 1 only. The mailbox carries values, never a parser pointer supplied by
// Core 0. Revalidate after lifecycle/topology work and before touching the parser.
void process_wii_orientation(uint8_t slot_index) {
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (!slot.wii_orientation_pending) {
critical_section_exit(&g_state_lock);
return;
}
const WiiOrientationRequest request = slot.pending_wii_orientation;
slot.wii_orientation_pending = false;
slot.pending_wii_orientation = {};
if (!is_solo_wii_remote(slot) ||
slot.connection_generation != request.connection_generation ||
!controller_identity_equal(slot.identity, request.identity)) {
critical_section_exit(&g_state_lock);
return;
}
uni_hid_device_t* device = slot.device;
// Subtype publication can lag set_mode during extension discovery. Even a
// reselection must reach the parser to replace a deferred opposite choice.
// A new logical epoch retires profile macros, hotkey holds, capture and
// feedback without touching this connection's identity or saved profiles.
invalidate_slot(slot);
slot.gamepad = {};
slot.extra_buttons = 0;
critical_section_exit(&g_state_lock);
// The setter can synchronously re-enter the platform ready callback, so
// release the lock first. Lifecycle and parser callbacks share this core.
if (device->report_parser.play_dual_rumble != nullptr) {
dispatch_rumble(device, 0, 0, 0);
}
uni_hid_parser_wii_set_mode(
device, request.vertical ? WII_MODE_VERTICAL : WII_MODE_HORIZONTAL);
apply_slot_lighting(slot_index, device);
}
uint8_t xbox_trigger_magnitude(const SwitchHapticsActuatorFrame& frame) {
uint16_t peak = 0;
for (uint8_t i = 0; i < frame.sample_count && i < 3; ++i) {
@ -2133,6 +2201,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
#endif
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
process_wii_orientation(slot_index);
drain_switch2_ingress(slot_index, now_ms);
RumbleEnvelope envelope{};
FeedbackEnvelope feedback{};
@ -3407,10 +3476,13 @@ void bluepad32_input_backend_playtest_snapshot(
} else {
switch (slot.device->controller_subtype) {
case CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL:
case CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL:
case CONTROLLER_SUBTYPE_WIIMOTE_ACCEL:
out->controller_layout =
Bluepad32ControllerLayout::kWiiRemote;
Bluepad32ControllerLayout::kWiiHorizontal;
break;
case CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL:
out->controller_layout =
Bluepad32ControllerLayout::kWiiVertical;
break;
case CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK:
case CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL:
@ -3550,6 +3622,30 @@ void bluepad32_input_backend_queue_rumble(
#endif
}
bool bluepad32_input_backend_set_wii_orientation(
const ControllerIdentity& identity, uint32_t connection_generation,
bool vertical) {
if (!g_initialized || !identity.stable ||
controller_identity_is_global(identity)) {
return false;
}
bool queued = false;
critical_section_enter_blocking(&g_state_lock);
for (BackendSlot& slot : g_slots) {
if (!is_solo_wii_remote(slot) ||
slot.connection_generation != connection_generation ||
!controller_identity_equal(slot.identity, identity)) {
continue;
}
slot.pending_wii_orientation = {identity, connection_generation, vertical};
slot.wii_orientation_pending = true;
queued = true;
break;
}
critical_section_exit(&g_state_lock);
return queued;
}
bool bluepad32_input_backend_identify(
const ControllerIdentity& identity) {
if (!g_initialized || !identity.stable ||

View file

@ -71,6 +71,8 @@ enum class Bluepad32ControllerLayout : uint8_t {
kJoyCon2MergedPair = 3,
kWiiRemote = 4,
kWiiNunchuk = 5,
kWiiHorizontal = 6,
kWiiVertical = 7,
};
// Side-effect-free raw input snapshot for management telemetry. Unlike the
@ -133,6 +135,12 @@ void bluepad32_input_backend_queue_rumble(
uint8_t slot, const ControllerRumbleOutput& rumble);
bool bluepad32_input_backend_identify(
const ControllerIdentity& identity);
// Queue a connection-local standalone Wii mapping change for Core 1's 5 ms
// timer. Acceptance is not application; dispatch advances the logical
// connection generation once, including reselection. Nunchuk is ineligible.
bool bluepad32_input_backend_set_wii_orientation(
const ControllerIdentity& identity, uint32_t connection_generation,
bool vertical);
// Enqueue bounded local profile confirmation for the matching live connection
// generation. The two-entry per-slot FIFO preserves initial-then-switch
// ordering. Profile lighting is transient and restored to the steady slot

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

@ -121,6 +121,9 @@ bool valid_out_size(Operation operation, size_t size) {
case Operation::kProfileIdentify:
return size == kRequestHeaderSize +
CONTROLLER_IDENTITY_ENCODED_SIZE;
case Operation::kWiiOrientation:
return size == kRequestHeaderSize +
CONTROLLER_IDENTITY_ENCODED_SIZE + 5;
case Operation::kPairingRefresh:
case Operation::kPairingClear:
return size == kRequestHeaderSize;
@ -518,7 +521,7 @@ size_t encode_profile_playtest(
CONTROLLER_MOTION_SAMPLE_CAPACITY ||
(snapshot.state.extra_buttons & 0x80u) != 0 ||
static_cast<uint8_t>(snapshot.controller_layout) >
static_cast<uint8_t>(Bluepad32ControllerLayout::kWiiNunchuk)) {
static_cast<uint8_t>(Bluepad32ControllerLayout::kWiiVertical)) {
return 0;
}
payload[0] = 1;
@ -899,6 +902,15 @@ bool process_out_request() {
&identity) &&
bluepad32_input_backend_identify(identity);
}
case Operation::kWiiOrientation: {
ControllerIdentity identity{};
constexpr size_t offset = CONTROLLER_IDENTITY_ENCODED_SIZE;
return payload[offset + 4] <= 1 &&
controller_identity_decode(payload, offset, &identity) &&
bluepad32_input_backend_set_wii_orientation(
identity, UsbConfigurationManagement::read_u32(payload + offset),
payload[offset + 4] != 0);
}
case Operation::kPairingRefresh:
bluepad32_input_backend_request_pairing_snapshot();
return true;

View file

@ -23,7 +23,7 @@ constexpr size_t kProfileListRowSize =
16 + PROFILE_SERVICE_METADATA_MAX_BYTES + 1;
constexpr size_t kProfileListPayloadSize =
1 + PROFILE_SERVICE_LIST_CAPACITY * kProfileListRowSize;
constexpr uint16_t kProfilePlaytestSchemaVersion = 4;
constexpr uint16_t kProfilePlaytestSchemaVersion = 5;
constexpr size_t kProfilePlaytestPayloadSize = 56;
constexpr size_t kProfileMetadataPayloadSize =
(CONTROLLER_PROFILE_COUNT + 1) *
@ -74,6 +74,7 @@ enum class Operation : uint8_t {
kProfileMetadataRead = 0x3a,
kProfileMetadataSet = 0x3b,
kProfileIdentify = 0x3c,
kWiiOrientation = 0x3d,
kHapticsExperiment = 0x40,
kHapticsTransportProbe = 0x41,
kMacroCapture = 0x42,

View file

@ -64,6 +64,7 @@ OP_PROFILE_PLAYTEST = 0x39
OP_PROFILE_METADATA_READ = 0x3A
OP_PROFILE_METADATA_SET = 0x3B
OP_PROFILE_IDENTIFY = 0x3C
OP_WII_ORIENTATION = 0x3D
OP_HAPTICS_EXPERIMENT = 0x40
OP_HAPTICS_TRANSPORT_PROBE = 0x41
OP_MACRO_CAPTURE = 0x42
@ -172,7 +173,8 @@ PROFILE_PLAYTEST_LEGACY_SCHEMA_VERSION = 2
PROFILE_PLAYTEST_LEGACY_SIZE = 54
PROFILE_PLAYTEST_EXTRA_BUTTON_SCHEMA_VERSION = 3
PROFILE_PLAYTEST_EXTRA_BUTTON_SIZE = 55
PROFILE_PLAYTEST_SCHEMA_VERSION = 4
PROFILE_PLAYTEST_TOPOLOGY_SCHEMA_VERSION = 4
PROFILE_PLAYTEST_SCHEMA_VERSION = 5
PROFILE_PLAYTEST_SIZE = 56
PROFILE_PLAYTEST_LAYOUTS = (
None,
@ -181,6 +183,8 @@ PROFILE_PLAYTEST_LAYOUTS = (
"joycon2-pair",
"wii-remote",
"wii-nunchuk",
"wii-horizontal",
"wii-vertical",
)
PROFILE_PLAYTEST_SLOT_COUNT = 4
PROFILE_METADATA_SCHEMA_VERSION = 1
@ -235,8 +239,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 +259,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 +442,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 +488,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 +2929,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 +3142,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; "
@ -3770,19 +3778,38 @@ def identify_controller(device: UsbDevice, identity: ControllerIdentity) -> None
_control_out(device, OP_PROFILE_IDENTIFY, identity.to_bytes())
def set_wii_orientation(
device: UsbDevice,
identity: ControllerIdentity,
connection_generation: int,
orientation: str,
) -> None:
if identity.is_global_fallback or not identity.stable:
raise ConfigManagerError("select a connected Wii Remote to change orientation")
_require_int(connection_generation, "connection generation", 0, 0xFFFFFFFF)
mode = _require_enum(orientation, ("horizontal", "vertical"), "Wii orientation")
_control_out(
device,
OP_WII_ORIENTATION,
identity.to_bytes() + struct.pack("<IB", connection_generation, mode),
)
def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest:
_raise_status(envelope)
expected_size = {
PROFILE_PLAYTEST_LEGACY_SCHEMA_VERSION: PROFILE_PLAYTEST_LEGACY_SIZE,
PROFILE_PLAYTEST_EXTRA_BUTTON_SCHEMA_VERSION: PROFILE_PLAYTEST_EXTRA_BUTTON_SIZE,
PROFILE_PLAYTEST_TOPOLOGY_SCHEMA_VERSION: PROFILE_PLAYTEST_SIZE,
PROFILE_PLAYTEST_SCHEMA_VERSION: PROFILE_PLAYTEST_SIZE,
}.get(envelope.schema_version)
if len(envelope.payload) != expected_size:
raise ConfigManagerError("invalid profile playtest payload")
payload = envelope.payload
extra_buttons = payload[54] if len(payload) >= PROFILE_PLAYTEST_EXTRA_BUTTON_SIZE else 0
layout_code = payload[55] if envelope.schema_version == PROFILE_PLAYTEST_SCHEMA_VERSION else 0
if layout_code >= len(PROFILE_PLAYTEST_LAYOUTS):
layout_code = payload[55] if len(payload) >= PROFILE_PLAYTEST_SIZE else 0
layout_count = 6 if envelope.schema_version == PROFILE_PLAYTEST_TOPOLOGY_SCHEMA_VERSION else len(PROFILE_PLAYTEST_LAYOUTS)
if layout_code >= layout_count:
raise ConfigManagerError("invalid playtest controller layout")
if extra_buttons & ~0x7F:
raise ConfigManagerError("invalid playtest extra buttons")

View file

@ -155,6 +155,8 @@ def _controller_presentation(
"joycon2-right": ("Nintendo Joy-Con 2 (R)", "switch", {0x2066}),
"joycon2-pair": ("Nintendo Joy-Con 2 pair", "switch", {0x2067, 0x2066}),
"wii-remote": ("Nintendo Wii Remote", "wii", {0x0306, 0x0330}),
"wii-horizontal": ("Nintendo Wii Remote (horizontal)", "wii", {0x0306, 0x0330}),
"wii-vertical": ("Nintendo Wii Remote (vertical)", "wii", {0x0306, 0x0330}),
"wii-nunchuk": ("Nintendo Wii Remote + Nunchuk", "wii", {0x0306, 0x0330}),
}
live = live_layouts.get(layout)
@ -208,7 +210,7 @@ def _source_controls(controller: dict[str, str]) -> list[str]:
if layout == "joycon2-left"
else ("start", "system", "right_trigger", "c", "right_sl", "right_sr")
)
elif layout in {"wii-remote", "wii-nunchuk"}:
elif layout in {"wii-remote", "wii-horizontal", "wii-vertical", "wii-nunchuk"}:
controls = (
"south", "east", "west", "north", "select", "start", "system",
"dpad_up", "dpad_down", "dpad_left", "dpad_right",
@ -444,6 +446,9 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
if action == "identify":
self._api_call(lambda: self._identify(identity_index))
return
if action == "wii-orientation":
self._api_call(lambda: self._set_wii_orientation(identity_index))
return
selection = self._parse_profile_path(path)
if selection is not None:
identity_index, profile_index, action = selection
@ -966,6 +971,25 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
config_manager.identify_controller(device, identity)
return {"identified": True}
def _set_wii_orientation(self, identity_index: int) -> dict[str, Any]:
body = self._read_json_object()
orientation = body.get("orientation")
generation = body.get("connection_generation")
if (
not isinstance(orientation, str)
or not isinstance(generation, int)
or isinstance(generation, bool)
or not 0 <= generation <= 0xFFFFFFFF
):
raise config_manager.ConfigManagerError(
"request must contain orientation ('horizontal' or 'vertical') "
"and a valid connection_generation"
)
device = self.profile_server.find_device()
_, identity = self._entries_and_identity(device, identity_index)
config_manager.set_wii_orientation(device, identity, generation, orientation)
return {"queued": True}
def _copy_profile(self, identity_index: int, profile_index: int) -> dict[str, Any]:
destination = self._read_json_object()
destination_identity_index = destination.get("identity_index")

View file

@ -149,12 +149,17 @@ const ControllerLayouts = (() => {
},
"wii-remote": {
name: "Wii Remote · orientation unknown", style: "switch", asset: "wii-remote-simple.svg",
viewBox: [0, 0, 200, 632], minWidth: 240, maxWidth: 260,
viewBox: [0, 0, 200, 632], rotation: -90, minWidth: 760,
controls: {
...wiiCommon,
...Object.fromEntries(["south", "east", "west", "north", "dpad_up", "dpad_right", "dpad_down", "dpad_left"].map(id => [id, back("Logical " + id.replaceAll("_", " "), id.startsWith("dpad") ? id.slice(5) : id)])),
south: point(100, 445, "1 (horizontal default)", "1"), east: point(100, 495, "2 (horizontal default)", "2"),
west: point(100, 216, "A (horizontal default)", "A"), north: back("B · underside", "B"),
dpad_left: point(100, 103, "D-pad physical Up → Left (horizontal default)", "◀"),
dpad_up: point(125, 128, "D-pad physical Right → Up (horizontal default)", "▲"),
dpad_right: point(100, 153, "D-pad physical Down → Right (horizontal default)", "▶"),
dpad_down: point(75, 128, "D-pad physical Left → Down (horizontal default)", "▼"),
},
note: "Firmware reports a Wii Remote, not its orientation. Only Minus, Plus and Home can be placed unambiguously. Choose an explicit horizontal/accelerometer or vertical preview to place the other sources. Power is not remappable.",
note: "Legacy firmware reports a Wii Remote without detected orientation. Controls default to standard horizontal positions. Select an explicit preview to compare layouts. B is underneath; Power is not remappable.",
},
"wii-horizontal": {
name: "Wii Remote · horizontal / accelerometer", style: "switch", asset: "wii-remote-simple.svg",
@ -168,12 +173,12 @@ const ControllerLayouts = (() => {
dpad_right: point(100, 153, "D-pad physical Down → Right", "▶"),
dpad_down: point(75, 128, "D-pad physical Left → Down", "▼"),
},
note: "Explicit orientation preview, not detected: 1→south, 2→east, A→west, B→north. D-pad sources rotate counterclockwise. B is underneath; Power is not remappable.",
note: "1→south, 2→east, A→west, B→north. D-pad sources rotate counterclockwise for horizontal grip. B is underneath; Power is not remappable.",
},
"wii-vertical": {
name: "Wii Remote · vertical", style: "switch", asset: "wii-remote-simple.svg",
viewBox: [0, 0, 200, 632], minWidth: 240, maxWidth: 260, controls: wiiVertical,
note: "Explicit orientation preview, not detected: B→south, A→east, 1→west, 2→north. D-pad directions stay upright. B is underneath; Power is not remappable.",
note: "B→south, A→east, 1→west, 2→north. D-pad directions stay upright. B is underneath; Power is not remappable.",
},
"wii-nunchuk": {
name: "Wii Remote + Nunchuk", style: "switch", asset: "wii-remote-nunchuk-simple.svg",
@ -184,8 +189,8 @@ const ControllerLayouts = (() => {
left_shoulder: point(420, 455, "1", "1"), right_shoulder: point(420, 505, "2", "2"),
west: back("Nunchuk C · rear", "C"), north: back("Nunchuk Z · rear", "Z"),
},
annotations: [{ x: 233, y: 220.4, label: "Stick → RIGHT axes (no click)" }],
note: "Nunchuk stick uses RIGHT axes and has no click. B→south, A→east, 1/2→left/right shoulder, Nunchuk C→west and Z→north. Nunchuk C is a normal face source, not the Switch 2 C extra. Power is not remappable.",
annotations: [{ x: 233, y: 220.4, label: "Stick → LEFT axes (no click)" }],
note: "Nunchuk stick uses LEFT axes with calibrated travel and has no click. B→south, A→east, 1/2→left/right shoulder, Nunchuk C→west and Z→north. Nunchuk C is a normal face source, not the Switch 2 C extra. Power is not remappable.",
},
};
return layouts;

View file

@ -320,6 +320,15 @@ h4 { font-size: 1rem; }
.controller-live[data-state="live"] { color: var(--mint); }
.layout-picker { margin-block: 16px 8px; }
.layout-picker .select { width: 100%; margin-top: 6px; }
.wii-orientation-control { margin-block: 14px 10px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: rgba(14, 23, 42, 0.6); }
.wii-orientation-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; align-items: center; }
.wii-orientation-actions .select { flex: 1 1 220px; }
.wii-orientation-actions .button { flex: 0 0 auto; }
.wii-orientation-status { margin-top: 8px; font-size: 0.74rem; line-height: 1.5; overflow-wrap: anywhere; color: var(--mint); }
.wii-orientation-status[data-state="idle"] { display: none; }
.wii-orientation-status[data-state="pending"], .wii-orientation-status[data-state="unsupported"] { color: var(--amber); }
.wii-orientation-status[data-state="error"] { color: var(--rose); }
.wii-orientation-status[data-state="locked"] { color: var(--muted); }
.controller-viewport { max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 16px 0; scrollbar-color: var(--subtle) var(--surface-2); }
.controller-viewport:focus-visible { outline: 2px solid var(--sky); outline-offset: 2px; }
.controller-photo-wrap { --diagram-min: 640px; --diagram-max: 900px; position: relative; width: 100%; min-width: var(--diagram-min); max-width: var(--diagram-max); margin: 0 auto; aspect-ratio: var(--diagram-ratio, 800 / 552); }

View file

@ -154,6 +154,18 @@
</select>
<p class="field-help" id="controllerLayoutHelp">Preview changes only this editor's source labels and diagram, never firmware topology or saved mappings.</p>
</div>
<div class="wii-orientation-control" id="wiiOrientationControl" hidden>
<label class="field-label" for="wiiOrientationSelect">Wii orientation</label>
<div class="wii-orientation-actions">
<select class="select" id="wiiOrientationSelect" aria-describedby="wiiOrientationHelp wiiOrientationStatus" disabled>
<option value="horizontal">Horizontal · sideways D-pad &amp; 1/2 buttons</option>
<option value="vertical">Vertical · upright pointer orientation</option>
</select>
<button class="button button-secondary" id="applyWiiOrientationButton" type="button" disabled>Apply orientation</button>
</div>
<p class="field-help" id="wiiOrientationHelp">Applies to the active connection only (not saved in profile slots). Nunchuk automatically locks orientation to vertical.</p>
<p class="wii-orientation-status" id="wiiOrientationStatus" data-state="idle" role="status" aria-live="polite"></p>
</div>
<div class="controller-viewport" tabindex="0" role="region" aria-label="Controller diagram; scroll horizontally on narrow screens">
<div class="controller-photo-wrap" id="controllerPhotoWrap">
<div class="controller-stage" id="controllerStage">

View file

@ -37,6 +37,24 @@ const joyconMode = {
readError: "",
applyError: "",
};
const wiiOrientation = {
current: null,
selected: "horizontal",
pending: false,
applying: false,
applyError: "",
submittedGeneration: null,
desiredLayout: null,
ownerKey: null,
applyTimer: 0,
};
function resetWiiOrientationDraft() {
wiiOrientation.pending = false;
wiiOrientation.applyError = "";
if (wiiOrientation.current) {
wiiOrientation.selected = wiiOrientation.current;
}
}
const state = {
schema: null,
identities: [],
@ -93,6 +111,11 @@ const elements = {
controllerLayoutPreview: document.querySelector("#controllerLayoutPreview"),
controllerLayoutHelp: document.querySelector("#controllerLayoutHelp"),
controllerLayoutNote: document.querySelector("#controllerLayoutNote"),
wiiOrientationControl: document.querySelector("#wiiOrientationControl"),
wiiOrientation: document.querySelector("#wiiOrientationSelect"),
applyWiiOrientation: document.querySelector("#applyWiiOrientationButton"),
wiiOrientationStatus: document.querySelector("#wiiOrientationStatus"),
wiiOrientationHelp: document.querySelector("#wiiOrientationHelp"),
controllerPhotoWrap: document.querySelector("#controllerPhotoWrap"),
controllerStage: document.querySelector("#controllerStage"),
selectedSource: document.querySelector("#selectedSource"),
@ -246,8 +269,9 @@ function syncControllerPresentation() {
const reported = live?.source_controls || owner?.source_controls || state.schema.controls;
const sources = preview ? Object.keys(layout.controls) : reported.filter(control =>
layout.generic || Object.hasOwn(layout.controls, control));
const topologyKnown = !layoutId.startsWith("joycon2-") || live?.layout === layoutId;
const liveDiagram = Boolean(live && !preview && topologyKnown && layoutId !== "wii-remote" && !layout.generic);
const topologyKnown = layoutId !== "wii-remote" &&
(!(layoutId.startsWith("joycon2-") || layoutId.startsWith("wii-")) || live?.layout === layoutId);
const liveDiagram = Boolean(live && !preview && topologyKnown && !layout.generic);
const style = preview ? layout.style : controller.style || layout.style;
const key = [owner?.key, layoutId, style, preview, liveDiagram, Boolean(live), live?.identity_key, controller.model, sources.join(",")].join("|");
if (state.presentationKey === key) return false;
@ -356,6 +380,17 @@ function clearPlaytest(message, stateName = "waiting") {
refreshSourceControls();
}
renderCapture();
if (wiiOrientation.applying) {
window.clearTimeout(wiiOrientation.applyTimer);
wiiOrientation.applying = false;
wiiOrientation.submittedGeneration = null;
wiiOrientation.desiredLayout = null;
wiiOrientation.ownerKey = null;
setBusy(false);
}
wiiOrientation.current = null;
resetWiiOrientationDraft();
renderWiiOrientation();
}
function renderPlaytest(sample) {
@ -466,12 +501,48 @@ function renderPlaytest(sample) {
elements.playtestHelp.textContent =
"Yellow is raw input; blue is the output produced by this unsaved draft.";
renderCapture();
if (wiiOrientation.applying) {
const submittedGen = wiiOrientation.submittedGeneration;
const nextGen = ((submittedGen + 1) >>> 0);
const gen = sample.connection_generation;
const desired = wiiOrientation.desiredLayout;
const matchesOwner = owner && wiiOrientation.ownerKey === owner.key;
if (matchesOwner && sample.layout === desired && (gen === submittedGen || gen === nextGen)) {
window.clearTimeout(wiiOrientation.applyTimer);
wiiOrientation.applying = false;
wiiOrientation.submittedGeneration = null;
wiiOrientation.desiredLayout = null;
wiiOrientation.ownerKey = null;
wiiOrientation.current = desired === "wii-horizontal" ? "horizontal" : "vertical";
wiiOrientation.selected = wiiOrientation.current;
wiiOrientation.pending = false;
wiiOrientation.applyError = "";
setBusy(false);
toast(`${label(wiiOrientation.current)} orientation applied.`);
} else if (!matchesOwner || (gen !== submittedGen && gen !== nextGen)) {
window.clearTimeout(wiiOrientation.applyTimer);
wiiOrientation.applying = false;
wiiOrientation.submittedGeneration = null;
wiiOrientation.desiredLayout = null;
wiiOrientation.ownerKey = null;
resetWiiOrientationDraft();
setBusy(false);
}
}
if (sample.layout === "wii-horizontal" || sample.layout === "wii-vertical") {
const confirmed = sample.layout === "wii-horizontal" ? "horizontal" : "vertical";
wiiOrientation.current = confirmed;
if (!wiiOrientation.pending && !wiiOrientation.applying) {
wiiOrientation.selected = confirmed;
}
}
renderWiiOrientation();
}
async function pollPlaytest() {
window.clearTimeout(state.playtestTimer);
if (
document.hidden || state.busy || state.playtestRequestActive ||
document.hidden || (state.busy && !wiiOrientation.applying) || state.playtestRequestActive ||
captureBlocking() || macroCapture.requestActive || !state.schema || !state.profile
) {
state.playtestTimer = window.setTimeout(pollPlaytest, 250);
@ -634,6 +705,92 @@ async function refreshJoyconMode(showLoading = false) {
}
}
function isWiiController(owner = currentOwner(), live = matchingLiveSample() ? state.liveSample : null) {
const vid = owner?.identity?.vendor_id;
const pid = owner?.identity?.product_id;
const isWiiPid = vid === 0x057E && (pid === 0x0306 || pid === 0x0330);
const liveLayout = live?.layout;
const isWiiLayout = liveLayout === "wii-remote" || liveLayout === "wii-nunchuk" ||
liveLayout === "wii-horizontal" || liveLayout === "wii-vertical";
return Boolean(isWiiPid || isWiiLayout);
}
function renderWiiOrientation() {
const owner = currentOwner();
const live = matchingLiveSample() ? state.liveSample : null;
const visible = isWiiController(owner, live);
elements.wiiOrientationControl.hidden = !visible;
if (!visible) return;
const isNunchuk = live?.layout === "wii-nunchuk";
const isLegacy = live?.layout === "wii-remote";
const isSupported = Boolean(
live && (live.layout === "wii-horizontal" || live.layout === "wii-vertical") &&
owner && owner.index !== 0
);
const locked = (state.busy && !wiiOrientation.applying) || captureBlocking() ||
macroCapture.requestActive || wiiOrientation.applying || !state.adapterConnected || !live;
if (isNunchuk) {
elements.wiiOrientation.value = "vertical";
elements.wiiOrientation.disabled = true;
elements.applyWiiOrientation.disabled = true;
elements.wiiOrientationStatus.dataset.state = "locked";
elements.wiiOrientationStatus.textContent = "Nunchuk attached: orientation is locked to vertical.";
return;
}
if (!live) {
elements.wiiOrientation.disabled = true;
elements.applyWiiOrientation.disabled = true;
elements.wiiOrientationStatus.dataset.state = "idle";
elements.wiiOrientationStatus.textContent = "";
return;
}
if (isLegacy) {
elements.wiiOrientation.disabled = true;
elements.applyWiiOrientation.disabled = true;
elements.wiiOrientationStatus.dataset.state = "unsupported";
elements.wiiOrientationStatus.textContent = "Legacy firmware detected. Update firmware to configure Wii orientation.";
return;
}
if (owner?.index === 0) {
elements.wiiOrientation.disabled = true;
elements.applyWiiOrientation.disabled = true;
elements.wiiOrientationStatus.dataset.state = "unsupported";
elements.wiiOrientationStatus.textContent = "Default profile owner cannot set orientation. Select the connected Wii Remote.";
return;
}
elements.wiiOrientation.disabled = locked || !isSupported;
const hasChanged = wiiOrientation.selected !== wiiOrientation.current;
elements.applyWiiOrientation.disabled = elements.wiiOrientation.disabled ||
wiiOrientation.applying || !hasChanged;
if (elements.wiiOrientation.value !== wiiOrientation.selected) {
elements.wiiOrientation.value = wiiOrientation.selected;
}
let statusText = "";
let statusState = "idle";
if (wiiOrientation.applying) {
statusState = "pending";
statusText = `Applying ${wiiOrientation.selected} orientation…`;
} else if (wiiOrientation.applyError) {
statusState = "error";
statusText = wiiOrientation.applyError;
} else if (hasChanged) {
statusState = "pending";
statusText = `Selected: ${label(wiiOrientation.selected)} — not applied.`;
} else if (wiiOrientation.current) {
statusState = "idle";
statusText = `Current: ${label(wiiOrientation.current)}.`;
}
elements.wiiOrientationStatus.dataset.state = statusState;
elements.wiiOrientationStatus.textContent = statusText;
}
function setBusy(busy) {
state.busy = busy;
@ -660,6 +817,7 @@ function setBusy(busy) {
button.disabled = busy;
});
renderCapture();
renderWiiOrientation();
}
function updateDirtyState() {
@ -965,7 +1123,9 @@ function renderButtonMap() {
elements.controllerLayoutHelp.textContent = preview
? `Manual preview only${live ? `; connected input is ${live.controller.model}` : ""}. No topology or saved mapping is changed. Physical highlighting is off.`
: live && !topologyKnown
? "Legacy firmware does not report Joy-Con pair/solo topology. This is an owner reference, not detected solo mode. Choose a preview or update firmware."
? layoutId === "wii-remote"
? "Legacy firmware does not report Wii orientation. This reference shows the default horizontal mapping; physical highlighting is off. Choose a preview or update firmware."
: "Legacy firmware does not report Joy-Con pair/solo topology. This is an owner reference, not detected solo mode. Choose a preview or update firmware."
: "Auto uses only the selected owner's metadata. Preview changes this editor's source labels and diagram, never firmware topology or saved mappings.";
elements.controllerLayoutNote.textContent =
!preview && live?.layout === "joycon2-pair" && !live.identity?.is_joycon_pair
@ -1037,6 +1197,7 @@ function renderButtonMap() {
elements.selectedControlDescription.textContent =
`${sourceAvailable(selected) ? "Source" : "Stored / unavailable source"} ${sourceLabel(selected)} [${selected}] produces ${mappedOutput == null ? "no output" : controlLabel(mappedOutput)}.${sourceAvailable(selected) ? "" : " Its mapping is retained; change the preview to locate it."}`;
elements.selectedMapping.innerHTML = buttonOptions(mappedOutput, true, state.schema.output_controls, style);
renderWiiOrientation();
}
elements.controllerCanvas.addEventListener("click", event => {
@ -2354,9 +2515,10 @@ elements.identity.addEventListener("change", async () => {
if (owner) persistOwnerKey(owner.key);
state.profileIndex = 0;
state.profileNames = Array(state.schema.profile_capacity).fill("");
wiiOrientation.current = null;
resetWiiOrientationDraft();
await loadProfile();
});
elements.refresh.addEventListener("click", async () => {
await loadLibrary(true);
});
@ -2395,6 +2557,63 @@ elements.applyJoyconMode.addEventListener("click", async () => {
}
});
elements.wiiOrientation.addEventListener("change", () => {
if (elements.wiiOrientation.disabled) return;
wiiOrientation.selected = elements.wiiOrientation.value;
wiiOrientation.pending = wiiOrientation.selected !== wiiOrientation.current;
wiiOrientation.applyError = "";
renderWiiOrientation();
});
elements.applyWiiOrientation.addEventListener("click", async () => {
const owner = currentOwner();
const live = matchingLiveSample() ? state.liveSample : null;
if (elements.applyWiiOrientation.disabled || (state.busy && !wiiOrientation.applying) ||
captureBlocking() || macroCapture.requestActive || !owner || owner.index === 0 || !live) return;
const targetOrientation = wiiOrientation.selected;
const targetLayout = targetOrientation === "horizontal" ? "wii-horizontal" : "wii-vertical";
const gen = live.connection_generation;
wiiOrientation.applying = true;
wiiOrientation.applyError = "";
wiiOrientation.submittedGeneration = gen;
wiiOrientation.desiredLayout = targetLayout;
wiiOrientation.ownerKey = owner.key;
window.clearTimeout(wiiOrientation.applyTimer);
wiiOrientation.applyTimer = window.setTimeout(() => {
if (wiiOrientation.applying) {
wiiOrientation.applying = false;
wiiOrientation.submittedGeneration = null;
wiiOrientation.desiredLayout = null;
wiiOrientation.ownerKey = null;
wiiOrientation.applyError = "Orientation confirmation timed out. Check controller connection.";
setBusy(false);
renderWiiOrientation();
}
}, 5000);
setBusy(true);
try {
await api(`/api/identities/${owner.index}/wii-orientation`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orientation: targetOrientation,
connection_generation: gen,
}),
});
} catch (error) {
window.clearTimeout(wiiOrientation.applyTimer);
wiiOrientation.applying = false;
wiiOrientation.submittedGeneration = null;
wiiOrientation.desiredLayout = null;
wiiOrientation.ownerKey = null;
wiiOrientation.applyError = `Could not set orientation: ${error.message}`;
toast(wiiOrientation.applyError, true);
setBusy(false);
renderWiiOrientation();
}
pollPlaytest();
});
elements.resetDraft.addEventListener("click", () => {
if (!state.schema || !window.confirm("Replace this draft with the default profile? Nothing is saved until you choose Save to Pico.")) return;
state.profile = clone(state.schema.default_profile);

View file

@ -12,6 +12,7 @@
#include "parser/uni_hid_parser_switch2.h"
#include "parser/uni_switch2_haptics.h"
#include "parser/uni_switch2_pairing.h"
#include "parser/uni_hid_parser_wii.h"
#include "platform/pico/controller_color_config.h"
#include "input/switch2_wake.h"
#include "pico/critical_section.h"
@ -103,6 +104,13 @@ struct LocalRumbleEvent {
};
std::vector<LocalRumbleEvent> local_rumble_events;
struct WiiModeEvent {
uni_hid_device_t* device;
wii_mode_t mode;
};
std::vector<WiiModeEvent> wii_mode_events;
bool wii_dispatch_active = false;
struct CoreStopped {};
@ -683,6 +691,19 @@ void tracked_state_lock_exit(critical_section_t* lock) {
#undef critical_section_enter_blocking
#undef critical_section_exit
extern "C" void uni_hid_parser_wii_set_mode(
uni_hid_device_t* device, wii_mode_t mode) {
require(wii_dispatch_active && state_lock_depth == 0,
"Wii parser mode changes must run in the Bluetooth timer without the state lock");
wii_mode_events.push_back({device, mode});
device->controller_subtype = mode == WII_MODE_VERTICAL
? CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL
: CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL;
// The real parser's report reconfiguration can synchronously announce ready.
require(platform_on_device_ready(device) == UNI_ERROR_SUCCESS,
"Wii mode reconfiguration must retain the ready connection");
}
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
namespace {
std::vector<btstack_timer_source_t*> native_timers;
@ -3493,9 +3514,9 @@ void test_rejections() {
Bluepad32ControllerLayout layout;
};
const LayoutCase layouts[] = {
{CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL, Bluepad32ControllerLayout::kWiiRemote},
{CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL, Bluepad32ControllerLayout::kWiiRemote},
{CONTROLLER_SUBTYPE_WIIMOTE_ACCEL, Bluepad32ControllerLayout::kWiiRemote},
{CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL, Bluepad32ControllerLayout::kWiiHorizontal},
{CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL, Bluepad32ControllerLayout::kWiiVertical},
{CONTROLLER_SUBTYPE_WIIMOTE_ACCEL, Bluepad32ControllerLayout::kWiiHorizontal},
{CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK, Bluepad32ControllerLayout::kWiiNunchuk},
{CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL, Bluepad32ControllerLayout::kWiiNunchuk},
{CONTROLLER_SUBTYPE_WII_CLASSIC, Bluepad32ControllerLayout::kUnspecified},
@ -5237,10 +5258,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),
@ -5407,6 +5436,222 @@ void test_native_second_slot_selection() {
}
#endif
uni_hid_device_t wii_device(int index) {
auto result = device(index, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
result.vendor_id = 0x057e;
result.product_id = 0x0330;
result.controller_type = CONTROLLER_TYPE_WiiController;
result.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL;
return result;
}
void dispatch_wii_requests() {
wii_dispatch_active = true;
now_ms += g_rumble_timer.timeout_ms;
g_rumble_timer.handler(&g_rumble_timer);
wii_dispatch_active = false;
}
void test_wii_orientation() {
start_pairing_backend();
initialize_runtime_profile_storage();
auto remote = wii_device(0);
auto ordinary = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
ready_switch2(remote);
ready_switch2(ordinary);
const auto identity = slot_snapshot(0).identity;
const auto ordinary_identity = slot_snapshot(1).identity;
configure_gesture_profile(identity, 2, 37);
configure_gesture_profile(ordinary_identity, 5, 63);
controller_profile_runtime_reset();
for (uint8_t index = 0; index < 2; ++index) {
controller_profile_runtime_transform(
index, slot_snapshot(index), now_ms, AdapterUsbMode::kXInput);
}
uni_controller_t report{};
report.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
report.gamepad.buttons = BUTTON_A;
++now_ms;
platform_on_controller_data(&remote, &report);
platform_on_controller_data(&ordinary, &report);
for (uint8_t index = 0; index < 2; ++index) {
require(controller_profile_runtime_transform(
index, slot_snapshot(index), now_ms, AdapterUsbMode::kXInput)
.state.button_system,
"both controllers need active toggle macros before changing only Wii orientation");
}
report.gamepad.buttons = BUTTON_Y;
report.gamepad.dpad = DPAD_UP;
report.gamepad.axis_x = 511;
report.gamepad.brake = 1023;
platform_on_controller_data(&remote, &report);
const auto before = slot_snapshot(0);
const auto ordinary_before = slot_snapshot(1);
require(bluepad32_input_backend_capture_start(
0, before.connection_generation, CaptureOptions{}),
"orientation transition must have an active recorder to retire");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101});
bluepad32_input_backend_queue_profile_feedback(
0, before.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed);
const unsigned writes_before = profile_write_attempts;
const size_t observations_before = observed_profile_identity_count;
require(bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation, true),
"live standalone Wii must accept a vertical request");
Bluepad32PlaytestSnapshot playtest{};
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(wii_mode_events.empty() &&
playtest.controller_layout == Bluepad32ControllerLayout::kWiiHorizontal &&
playtest.connection_generation == before.connection_generation &&
playtest.state.button_north && playtest.state.dpad_up,
"enqueue must not call the parser or claim a new mapping on the management core");
dispatch_wii_requests();
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(wii_mode_events.size() == 1 && wii_mode_events[0].device == &remote &&
wii_mode_events[0].mode == WII_MODE_VERTICAL &&
playtest.active &&
playtest.controller_layout == Bluepad32ControllerLayout::kWiiVertical &&
playtest.connection_generation == before.connection_generation + 1u &&
controller_identity_equal(playtest.identity, identity) &&
playtest.physical_button_mask == 0 && !playtest.state.button_north &&
!playtest.state.dpad_up && playtest.state.left_stick_x == 0 &&
playtest.state.left_trigger == 0 && playtest.state.motion_sample_count == 0,
"timer dispatch must atomically retire old input and publish the new live mapping epoch");
Bluepad32CaptureSnapshot capture{};
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kDisconnected &&
!controller_profile_runtime_transform(
0, slot_snapshot(0), now_ms, AdapterUsbMode::kXInput).state.button_system &&
controller_profile_runtime_transform(
1, slot_snapshot(1), now_ms, AdapterUsbMode::kXInput).state.button_system &&
slot_snapshot(1).connection_generation == ordinary_before.connection_generation &&
remote.last_high == 0 && remote.last_low == 0 &&
!bluepad32_input_backend_toggle_motion(0, before.connection_generation),
"mapping changes must cancel old macro/capture/hotkey/feedback epochs without disturbing peers");
report.gamepad = {};
report.gamepad.buttons = BUTTON_B;
platform_on_controller_data(&remote, &report);
require(slot_snapshot(0).state.button_east && !slot_snapshot(0).state.button_north,
"fresh input must use the new mapping without reviving old controls");
const uint32_t vertical_generation = playtest.connection_generation;
require(bluepad32_input_backend_set_wii_orientation(
identity, vertical_generation, false),
"current vertical mapping must be reversible without reconnecting");
dispatch_wii_requests();
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.controller_layout == Bluepad32ControllerLayout::kWiiHorizontal &&
playtest.connection_generation == vertical_generation + 1u &&
wii_mode_events.size() == 2 && wii_mode_events[1].mode == WII_MODE_HORIZONTAL,
"horizontal selection must return the real parser and metadata to horizontal");
const uint32_t horizontal_generation = playtest.connection_generation;
require(bluepad32_input_backend_set_wii_orientation(
identity, horizontal_generation, false),
"reselection must still reach a parser with a potentially deferred opposite choice");
dispatch_wii_requests();
require(wii_mode_events.size() == 3 && wii_mode_events[2].mode == WII_MODE_HORIZONTAL &&
slot_snapshot(0).connection_generation == horizontal_generation + 1u &&
profile_write_attempts == writes_before &&
observed_profile_identity_count == observations_before &&
device_disconnect_calls == 0,
"reselection and orientation roundtrip must preserve profiles, flash and physical connections");
require_active_profile(identity, 2, 37);
require_active_profile(ordinary_identity, 5, 63);
}
void test_wii_orientation_races() {
auto remote = wii_device(0);
const auto identity = identity_for_device(&remote);
require(!bluepad32_input_backend_set_wii_orientation(identity, 0, true),
"orientation requests must reject an uninitialized backend");
start_pairing_backend();
platform_on_device_connected(&remote);
require(!bluepad32_input_backend_set_wii_orientation(
identity, slot_snapshot(0).connection_generation, true),
"orientation requests must reject connections that are not ready");
require(platform_on_device_ready(&remote) == UNI_ERROR_SUCCESS,
"Wii race fixture must become ready");
const auto before = slot_snapshot(0);
auto ordinary = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
ordinary.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL;
ready_switch2(ordinary);
auto unstable = wii_device(2);
unstable.conn.protocol = UNI_BT_CONN_PROTOCOL_NONE;
gap_connection_types[unstable.conn.handle] = GAP_CONNECTION_INVALID;
ready_switch2(unstable);
auto wrong_identity = identity;
wrong_identity.product_id ^= 1u;
require(!bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation - 1u, true) &&
!bluepad32_input_backend_set_wii_orientation(
wrong_identity, before.connection_generation, true) &&
!bluepad32_input_backend_set_wii_orientation(
slot_snapshot(1).identity, slot_snapshot(1).connection_generation, true) &&
!bluepad32_input_backend_set_wii_orientation(
slot_snapshot(2).identity, slot_snapshot(2).connection_generation, true),
"stale generation, wrong owner, non-Wii and global fallback must not target a parser");
const uni_controller_subtype_t extensions[] = {
CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK,
CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL,
CONTROLLER_SUBTYPE_WII_CLASSIC,
};
for (auto subtype : extensions) {
remote.controller_subtype = subtype;
require(!bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation, false),
"Nunchuk and other extension mappings cannot be changed by standalone orientation");
}
remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL;
require(bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation, true),
"race fixture must enqueue while still standalone");
remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK;
dispatch_wii_requests();
Bluepad32PlaytestSnapshot playtest{};
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(wii_mode_events.empty() &&
playtest.controller_layout == Bluepad32ControllerLayout::kWiiNunchuk &&
playtest.connection_generation == before.connection_generation,
"extension attachment before dispatch must reject the queued request without changing topology");
remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL;
dispatch_wii_requests();
require(wii_mode_events.empty(),
"a request rejected during attachment must not revive when the Nunchuk detaches");
require(bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation, true),
"standalone connection must accept another request after detach");
const WiiOrientationRequest stale = g_slots[0].pending_wii_orientation;
platform_on_device_disconnected(&remote);
remote = wii_device(0);
ready_switch2(remote);
dispatch_wii_requests();
require(wii_mode_events.empty() &&
!bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation, true),
"physical object reuse with the same identity must discard old pending requests and reject its old epoch");
// Exercise the dispatch guard even if an already accepted old envelope
// survives a future mailbox refactor.
g_slots[0].pending_wii_orientation = stale;
g_slots[0].wii_orientation_pending = true;
dispatch_wii_requests();
g_slots[0].pending_wii_orientation = {
wrong_identity, slot_snapshot(0).connection_generation, true};
g_slots[0].wii_orientation_pending = true;
dispatch_wii_requests();
require(wii_mode_events.empty() &&
slot_snapshot(0).connection_generation == before.connection_generation + 1u,
"dispatch must independently revalidate both connection generation and identity");
const auto replacement = slot_snapshot(0);
require(bluepad32_input_backend_set_wii_orientation(
replacement.identity, replacement.connection_generation, true) &&
bluepad32_input_backend_set_wii_orientation(
replacement.identity, replacement.connection_generation, false),
"rapid standalone choices must coalesce before dispatch");
dispatch_wii_requests();
require(wii_mode_events.size() == 1 && wii_mode_events[0].mode == WII_MODE_HORIZONTAL &&
slot_snapshot(0).connection_generation == replacement.connection_generation + 1u,
"coalescing back to the current mapping must not apply an obsolete intermediate choice");
}
} // namespace
int main(int argc, char** argv) {
@ -5422,6 +5667,14 @@ int main(int argc, char** argv) {
return 0;
}
#endif
if (scenario == "wii-orientation") {
test_wii_orientation();
return 0;
}
if (scenario == "wii-orientation-races") {
test_wii_orientation_races();
return 0;
}
if (scenario == "switch2-hd-pro") {
test_switch2_hd_pro();
} else if (scenario == "switch2-gesture-timing") {

View file

@ -75,14 +75,6 @@ void set_accel_calibration(
}
}
void test_wii_accelerometer() {
int32_t output[3]{};
uni_imu_normalize_wii_accel(100, -50, 25, output);
expect(output[0] == -8192 && output[1] == 2048 &&
output[2] == -4096,
"Wii accelerometer scale or SDL axis mapping is wrong");
}
void test_zcm1_calibration_and_normalization() {
constexpr auto model = UNI_PSMOVE_IMU_MODEL_ZCM1;
std::array<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE> blob{};
@ -200,7 +192,6 @@ void test_uncalibrated_psmove_is_suppressed() {
} // namespace
int main() {
test_wii_accelerometer();
test_zcm1_calibration_and_normalization();
test_zcm2_calibration_and_normalization();
test_uncalibrated_psmove_is_suppressed();

View file

@ -0,0 +1,19 @@
#pragma once
#include <uni.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum wii_flags {
WII_MODE_HORIZONTAL = 0,
WII_MODE_VERTICAL = 1,
WII_MODE_ACCEL = 2,
} wii_mode_t;
void uni_hid_parser_wii_set_mode(uni_hid_device_t* device, wii_mode_t mode);
#ifdef __cplusplus
}
#endif

View file

@ -83,6 +83,11 @@ enum {
MISC_BUTTON_CAPTURE = 1 << 3,
};
typedef enum {
CONTROLLER_TYPE_UnknownController = 0,
CONTROLLER_TYPE_WiiController = 35,
} uni_controller_type_t;
typedef enum {
CONTROLLER_SUBTYPE_NONE = 0,
CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL,
@ -163,6 +168,7 @@ inline uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer)
struct uni_hid_device_s {
uint16_t vendor_id;
uint16_t product_id;
uni_controller_type_t controller_type;
uni_controller_subtype_t controller_subtype;
uni_bt_conn_t conn;
int idx;

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(
@ -99,6 +99,8 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
subprocess.run(command, check=True, cwd=root)
subprocess.run([str(executable), "xbox-rumble"], check=True, cwd=root)
for scenario in (
"wii-orientation",
"wii-orientation-races",
"switch2-individual-core-start",
"switch2-individual-forward",
"switch2-individual-reverse",

View file

@ -2396,6 +2396,9 @@ def test_playtest_layout_and_extra_inputs_preserve_legacy_firmware() -> None:
assert current.to_json_object()["buttons"] == ["south", "dpad_up", "dpad_right"]
assert parse(payload[:55], 3) == replace(current, layout=None)
assert parse(payload[:54], 2) == replace(current, extra_buttons=0, layout=None)
assert parse(payload, 5) == current
assert parse(payload[:55] + b"\x06", 5).layout == "wii-horizontal"
assert parse(payload[:55] + b"\x07", 5).layout == "wii-vertical"
assert parse(payload[:55] + b"\x00", 4).layout is None
for data, schema in (
(payload[:54] + b"\x80", 3),
@ -2406,7 +2409,9 @@ def test_playtest_layout_and_extra_inputs_preserve_legacy_firmware() -> None:
(payload, 3),
(payload[:55], 4),
(payload + b"\x00", 4),
(payload, 5),
(payload[:55] + b"\x08", 5),
(payload[:55], 5),
(payload, 6),
):
with pytest.raises(config_manager.ConfigManagerError):
parse(data, schema)
@ -3184,17 +3189,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 +3993,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

@ -436,7 +436,14 @@ def test_wii_pid_does_not_claim_a_remote_or_extension_without_live_metadata(
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
assert listing["identities"][1]["controller"]["layout"] == "generic"
for code, expected in ((0, "generic"), (4, "wii-remote"), (5, "wii-nunchuk"), (0, "generic")):
for code, expected in (
(0, "generic"),
(4, "wii-remote"),
(5, "wii-nunchuk"),
(6, "wii-horizontal"),
(7, "wii-vertical"),
(0, "generic"),
):
device.playtest_layout = code
status, sample = request_json(f"{base_url}/api/profiles/0/1/playtest")
assert status == 200
@ -445,7 +452,6 @@ def test_wii_pid_does_not_claim_a_remote_or_extension_without_live_metadata(
assert set(sample["source_controls"]).isdisjoint(config_manager.EXTRA_BUTTONS)
assert ("left_shoulder" in sample["source_controls"]) == (code == 5)
def test_editor_reads_writes_and_activates_profiles_atomically(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -859,4 +865,77 @@ def test_recorder_accepts_first_connection_generation_zero(
},
)
assert status == 200 and stopped["state_name"] == "stopped"
assert stopped["steps"][0]["duration_ms"] == 100
def test_wii_orientation_endpoint_validation_and_failures(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
with running_server(monkeypatch, device) as (base_url, token):
# Missing token
status, _ = request_json(
f"{base_url}/api/identities/1/wii-orientation",
method="POST",
value={"orientation": "horizontal", "connection_generation": 1},
)
assert status == 403
# Out of bounds identity index
status, _ = request_json(
f"{base_url}/api/identities/99/wii-orientation",
method="POST",
token=token,
value={"orientation": "horizontal", "connection_generation": 1},
)
assert status == 400
# Invalid / missing fields
for invalid_body in (
{},
{"orientation": "diagonal", "connection_generation": 1},
{"orientation": "horizontal"},
{"connection_generation": 1},
{"orientation": "horizontal", "connection_generation": -1},
{"orientation": "horizontal", "connection_generation": "1"},
{"orientation": 123, "connection_generation": 1},
):
status, _ = request_json(
f"{base_url}/api/identities/1/wii-orientation",
method="POST",
token=token,
value=invalid_body,
)
assert status == 400
# ConfigManagerError from config_manager
def failing_config(*args: Any, **kwargs: Any) -> None:
raise config_manager.ConfigManagerError("firmware rejected orientation")
monkeypatch.setattr(
config_manager, "set_wii_orientation", failing_config
)
status, err = request_json(
f"{base_url}/api/identities/1/wii-orientation",
method="POST",
token=token,
value={"orientation": "horizontal", "connection_generation": 1},
)
assert status == 400
assert "error" in err
# USB error from config_manager
def usb_failing_config(*args: Any, **kwargs: Any) -> None:
raise usb.core.USBError("USB pipe error")
monkeypatch.setattr(
config_manager, "set_wii_orientation", usb_failing_config
)
status, err = request_json(
f"{base_url}/api/identities/1/wii-orientation",
method="POST",
token=token,
value={"orientation": "horizontal", "connection_generation": 1},
)
assert status == 503
assert "error" in err

View file

@ -0,0 +1,48 @@
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
from prepare_bluepad32 import prepare_bluepad32
def test_wii_parser_native_motion_and_lifecycle(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"
prepared = prepare_bluepad32(
root / "external" / "bluepad32",
root / "patches" / "bluepad32-sdl3-imu.patch",
tmp_path / "bluepad32-src",
)
component = prepared / "src" / "components" / "bluepad32"
executable = tmp_path / "wii_parser_native_test"
subprocess.run(
[
compiler,
"-std=gnu11",
"-O1",
"-Wall",
"-Wextra",
"-ffunction-sections",
"-fdata-sections",
"-DENABLE_BLE",
"-DENABLE_CLASSIC",
f"-I{root / 'tests' / 'switch_parser_native_stubs'}",
f"-I{root / 'bluepad32_config'}",
f"-I{component / 'include'}",
str(root / "tests" / "wii_parser_native_test.c"),
str(component / "parser" / "uni_hid_parser_wii.c"),
str(component / "controller" / "uni_gamepad.c"),
"-Wl,--gc-sections",
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -567,7 +567,14 @@ void test_profile_vendor_requests() {
control_payload[kResponseHeaderSize + 55] == 3,
"profile playtest response lost live controller state");
current_playtest[2].controller_layout =
static_cast<Bluepad32ControllerLayout>(6);
Bluepad32ControllerLayout::kWiiVertical;
require(usb_configuration_management_vendor_control(
0, CONTROL_STAGE_SETUP, &request) &&
control_payload[10] == 5 &&
control_payload[kResponseHeaderSize + 55] == 7,
"Wii orientation must be available through schema-5 playtest");
current_playtest[2].controller_layout =
static_cast<Bluepad32ControllerLayout>(8);
uint8_t invalid_playtest[kMaximumResponseSize]{};
require(encode_profile_playtest(
2, current_playtest[2], invalid_playtest, sizeof(invalid_playtest)) == 0,
@ -629,6 +636,25 @@ void test_profile_vendor_requests() {
require(identify_requested,
"controller Identify request was not dispatched");
std::vector<uint8_t> orientation = identify;
orientation.resize(CONTROLLER_IDENTITY_ENCODED_SIZE + 5);
write_u32(&orientation, CONTROLLER_IDENTITY_ENCODED_SIZE, 0x11223344);
orientation.back() = 1;
perform_out(Operation::kWiiOrientation, orientation);
orientation.back() = 2;
next_out_payload = make_request(Operation::kWiiOrientation, orientation);
request = setup_request(Operation::kWiiOrientation, TUSB_DIR_OUT,
static_cast<uint16_t>(next_out_payload.size()));
require(usb_configuration_management_vendor_control(
0, CONTROL_STAGE_SETUP, &request) &&
!usb_configuration_management_vendor_control(
0, CONTROL_STAGE_ACK, &request),
"invalid Wii orientation was treated as vertical");
request.wLength--;
require(!usb_configuration_management_vendor_control(
0, CONTROL_STAGE_SETUP, &request),
"truncated Wii orientation request was accepted");
current_profile_transaction = {};
current_profile_transaction.metadata.state =
ProfileServiceState::kReady;
@ -810,13 +836,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 +850,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 +887,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,
@ -1132,6 +1157,11 @@ bool bluepad32_input_backend_identify(
return true;
}
bool bluepad32_input_backend_set_wii_orientation(
const ControllerIdentity&, uint32_t, bool) {
return true;
}
ConfigurationTransactionStatus profile_service_reset(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t selected_profile) {

View file

@ -0,0 +1,913 @@
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "parser/uni_hid_parser_wii.h"
#include "uni_hid_device.h"
// The real staged parser and gamepad definitions are linked. Only transport and
// the ready notification are substituted; no parser-private state is inspected.
// Wire fixtures follow WiiBrew's Wiimote and Wii Motion Plus protocol pages.
typedef struct {
uint16_t len;
uint8_t bytes[32];
} transaction_t;
typedef enum { EXT_NONE, EXT_NUNCHUK, EXT_WII_U_PRO } extension_t;
typedef struct {
uni_hid_device_t device;
transaction_t sent[128];
unsigned sent_count;
unsigned cursor;
unsigned ready_count;
extension_t extension;
bool motionplus;
bool mp_initialized;
bool mp_active;
bool status_before_activation_ack;
bool status_after_activation_ack;
bool deactivation_status_before_ack;
bool deactivation_status_after_ack;
unsigned initializing_extension_reads;
unsigned transient_extension_read_errors;
uint8_t extension_identity[6];
bool fail_accel_reads;
uint8_t connection_buttons;
uint8_t activation_mode;
uint8_t report_mode;
uint32_t fail_read_address;
uint32_t fail_write_address;
uint8_t accel_calibration[20];
uint8_t mp_calibration[32];
uint8_t nunchuk_calibration[16];
} fixture_t;
static fixture_t f;
void uni_hid_device_send_intr_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) {
assert(d == &f.device);
assert(f.sent_count < 128 && len >= 2 && len <= sizeof(f.sent[0].bytes));
assert(report[0] == 0xa2);
transaction_t* transaction = &f.sent[f.sent_count++];
transaction->len = len;
memcpy(transaction->bytes, report, len);
}
bool uni_hid_device_set_ready_complete(uni_hid_device_t* d) {
assert(d == &f.device);
++f.ready_count;
d->conn.state = UNI_BT_CONN_STATE_DEVICE_READY;
return true;
}
void uni_log(const char* fmt, ...) { (void)fmt; }
void printf_hexdump(const void* data, int len) { (void)data; (void)len; }
static void put_be16(uint8_t* output, uint16_t value) {
output[0] = value >> 8;
output[1] = value;
}
static void put_le16(uint8_t* output, uint16_t value) {
output[0] = value;
output[1] = value >> 8;
}
static void pack_accel_calibration(uint8_t* output, const uint16_t values[3]) {
output[3] = 0;
for (unsigned axis = 0; axis < 3; ++axis) {
output[axis] = values[axis] >> 2;
output[3] |= (values[axis] & 3) << (4 - 2 * axis);
}
}
static void update_mp_checksum(void) {
// CRC32 covers fast[0..13] followed by slow[0..13], not the CRC slots.
uint32_t crc = UINT32_MAX;
for (unsigned block = 0; block < 2; ++block) {
for (unsigned byte = 0; byte < 14; ++byte) {
crc ^= f.mp_calibration[block * 16 + byte];
for (unsigned bit = 0; bit < 8; ++bit)
crc = (crc >> 1) ^ ((crc & 1) ? UINT32_C(0xedb88320) : 0);
}
}
crc ^= UINT32_MAX;
put_be16(&f.mp_calibration[14], crc >> 16);
put_be16(&f.mp_calibration[30], crc);
}
static void set_nunchuk_stick_calibration(const uint8_t values[6]) {
memcpy(f.nunchuk_calibration + 8, values, 6);
uint8_t checksum = 0x55;
for (unsigned i = 0; i < 14; ++i)
checksum += f.nunchuk_calibration[i];
f.nunchuk_calibration[14] = checksum;
f.nunchuk_calibration[15] = checksum + 0x55;
}
static void reset_fixture(uint16_t product_id, bool motionplus, extension_t extension) {
memset(&f, 0, sizeof(f));
f.device.vendor_id = 0x057e;
f.device.product_id = product_id;
f.device.conn.connected = true;
f.device.conn.interrupt_cid = 0x40;
f.extension = extension;
f.motionplus = motionplus;
f.fail_read_address = UINT32_MAX;
f.fail_write_address = UINT32_MAX;
const uint8_t nunchuk_identity[6] = {0, 0, 0xa4, 0x20, 0, 0};
memcpy(f.extension_identity, nunchuk_identity, sizeof(nunchuk_identity));
set_nunchuk_stick_calibration((const uint8_t[]){224, 32, 128, 224, 32, 128});
// Non-default centers, unequal spans, and nonzero packed low bits catch
// nominal 0x200/100-count calibration and high-byte-only decoding.
const uint16_t zero[3] = {510, 506, 514};
const uint16_t one_g[3] = {614, 634, 594};
pack_accel_calibration(f.accel_calibration, zero);
pack_accel_calibration(&f.accel_calibration[4], one_g);
uint8_t checksum = 0x55;
for (unsigned byte = 0; byte < 9; ++byte)
checksum += f.accel_calibration[byte];
f.accel_calibration[9] = checksum;
memcpy(&f.accel_calibration[10], f.accel_calibration, 10);
// MP words have 16-bit precision; reports have 14 bits. Factory yaw and
// pitch spans are negative, whereas roll is positive. Fast and slow also
// deliberately have different centers and sensitivities on every axis.
const uint16_t zero_fast[3] = {32000, 31600, 31200};
const uint16_t scale_fast[3] = {22400, 36400, 28000};
const uint16_t zero_slow[3] = {32400, 32000, 31600};
const uint16_t scale_slow[3] = {21600, 37400, 28000};
for (unsigned axis = 0; axis < 3; ++axis) {
put_be16(&f.mp_calibration[2 * axis], zero_fast[axis]);
put_be16(&f.mp_calibration[6 + 2 * axis], scale_fast[axis]);
put_be16(&f.mp_calibration[16 + 2 * axis], zero_slow[axis]);
put_be16(&f.mp_calibration[22 + 2 * axis], scale_slow[axis]);
}
f.mp_calibration[12] = 200; // Fast calibration is at 1200 degrees/s.
f.mp_calibration[28] = 45; // Slow calibration is at 270 degrees/s.
f.mp_calibration[13] = 0x31;
f.mp_calibration[29] = 0x72;
update_mp_checksum();
}
static void feed(const uint8_t* report, uint16_t len) {
uni_hid_parser_wii_init_report(&f.device);
uni_hid_parser_wii_parse_input_report(&f.device, report, len);
}
static void send_status(bool extension_present) {
const uint8_t report[] = {0x20, f.connection_buttons, 0, extension_present ? 2 : 0, 0, 0, 0xc0};
feed(report, sizeof(report));
}
static void send_ack(uint8_t command, uint8_t error) {
const uint8_t report[] = {0x22, 0, 0, command, error};
feed(report, sizeof(report));
}
static uint32_t transaction_address(const transaction_t* transaction) {
assert(transaction->len >= 8);
const uint8_t* bytes = transaction->bytes;
return ((uint32_t)bytes[3] << 16) | ((uint32_t)bytes[4] << 8) | bytes[5];
}
static void send_read_reply(uint16_t address, const uint8_t* data, uint8_t size, uint8_t error) {
assert(size >= 1 && size <= 16);
uint8_t report[22] = {0x21, 0, 0, (uint8_t)(((size - 1) << 4) | error)};
put_be16(&report[4], address);
if (!error)
memcpy(&report[6], data, size);
feed(report, sizeof(report));
}
static bool read_wire_byte(uint8_t space, uint32_t address, uint8_t* value) {
if (space == 0) {
if (address >= 0x16 && address < 0x2a && !f.fail_accel_reads) {
*value = f.accel_calibration[address - 0x16];
return true;
}
return false;
}
assert(space == 4);
uint32_t bank = address >> 16;
uint16_t offset = address;
if ((bank == 0xa6 && f.motionplus && !f.mp_active) ||
(bank == 0xa4 && f.mp_active)) {
if (offset >= 0x20 && offset < 0x40) {
*value = f.mp_calibration[offset - 0x20];
return true;
}
if (offset >= 0xfa && offset <= 0xff) {
// RVL-CNT-01-TR hardware returns an A4 signature even when read
// through inactive A600FA; the ID is not an echo of the read bank.
const bool integrated = f.device.product_id == 0x0330;
const uint8_t identity[] = {integrated ? 1 : 0, 0, integrated ? 0xa4 : (uint8_t)bank,
0x20, f.mp_active ? f.activation_mode : 0, 5};
*value = identity[offset - 0xfa];
return true;
}
}
if (bank == 0xa4 && !f.mp_active && f.extension == EXT_NUNCHUK && offset >= 0x20 && offset < 0x30) {
*value = f.nunchuk_calibration[offset - 0x20];
return true;
}
if (bank == 0xa4 && !f.mp_active && f.extension != EXT_NONE && offset >= 0xfa && offset <= 0xff) {
uint8_t identity[6];
memcpy(identity, f.extension_identity, sizeof(identity));
if (f.extension == EXT_WII_U_PRO) {
identity[4] = 1;
identity[5] = 0x20;
}
*value = identity[offset - 0xfa];
return true;
}
return false;
}
static void answer_transaction(const transaction_t* transaction) {
const uint8_t* bytes = transaction->bytes;
switch (bytes[1]) {
case 0x11: // LEDs do not acknowledge unless explicitly requested.
if (bytes[2] & 2)
send_ack(0x11, 0);
break;
case 0x12:
assert(transaction->len == 4);
f.report_mode = bytes[3];
if (bytes[2] & 2)
send_ack(0x12, 0);
break;
case 0x15:
send_status(f.extension != EXT_NONE || f.mp_active);
break;
case 0x16: {
uint32_t address = transaction_address(transaction);
assert((bytes[2] & 0xfe) == 4);
assert(bytes[6] == 1 && transaction->len >= 8);
uint8_t error = 0;
bool deactivated = false;
bool activated = false;
if (address == f.fail_write_address || ((address >> 16) == 0xa6 && !f.motionplus)) {
error = 7;
} else if (address == 0xa600f0) {
assert(bytes[7] == 0x55);
f.mp_initialized = true;
} else if (address == 0xa600fe) {
// Initialization before activation and the passthrough selector
// are protocol requirements, not a pinned parser FSM sequence.
assert(f.mp_initialized);
assert(bytes[7] == 4 || bytes[7] == 5);
f.mp_active = true;
f.activation_mode = bytes[7];
activated = true;
if (f.status_before_activation_ack)
send_status(true);
} else if (address == 0xa400f0) {
assert(bytes[7] == 0x55);
deactivated = f.mp_active;
f.mp_active = false;
if (deactivated && f.deactivation_status_before_ack) {
send_status(false);
send_status(true);
}
} else {
assert((address == 0xa400fb || address == 0xa600fb) && bytes[7] == 0);
}
send_ack(0x16, error);
if (deactivated && f.deactivation_status_after_ack) {
send_status(false);
send_status(true);
}
if (activated && f.status_after_activation_ack)
send_status(true);
break;
}
case 0x17: {
uint32_t address = transaction_address(transaction);
uint16_t remaining = ((uint16_t)bytes[6] << 8) | bytes[7];
assert(remaining && remaining <= 32);
if (address == 0xa400fa && !f.mp_active && f.extension != EXT_NONE) {
if (f.transient_extension_read_errors) {
--f.transient_extension_read_errors;
send_read_reply(address, NULL, 1, 7);
break;
}
if (f.initializing_extension_reads) {
--f.initializing_extension_reads;
const uint8_t initializing[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff};
send_read_reply(address, initializing, sizeof(initializing), 0);
break;
}
}
if (address == f.fail_read_address) {
send_read_reply(address, NULL, 1, 7);
break;
}
while (remaining) {
uint8_t data[16];
uint8_t size = remaining > 16 ? 16 : remaining;
bool available = true;
for (unsigned i = 0; i < size; ++i)
available &= read_wire_byte(bytes[2] & 0xfe, address + i, &data[i]);
send_read_reply(address, data, size, available ? 0 : 7);
if (!available)
break;
address += size;
remaining -= size;
}
break;
}
default:
assert(!"unexpected Wii output report");
}
}
static void finish_setup(void) {
// Output may be emitted recursively by each reply. Bound the wire dialogue
// so retry loops fail instead of hanging pytest.
for (unsigned step = 0; step < 128 && f.cursor < f.sent_count; ++step) {
transaction_t transaction = f.sent[f.cursor++];
answer_transaction(&transaction);
}
assert(f.cursor == f.sent_count);
assert(f.ready_count == 1);
assert(f.report_mode != 0);
}
static void connect_device(void) {
uni_hid_parser_wii_setup(&f.device);
finish_setup();
}
static transaction_t stop_at_read(uint32_t address) {
for (unsigned step = 0; step < 128 && f.cursor < f.sent_count; ++step) {
transaction_t transaction = f.sent[f.cursor++];
if (transaction.bytes[1] == 0x17 && transaction_address(&transaction) == address)
return transaction;
answer_transaction(&transaction);
}
assert(!"required Wii memory request was not sent");
return (transaction_t){0};
}
static void put_accel_report(uint8_t* report, uint16_t x, uint16_t y, uint16_t z) {
assert(!(y & 1) && !(z & 1)); // Y/Z wire samples have no least-significant bit.
report[1] |= (x & 3) << 5;
report[2] |= ((y & 2) << 4) | ((z & 2) << 5);
report[3] = x >> 2;
report[4] = y >> 2;
report[5] = z >> 2;
}
static void put_gyro_report(uint8_t* extension, uint16_t yaw, uint16_t roll, uint16_t pitch,
bool yaw_slow, bool roll_slow, bool pitch_slow) {
extension[0] = yaw;
extension[1] = roll;
extension[2] = pitch;
extension[3] = ((yaw >> 8) << 2) | (yaw_slow ? 2 : 0) | (pitch_slow ? 1 : 0);
extension[4] = ((roll >> 8) << 2) | (roll_slow ? 2 : 0) | (f.extension != EXT_NONE ? 1 : 0);
extension[5] = ((pitch >> 8) << 2) | 2;
}
static void send_motion(uint16_t yaw, uint16_t roll, uint16_t pitch,
bool yaw_slow, bool roll_slow, bool pitch_slow) {
uint8_t report[22] = {0x35, 1, 0x0a}; // Left + A + 1, no connection-time A needed.
put_accel_report(report, 614, 442, 534);
put_gyro_report(&report[6], yaw, roll, pitch, yaw_slow, roll_slow, pitch_slow);
feed(report, sizeof(report));
}
static void expect_vector(const int32_t vector[3], int32_t x, int32_t y, int32_t z) {
assert(vector[0] == x);
assert(vector[1] == y);
assert(vector[2] == z);
}
static void expect_accel(void) {
// Native (+1g, -0.5g, +0.25g) maps to SDL (-X, +Z, +Y).
expect_vector(f.device.controller.gamepad.accel, -8192, 2048, -4096);
}
static void send_core_and_accel(void) {
uint8_t report[22] = {f.report_mode, 1, 0x0a};
assert(f.report_mode == 0x30 || f.report_mode == 0x31 || f.report_mode == 0x35);
uint16_t len = f.report_mode == 0x35 ? 22 : f.report_mode == 0x31 ? 6 : 3;
if (len >= 6)
put_accel_report(report, 614, 442, 534);
feed(report, len);
assert(f.device.controller.gamepad.dpad == DPAD_DOWN);
assert(f.device.controller.gamepad.buttons == (BUTTON_X | BUTTON_A));
}
static void send_nunchuk_controls(bool passthrough) {
uint8_t report[22] = {0x35};
put_accel_report(report, 614, 442, 534);
const uint8_t extension[6] = {160, 192, 128, 128, passthrough ? 129 : 128, passthrough ? 4 : 1};
memcpy(report + 6, extension, sizeof(extension));
feed(report, sizeof(report));
assert(f.device.controller.gamepad.buttons == BUTTON_X);
assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == -341);
assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0);
assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL);
expect_accel();
}
static void integrated_motionplus_calibration_and_slow_bits(void) {
reset_fixture(0x0330, true, EXT_NONE);
connect_device();
assert(f.mp_active && f.activation_mode == 4 && f.report_mode == 0x35);
// Each axis is the only slow axis in one packet. A shared slow flag or
// swapped e[3]/e[4] bits cannot accidentally pass all three vectors.
send_motion(7760, 8200, 7560, false, true, false);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
expect_accel();
assert(f.device.controller.gamepad.dpad == DPAD_DOWN);
assert(f.device.controller.gamepad.buttons == (BUTTON_X | BUTTON_A));
send_motion(8300, 7660, 7560, true, false, false);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, 20 * 1024, -240 * 1024);
send_motion(7760, 7660, 8100, false, false, true);
expect_vector(f.device.controller.gamepad.gyro, -60 * 1024, -120 * 1024, -240 * 1024);
}
static void external_motionplus_without_extension_status(void) {
reset_fixture(0x0306, true, EXT_NONE);
f.status_before_activation_ack = true;
connect_device();
assert(f.mp_active && f.activation_mode == 4 && f.report_mode == 0x35);
send_motion(7760, 8200, 7560, false, true, false);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
expect_accel();
}
static void absent_motionplus_keeps_calibrated_remote(void) {
reset_fixture(0x0306, false, EXT_NONE);
connect_device();
assert(!f.mp_active && f.report_mode == 0x31);
send_core_and_accel();
expect_accel();
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
}
static void setup_read_and_write_errors_leave_buttons_ready(void) {
reset_fixture(0x0330, true, EXT_NONE);
f.fail_read_address = 0xa60030;
connect_device();
send_core_and_accel();
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
reset_fixture(0x0330, true, EXT_NONE);
f.fail_write_address = 0xa600fe;
connect_device();
assert(!f.mp_active);
send_core_and_accel();
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
reset_fixture(0x0306, false, EXT_NUNCHUK);
f.fail_write_address = 0xa400f0;
connect_device();
// A failed reset ACK is not an extension identity. If the subsequent read
// succeeds, preserve the real Nunchuk rather than forcing standalone mode.
send_nunchuk_controls(false);
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
}
static void failed_calibration_never_fabricates_motion(void) {
reset_fixture(0x0330, true, EXT_NONE);
f.accel_calibration[9] ^= 1;
f.accel_calibration[19] ^= 1;
connect_device();
send_motion(7760, 8200, 7560, false, true, false);
expect_vector(f.device.controller.gamepad.accel, 0, 0, 0);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
assert(f.device.controller.gamepad.buttons == (BUTTON_X | BUTTON_A));
reset_fixture(0x0330, true, EXT_NONE);
f.mp_calibration[30] ^= 1;
connect_device();
if (f.report_mode == 0x35)
send_motion(7760, 8200, 7560, false, true, false);
else
send_core_and_accel();
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
expect_accel();
assert(f.device.controller.gamepad.buttons == (BUTTON_X | BUTTON_A));
// A correct CRC alone is insufficient: zero sensitivity must be rejected.
reset_fixture(0x0330, true, EXT_NONE);
memcpy(&f.mp_calibration[6], f.mp_calibration, 2);
update_mp_checksum();
connect_device();
if (f.report_mode == 0x35)
send_motion(7760, 8200, 7560, false, true, false);
else
send_core_and_accel();
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
expect_accel();
}
static void accel_backup_and_memory_error_recovery(void) {
reset_fixture(0x0306, false, EXT_NONE);
f.accel_calibration[9] ^= 1;
connect_device();
send_core_and_accel();
expect_accel();
reset_fixture(0x0306, false, EXT_NONE);
f.fail_accel_reads = true;
connect_device();
send_core_and_accel();
expect_vector(f.device.controller.gamepad.accel, 0, 0, 0);
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
}
static void malformed_and_mismatched_setup_replies_do_not_advance(void) {
reset_fixture(0x0330, true, EXT_NONE);
uni_hid_parser_wii_setup(&f.device);
transaction_t request = stop_at_read(0xa60020);
unsigned count = f.sent_count;
const uint8_t empty[] = {0x21};
const uint8_t short_header[] = {0x21, 0, 0, 0xf0, 0};
const uint8_t short_data[] = {0x21, 0, 0, 0xf0, 0, 0x20, 0};
const uint8_t short_ack[] = {0x22, 0, 0, 0x16};
feed(empty, 0);
feed(empty, sizeof(empty));
feed(short_header, sizeof(short_header));
feed(short_data, sizeof(short_data));
feed(short_ack, sizeof(short_ack));
send_ack(0x12, 0);
send_ack(0x16, 0); // No write is outstanding.
send_read_reply(0x30, &f.mp_calibration[16], 16, 0);
send_read_reply(0x20, f.mp_calibration, 15, 0);
assert(f.sent_count == count && f.ready_count == 0);
// Accept either two 16-byte reads or one 32-byte read; both produce the
// same two wire replies, since a 0x21 response carries at most 16 bytes.
unsigned requested = ((unsigned)request.bytes[6] << 8) | request.bytes[7];
assert(requested == 16 || requested == 32);
send_read_reply(0x20, f.mp_calibration, 16, 0);
if (requested == 16)
(void)stop_at_read(0xa60030);
count = f.sent_count;
// Duplicate first half cannot satisfy the outstanding second-half read.
send_read_reply(0x20, f.mp_calibration, 16, 0);
assert(f.sent_count == count && f.ready_count == 0);
send_read_reply(0x30, &f.mp_calibration[16], 16, 0);
finish_setup();
send_motion(7760, 8200, 7560, false, true, false);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
}
static void short_motion_reports_do_not_publish_partial_samples(void) {
reset_fixture(0x0330, true, EXT_NONE);
connect_device();
uint8_t report[22];
memset(report, 0xff, sizeof(report));
report[0] = 0x35;
unsigned count = f.sent_count;
// The sixth extension byte contains pitch MSBs and the MP discriminator.
// No prefix missing that byte constitutes a valid combined sample.
for (uint16_t len = 1; len < 12; ++len) {
feed(report, len);
expect_vector(f.device.controller.gamepad.accel, 0, 0, 0);
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
}
report[0] = 0x31;
feed(report, 5);
expect_vector(f.device.controller.gamepad.accel, 0, 0, 0);
report[0] = 0x20;
feed(report, 6);
assert(f.sent_count == count && f.ready_count == 1);
send_motion(7760, 8200, 7560, false, true, false);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
expect_accel();
}
static void nunchuk_passthrough_retains_controls_between_samples(void) {
reset_fixture(0x0330, true, EXT_NUNCHUK);
connect_device();
assert(f.mp_active && f.activation_mode == 5 && f.report_mode == 0x35);
uint8_t nunchuk[22] = {0x35, 1, 0x0a};
put_accel_report(nunchuk, 614, 442, 534);
const uint8_t extension[6] = {160, 96, 128, 128, 129, 4}; // C held, Z released.
memcpy(&nunchuk[6], extension, sizeof(extension));
feed(nunchuk, sizeof(nunchuk));
assert(f.device.controller.gamepad.dpad == DPAD_LEFT);
assert(f.device.controller.gamepad.buttons == (BUTTON_B | BUTTON_SHOULDER_L | BUTTON_X));
assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == 170);
uint8_t gyro[22] = {0x35}; // Release core controls but keep the Nunchuk held.
put_accel_report(gyro, 614, 442, 534);
put_gyro_report(&gyro[6], 7760, 8200, 7560, false, true, false);
feed(gyro, sizeof(gyro));
assert(f.device.controller.gamepad.buttons == BUTTON_X);
assert(f.device.controller.gamepad.dpad == 0);
assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == 170);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
expect_accel();
nunchuk[2] &= 0x60; // Preserve accel low bits, release core buttons.
nunchuk[1] &= 0x60;
nunchuk[11] = 8; // C released, Z held: moved bits 3/2, not plain bits 1/0.
feed(nunchuk, sizeof(nunchuk));
assert(f.device.controller.gamepad.buttons == BUTTON_Y);
feed(gyro, sizeof(gyro));
assert(f.device.controller.gamepad.buttons == BUTTON_Y);
assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == 170);
nunchuk[6] = nunchuk[7] = 128;
nunchuk[11] = 12; // Both released; stale cached presses must now disappear.
feed(nunchuk, sizeof(nunchuk));
feed(gyro, sizeof(gyro));
assert(f.device.controller.gamepad.buttons == 0);
assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0);
assert(f.device.controller.gamepad.axis_x == 0 && f.device.controller.gamepad.axis_y == 0);
}
static void legacy_plain_nunchuk_and_wii_u_pro(void) {
reset_fixture(0x0306, false, EXT_NUNCHUK);
connect_device();
assert(!f.mp_active && (f.report_mode == 0x32 || f.report_mode == 0x35));
uint8_t nunchuk[22] = {f.report_mode, 1, 0x0a};
unsigned offset = f.report_mode == 0x35 ? 6 : 3;
if (offset == 6)
put_accel_report(nunchuk, 614, 442, 534);
const uint8_t extension[6] = {160, 192, 128, 128, 128, 1}; // Plain C held, Z released.
memcpy(&nunchuk[offset], extension, sizeof(extension));
feed(nunchuk, f.report_mode == 0x35 ? 22 : 11);
assert(f.device.controller.gamepad.dpad == DPAD_LEFT);
assert(f.device.controller.gamepad.buttons == (BUTTON_B | BUTTON_SHOULDER_L | BUTTON_X));
assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == -341);
assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0);
reset_fixture(0x0330, false, EXT_WII_U_PRO);
connect_device();
assert(!f.mp_active && f.report_mode == 0x34);
uint8_t pro[22] = {0x34};
put_le16(&pro[3], 0x0a80);
put_le16(&pro[5], 0x0580);
put_le16(&pro[7], 0x06c0);
put_le16(&pro[9], 0x0940);
pro[11] = 0x7b; // Right and + held.
pro[12] = 0xbf; // Native B held.
pro[13] = 0xff;
feed(pro, sizeof(pro));
assert(f.device.controller.gamepad.axis_x == 256 && f.device.controller.gamepad.axis_y == 128);
assert(f.device.controller.gamepad.axis_rx == -256 && f.device.controller.gamepad.axis_ry == -128);
assert(f.device.controller.gamepad.dpad == DPAD_RIGHT);
assert(f.device.controller.gamepad.buttons == BUTTON_A);
assert(f.device.controller.gamepad.misc_buttons == MISC_BUTTON_START);
expect_vector(f.device.controller.gamepad.accel, 0, 0, 0);
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
}
static void plus_selects_vertical_without_disabling_motion(void) {
reset_fixture(0x0330, true, EXT_NONE);
f.connection_buttons = 0x10;
connect_device();
assert(f.mp_active && f.report_mode == 0x35);
send_motion(7760, 8200, 7560, false, true, false);
assert(f.device.controller.gamepad.dpad == DPAD_LEFT);
assert(f.device.controller.gamepad.buttons == (BUTTON_B | BUTTON_X));
expect_accel();
}
static void unsolicited_status_restores_stream_without_duplicate_ready(void) {
reset_fixture(0x0330, true, EXT_NONE);
connect_device();
unsigned start = f.sent_count;
send_status(true);
finish_setup();
bool restored = false;
for (unsigned i = start; i < f.sent_count; ++i) {
if (f.sent[i].bytes[1] == 0x12 && f.sent[i].bytes[3] == 0x35)
restored = true;
assert(f.sent[i].bytes[1] != 0x16); // Reinitialization deactivates MP.
}
assert(restored && f.ready_count == 1 && f.mp_active);
start = f.sent_count;
send_ack(0x16, 0);
send_read_reply(0xfa, (const uint8_t[]){0, 0, 0xa6, 0x20, 0, 5}, 6, 0);
assert(f.sent_count == start && f.ready_count == 1);
send_motion(7760, 8200, 7560, false, true, false);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
}
static void initializing_and_vendor_nunchuk_ids_at_connection(void) {
reset_fixture(0x0330, true, EXT_NUNCHUK);
// Some third-party revisions do not have the canonical 0000 prefix.
f.extension_identity[0] = 1;
f.initializing_extension_reads = 2;
f.transient_extension_read_errors = 1;
f.status_after_activation_ack = true;
connect_device();
assert(f.mp_active && f.activation_mode == 5);
send_nunchuk_controls(true);
send_motion(7760, 8200, 7560, false, true, false);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.buttons & BUTTON_X);
// An all-zero suffix is not proof of absence: SDL and upstream recognize
// the zero-filled ID documented for a replugged wireless BladeFX adapter.
reset_fixture(0x0306, false, EXT_NUNCHUK);
memset(f.extension_identity, 0, sizeof(f.extension_identity));
connect_device();
send_nunchuk_controls(false);
}
static void motionplus_nunchuk_hotplug_and_detach(void) {
reset_fixture(0x0330, true, EXT_NONE);
connect_device();
assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL);
f.deactivation_status_before_ack = true;
f.status_after_activation_ack = true;
f.extension = EXT_NUNCHUK;
// Downstream hotplug is carried in MP data, not the ordinary status bit.
send_motion(7760, 8200, 7560, false, true, false);
finish_setup();
assert(f.mp_active && f.activation_mode == 5 && f.ready_count == 1);
send_nunchuk_controls(true);
// Unsolicited status starts a mapped-ID check. A detach during that pending
// read must be deferred, not overwrite its address-less transaction state.
send_status(false);
transaction_t verify = stop_at_read(0xa400fa);
f.extension = EXT_NONE;
uint8_t gyro[22] = {0x35};
put_accel_report(gyro, 614, 442, 534);
put_gyro_report(gyro + 6, 7760, 8200, 7560, false, true, false);
feed(gyro, sizeof(gyro));
assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0);
assert(f.device.controller.gamepad.axis_x == 0 && f.device.controller.gamepad.axis_y == 0);
assert(f.device.controller.gamepad.buttons == 0);
assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
answer_transaction(&verify);
finish_setup();
assert(f.mp_active && f.activation_mode == 4 && f.ready_count == 1);
send_motion(7760, 8200, 7560, false, true, false);
assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0);
assert(f.device.controller.gamepad.axis_x == 0 && f.device.controller.gamepad.axis_y == 0);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
expect_accel();
}
static void external_motionplus_hotplug_retains_selected_orientation(void) {
reset_fixture(0x0306, true, EXT_NONE);
f.connection_buttons = 0x10;
connect_device();
f.deactivation_status_after_ack = true;
f.status_before_activation_ack = true;
f.extension = EXT_NUNCHUK;
send_motion(7760, 8200, 7560, false, true, false);
finish_setup();
send_nunchuk_controls(true);
f.extension = EXT_NONE;
send_motion(7760, 8200, 7560, false, true, false);
finish_setup();
assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL);
assert(f.mp_active && f.activation_mode == 4 && f.ready_count == 1);
send_motion(7760, 8200, 7560, false, true, false);
assert(f.device.controller.gamepad.dpad == DPAD_LEFT);
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
// Removing the external MP itself clears its stale angular velocity, while
// the Remote's independently calibrated accelerometer remains available.
f.motionplus = false;
f.mp_active = false;
send_status(false);
finish_setup();
uint8_t remote[6] = {0x31, 1, 0x0a};
put_accel_report(remote, 614, 442, 534);
feed(remote, sizeof(remote));
assert(f.device.controller.gamepad.dpad == DPAD_LEFT);
assert(f.device.controller.gamepad.buttons == (BUTTON_B | BUTTON_X));
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
expect_accel();
assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL);
}
static void plain_nunchuk_hotplug_and_setup_race(void) {
reset_fixture(0x0306, false, EXT_NONE);
uni_hid_parser_wii_setup(&f.device);
transaction_t accel = stop_at_read(0x16);
// An attachment status while EEPROM is being read must not lose that read.
f.extension = EXT_NUNCHUK;
send_status(true);
answer_transaction(&accel);
finish_setup();
send_nunchuk_controls(false);
f.extension = EXT_NONE;
send_status(false);
assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0);
assert(f.device.controller.gamepad.axis_x == 0 && f.device.controller.gamepad.axis_y == 0);
assert(f.device.controller.gamepad.buttons == 0);
finish_setup();
assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL);
send_core_and_accel();
expect_accel();
uni_hid_parser_wii_set_mode(&f.device, WII_MODE_VERTICAL);
finish_setup();
f.extension = EXT_NUNCHUK;
send_status(true);
finish_setup();
send_nunchuk_controls(false);
f.extension = EXT_NONE;
send_status(false);
finish_setup();
assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL);
assert(f.ready_count == 1);
}
static void expect_nunchuk_stick(uint8_t x, uint8_t y, int expected_x, int expected_y) {
uint8_t report[22] = {0x35};
put_accel_report(report, 614, 442, 534);
report[6] = x;
report[7] = y;
report[10] = 1; // Extension connected.
report[11] = f.mp_active ? 12 : 3; // C/Z released in the selected format.
feed(report, sizeof(report));
assert(f.device.controller.gamepad.axis_x == expected_x);
assert(f.device.controller.gamepad.axis_y == expected_y);
assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0);
}
static void calibrated_nunchuk_left_stick_endpoints_and_replacement(void) {
reset_fixture(0x0330, true, EXT_NUNCHUK);
set_nunchuk_stick_calibration((const uint8_t[]){220, 40, 124, 210, 30, 126});
connect_device();
expect_nunchuk_stick(124, 126, 0, 0);
expect_nunchuk_stick(220, 126, 511, 0);
expect_nunchuk_stick(40, 126, -512, 0);
expect_nunchuk_stick(124, 210, 0, -512);
expect_nunchuk_stick(124, 30, 0, 511);
expect_nunchuk_stick(172, 168, 256, -256);
expect_nunchuk_stick(82, 78, -256, 256);
expect_nunchuk_stick(255, 0, 511, 511);
expect_nunchuk_stick(0, 255, -512, -512);
send_motion(7760, 8200, 7560, false, true, false);
assert(f.device.controller.gamepad.axis_x == -512 && f.device.controller.gamepad.axis_y == -512);
f.extension = EXT_NONE;
send_motion(7760, 8200, 7560, false, true, false);
finish_setup();
assert(f.device.controller.gamepad.axis_x == 0 && f.device.controller.gamepad.axis_y == 0);
// A different Nunchuk must not inherit the previous extension's center/range.
set_nunchuk_stick_calibration((const uint8_t[]){230, 50, 130, 220, 40, 120});
f.extension = EXT_NUNCHUK;
send_motion(7760, 8200, 7560, false, true, false);
finish_setup();
expect_nunchuk_stick(130, 120, 0, 0);
expect_nunchuk_stick(230, 40, 511, 511);
}
static void unavailable_nunchuk_calibration_keeps_safe_nominal_stick(void) {
reset_fixture(0x0306, false, EXT_NUNCHUK);
f.fail_read_address = 0xa40020;
connect_device();
expect_nunchuk_stick(128, 128, 0, 0);
expect_nunchuk_stick(224, 32, 511, 511);
reset_fixture(0x0330, true, EXT_NUNCHUK);
set_nunchuk_stick_calibration((const uint8_t[]){128, 32, 128, 224, 32, 128});
connect_device(); // Correct checksum but zero positive span.
expect_nunchuk_stick(160, 96, 170, 170);
reset_fixture(0x0330, true, EXT_NUNCHUK);
f.nunchuk_calibration[15] ^= 1;
connect_device();
expect_nunchuk_stick(128, 128, 0, 0);
expect_nunchuk_stick(224, 32, 511, 511);
}
static void run_case(const char* name, void (*test)(void)) {
printf("Wii parser: %s\n", name);
fflush(stdout);
test();
}
int main(void) {
run_case("calibrated Nunchuk left-stick endpoints and replacement", calibrated_nunchuk_left_stick_endpoints_and_replacement);
run_case("unavailable Nunchuk calibration uses safe nominal travel", unavailable_nunchuk_calibration_keeps_safe_nominal_stick);
run_case("integrated MotionPlus calibration and per-axis slow bits", integrated_motionplus_calibration_and_slow_bits);
run_case("external MotionPlus with absent extension status bit", external_motionplus_without_extension_status);
run_case("absent MotionPlus retains calibrated remote", absent_motionplus_keeps_calibrated_remote);
run_case("setup read/write errors leave buttons ready", setup_read_and_write_errors_leave_buttons_ready);
run_case("invalid calibration suppresses only unavailable motion", failed_calibration_never_fabricates_motion);
run_case("accelerometer backup and EEPROM errors", accel_backup_and_memory_error_recovery);
run_case("malformed and mismatched setup replies", malformed_and_mismatched_setup_replies_do_not_advance);
run_case("short reports reject partial motion samples", short_motion_reports_do_not_publish_partial_samples);
run_case("Nunchuk passthrough retains and releases held controls", nunchuk_passthrough_retains_controls_between_samples);
run_case("legacy plain Nunchuk and Wii U Pro mappings", legacy_plain_nunchuk_and_wii_u_pro);
run_case("connection-time plus selects vertical motion mode", plus_selects_vertical_without_disabling_motion);
run_case("unsolicited status restores reporting exactly once", unsolicited_status_restores_stream_without_duplicate_ready);
run_case("initializing and vendor Nunchuk identities", initializing_and_vendor_nunchuk_ids_at_connection);
run_case("integrated MotionPlus downstream hotplug and detach", motionplus_nunchuk_hotplug_and_detach);
run_case("external MotionPlus preserves selected standalone orientation", external_motionplus_hotplug_retains_selected_orientation);
run_case("plain Nunchuk hotplug and setup-time status race", plain_nunchuk_hotplug_and_setup_race);
puts("Wii parser native contracts passed");
return 0;
}