diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index 0f450ea..dc5bb52 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -160,6 +160,14 @@ including an additional 8BitDo Ultimate, require opening the BOOTSEL pairing window while another controller is active. Do not restore continuous inquiry as a convenience feature; it causes gameplay latency. +A narrowly scoped exception now permits low-duty passive BLE discovery while +a ready solo Joy-Con 2 awaits a remembered opposite half. It requires free +physical capacity and no pending controller setup, accepts only remembered +opposite-half candidates, and stops when the pair completes. It neither +starts Classic inquiry nor opens new authentication. Lifecycle regressions +cover setup failure, capacity exhaustion, pairing-window transitions and +unrelated-controller isolation. + ## Implementation principles ### Protocol-neutral state @@ -523,6 +531,8 @@ Target hardware families: | DualShock 4 | input, Switch motion, rumble, lightbar | | Switch Pro | input, motion, rumble, player LED | | Joy-Con L/R | each half as the standalone controller exposed by Bluepad32 | +| Switch 2 Pro | BLE setup, sticks/motion and C/GL/GR observed; native HD frequencies and timing physically measured; motion accuracy/endurance need wider qualification | +| Joy-Con 2 L/R | paired stereo HD rumble physically confirmed; remembered reconnect and mixed-radio interval transitions exercised; long-duration qualification remains | | Wii Remote | buttons, accelerometer, rumble | | Wii Remote + Classic Controller | extension controls | | Wii U Pro | buttons, sticks, rumble | @@ -619,6 +629,68 @@ The live playtest, eight-slot catalog, controller-native labels, automatic active-profile synchronization, and Sets A/B are implemented. Set C remains candidate work; the native Switch-family output plan below is separate. +Studio now uses nine supplied SVG assets with model-specific, viewBox-relative +hotspots and sideways Joy-Con source mappings. Live playtest schema 4 adds a +read-only topology byte; the host retains schema 2/3 compatibility. Auto views +follow actual matching-owner pair/solo metadata; explicit previews do not +change firmware topology or highlight another controller as detected hardware. +Rear/rail controls remain off-art and unavailable stored mappings are retained. +Chromium checks covered all assets, ten layout/orientation views, mobile +panning and non-overlapping hotspots, remapping, and simulated live topology +transitions without losing focus or draft data. The connected real Joy-Con pair +reported its paired layout on the flashed firmware. All 293 tests and all five +firmware builds passed at checkpoint `8451b1e`. + +Joy-Con 2 L + R now has a distinct persistent owner keyed by both typed +Bluetooth addresses. Its first bank atomically snapshots the left bank's eight +profiles, names and active slot; the new alias is empty. Both solo banks remain +unchanged. Later edits are copy-on-write, reconnect never reseeds, and a +different member combination gets a different owner. The first-ready USB slot +and existing input/rumble generation barriers remain intact. Failed seed +initialization leaves the first solo active. + +Indexed catalog 3 migrates catalog 1/2 atomically at boot. Tagged pair identities +remain 14 bytes and cannot enter physical Bluetooth approval inventories. +Regression coverage includes pair/solo edits and activation, both connection +orders, member replacement, capacity, compaction, interrupted publication and +committed-but-unacknowledged seeds. All 321 tests passed; the physical catalog +migration preserved the seven existing owners and all 56 profiles, metadata, +active selections and adapter settings. +The real pair subsequently created its separate L + R owner: eight owners and +64 profiles, with all eight initial pair profiles/names matching the left bank. +An inactive pair-profile edit and rename survived reboot without touching either +solo bank; independent activation was checked and all test settings restored. +Studio's selected pair key matched the live firmware owner, and Identify +produced two physical dispatches. + +Joy-Con 2 now supports a persisted adapter-wide Paired/Individual default in +configuration schema 4, byte 4 of the unchanged 232-byte payload. Studio and +`joycon-mode [paired|individual]` apply it live. Individual uses separate +sideways players/solo banks; Paired reuses the composite bank. Live split/join +preserves Bluetooth links, unrelated players and existing pair membership, +while clearing affected input/capture/rumble epochs. + +The physical connection-only override is a two-second hold of left ZL+Minus +and right ZR+Plus. It joins the selected solos or splits their current pair; +ambiguous solo arming does not choose arbitrarily. Reserved inputs are masked +before profile/macro handling, both participants need fresh held reports, and +all chord buttons must release before retry. Confirmation is bounded to the +participating halves. Disconnect/reuse or a changed saved default clears the +override, without writing a per-pair preference. + +Explicit gesture joins retain the lower participating player slot rather than +always retaining the left half's slot. A regression reproduced the old player-2 +result with the left half ready first but occupying the higher transport/player +index. Both ready orders and an earlier unrelated player are covered. Hardware +with right on player 1 and left on player 2 now joins on player 1; all profile +banks remain unchanged. + +All 349 tests and all five builds passed. Real hardware exercised both live +mode transitions, Individual boot persistence, physical join/split pulses, +unchanged configuration generations during gestures, and automatic default +restoration after resetting/reconnecting one half. All 64 profile records and +metadata remain unchanged; the final saved default is Individual. + | Priority | Candidate | Intended scope | Dependency or principal risk | |---|---|---|---| | A1 — Complete | Named and copyable profiles | Profile names and controller aliases use catalog metadata rather than input-profile fields. Copy/export/import preserve the current draft and per-section resets leave other settings unchanged. | Atomic metadata records; no report-path cost. | @@ -703,6 +775,92 @@ Set B delivery evidence: firmware-timestamped input, explicit start/stop/run identity and visible capacity/time/disconnect termination. It does not write profiles until Save. +### Switch 2 controller input — Expanded support implemented + +- Scoped proprietary BLE implementation informed by Bluepad32 PR219 at + `9c95e43a87d3bd8a68565da0836d8a758bd8d8af`; no unrelated Steam/Xbox/Sony + fork changes. Dynamic GATT discovery, matched acknowledgements, calibration, + normalized motion and retained finite/stateful rumble replace the unsafe + fixed-handle/timeout-advance behavior in that proposal. +- Joy-Con 2 pairs retain the first-ready USB slot and use an independent pair identity; + either connection order works. Right-half motion, both-half feedback, + sideways solo operation and neutral detach transitions are implemented. + A pair still consumes two of the four physical Bluetooth connections. +- Seven source-only inputs (C, GL/GR and four rail buttons) are editable in + Profile Studio, including Shift mappings and action/macro modifiers. + Profile schema 7 uses reserved bytes in the unchanged 384-byte payload; + schemas 1–6 retain their prior settings and initialize extras unmapped. +- Application pairing has its own bounded persistent authorization list. + Fresh pairing remains window-gated; Clear pairings forgets the list and + reports storage failure instead of falsely acknowledging a clear. + These proprietary BLE links are unencrypted, not authenticated SMP bonds. +- Verification: 287 tests passed and all five firmware variants built. + The flashed real Switch 2 Pro delivered sticks, motion and independent + C/GL/GR combinations; schema-7 extra mappings saved/read/restored. + All 32 pre-existing profiles, metadata and active indices survived. + Subsequent native-rumble and mixed-radio measurements are summarized below. +- Full operation and limitations: [README.md](README.md#switch-2-controller-input). + +Switch 2 native HD output now preserves decoded stereo frequency/amplitude +and up to three subframes, using microphone-verified 10-bit frequency fields +and `0x50/0x60/0x70` sample-count headers. A 90-packet timing run measured +approximately 5.3 ms per frame; the sender guards 6/11/16 ms for complete +batches. Two bounded queue stages retain receipt timestamps; obsolete work +is counted and discarded rather than replayed or allowed to starve fresh work. +Keepalives/feedback resumption retain only the current final sample. + +The final Pro-only native run sustained 512 changing one-subframe commands at +125.14 Hz with zero queue drops. Stereo/sweep/three-subframe patterns also +recorded zero drops. Deliberate three-subframe saturation at 125.11 Hz +discarded/superseded 69 output-stage commands out of 128 host updates and +recovered to a clean stop. No mixed-radio lossless claim is made. +Paired Joy-Con 2 stereo/subframe patterns produced 394 physical dispatches +from 197 host requests without queue drops; the user confirmed both sides. +Normal GATT-client busy responses now retain queued output instead of +disconnecting the controller. + +Joy-Con 2 links now retain 7.5 ms BLE intervals in Paired and Individual modes, +including when a Classic controller connects. This intentionally removes their +automatic 30 ms slowdown while preserving the idle-neutral sending fix. Other +Switch 2 models retain 30 ms when multiple physical Switch 2 links are present, +otherwise 7.5 ms; unrelated BLE controllers are untouched. Lifecycle coverage +checks modes, Classic arrival/departure, handle reuse, slow-interval correction, +and the remaining Switch 2 Pro coexistence/retry policy. + +Idle-neutral output ends after three successful neutral submissions/completions. +Late non-neutral completion re-arms stops even after a logical reset; failed or +pending writes do not consume the budget. Native subframes, expiry and normal +active-output cadence remain unchanged. The direct lab fixtures and unqualified +shared Classic/GATT admission experiment are excluded from this checkpoint. + +Measured limits remain important: +- At 30 ms, idle suppression reduced outgoing HCI ACL writes from 974 to 469 per + ten seconds with the same 469 DualSense PCM sends. Total HCI transactions did + not decrease because receive-credit traffic increased. +- The optimized 30-second mixed run sent 1,431 PCM packets without skips/send + failures, but had two Switch 2 ingress drops and nine output-stage drops. + The user later clarified that 30 ms Joy-Con rumble was weak; activity and + clean stops were not full quality approval. DualSense was weak/inconsistent. +- A single right-Joy-Con direct-packet reference confirmed clear tone and good + feel at a nominal 7.5 ms packet cadence: 267 packets per two-second burst, + 7.504 ms average submission spacing, same 320 Hz/amplitude as the weak 20 ms + one-/three-sample trials. This is distinct from the BLE link interval. +- Adding DualSense PCM to the fast fixture timed out in about 0.66 seconds, + with one or both Joy-Cons vibrating (both links connected in either case). + Both runs sent only 11 PCM packets; the two-vibrating run also had Joy-Con + submission gaps up to 32 ms. Explicit stop writes completed after abort. + +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. + +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, +and that production image passed another simultaneous-rumble exercise after +normal controller reconnect. See the +[native HD contract and measurements](README.md#switch-2-native-hd-rumble). + ### Native Switch-family HD rumble — Implemented, qualification incomplete Standalone agent handoff: [SWITCH_FAMILY_HD_RUMBLE_PLAN.md](SWITCH_FAMILY_HD_RUMBLE_PLAN.md). @@ -731,10 +889,10 @@ Current constraints: - `native_output_scheduler.cpp` arbitrates both native writers with urgent-stop precedence, earliest deadlines, rotating ties, periodic credit reservations, and generation-bound grant completion. Radio power policy is unchanged. -- Adapter configuration schema 3 persists up to 16 explicit physical approvals. - Existing profile schema 6/catalog 2, bonds and wake identity are unchanged. -- Joy-Cons are currently separate, horizontally mapped controllers in - Bluepad32. A paired two-Joy-Con logical controller is not implemented. +- Adapter configuration schema 4 persists up to 16 explicit physical approvals and the Joy-Con default mode. + Profile schema 7/catalog 3 preserves prior settings; bonds and wake identity are unchanged. +- Original Switch Joy-Cons remain separate, horizontally mapped controllers. + Joy-Con 2 logical pairing is a distinct BLE implementation described above. Delivery order: diff --git a/CMakeLists.txt b/CMakeLists.txt index 24b2bb4..0ff097d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -197,6 +197,8 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") set(BLUEPAD32_SOURCE ${CMAKE_CURRENT_LIST_DIR}/external/bluepad32) set(BLUEPAD32_PATCH ${CMAKE_CURRENT_LIST_DIR}/patches/bluepad32-sdl3-imu.patch) set(BLUEPAD32_ROOT ${CMAKE_CURRENT_BINARY_DIR}/_deps/bluepad32-src) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + ${BLUEPAD32_PREP_SCRIPT} ${BLUEPAD32_PATCH}) execute_process( COMMAND ${Python3_EXECUTABLE} ${BLUEPAD32_PREP_SCRIPT} --bluepad32 ${BLUEPAD32_SOURCE} @@ -225,6 +227,10 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") ${BLUEPAD32_ROOT}/src/components/bluepad32 ${CMAKE_CURRENT_BINARY_DIR}/libbluepad32 ) + target_sources(bluepad32 PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_hid_parser_switch2.c + ${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_switch2_pairing.c + ${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_switch2_haptics.c) endif() # Firmware sources live under one include root and are grouped by responsibility. @@ -287,6 +293,9 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") PICO_BTSTACK_CYW43_MAX_HCI_PROCESS_LOOP_COUNT=$,1,16> SWITCH2_WAKE_CONFIGURED=${SWITCH2_WAKE_CONFIGURED_VALUE} ) + # GATT's automatic SMP retry is incompatible with Switch 2's proprietary + # pairing. Reject only those links; retain normal SMP for every other pad. + target_link_options(switch-pico PRIVATE "LINKER:--wrap=sm_request_pairing") if(SWITCH_PICO_CYW43_PACKET_READ) target_sources(switch-pico PRIVATE ${SWITCH_PICO_SOURCE_DIR}/platform/pico/cyw43_packet_transport.c) diff --git a/README.md b/README.md index aac6bd7..fbb5b4e 100644 --- a/README.md +++ b/README.md @@ -176,15 +176,18 @@ exact pre-capture firmware and persistent state. - DualSense: hold Create + PS. - 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. - 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. -Pairing order determines the initial USB slot assignment. Up to four controllers map 1:1 to the four emulated Switch Pro Controller interfaces. +Pairing order determines the initial USB slot assignment. Up to four physical Bluetooth controllers are supported. Ordinary controllers each occupy one emulated Switch Pro interface; a merged Joy-Con 2 pair consumes two Bluetooth connections but occupies one USB player slot. With no active controller, the Pico runs Bluepad32 discovery and autoconnect. After any controller becomes active, active discovery pauses to protect input, motion, and rumble latency; bonded controllers may still initiate incoming reconnects. Pairing keys persist across Pico power cycles, so reconnect a previously paired controller by pressing its normal Home, PS, or Xbox power button. Hold BOOTSEL for the bounded pairing window before pairing a new controller or a controller that requires host-side discovery. Outside that window, BTstack remains non-bondable and rejects new Classic and BLE authentication. -To clear every stored Classic and BLE pairing without a PC, hold BOOTSEL continuously for 10 seconds. The normal pairing window opens after two seconds; continuing to hold until the LED changes to a rapid blink clears all bonds, disconnects active controllers, publishes neutral state to every slot, and closes new authentication. Release BOOTSEL, open a new pairing window, and pair controllers again. +Exception: a ready solo Joy-Con 2 keeps a low-duty passive BLE scan running for a remembered opposite half while physical capacity remains and no controller setup is pending. This reconnect does not require BOOTSEL, start Classic inquiry, or enable fresh pairing. Scanning stops when the pair completes; an explicit pairing window restores normal discovery. + +To clear every stored Classic, BLE and proprietary Switch 2 pairing without a PC, hold BOOTSEL continuously for 10 seconds. The normal pairing window opens after two seconds; continuing to hold until the LED changes to a rapid blink clears remembered controllers, disconnects active controllers, publishes neutral state to every slot, and closes new authentication. Release BOOTSEL, open a new pairing window, and pair controllers again. A persistent-storage failure is reported rather than acknowledging a successful clear. ### LED meanings and device state @@ -203,9 +206,60 @@ The Pico 2 W onboard LED reports the overall Bluetooth state: - **Reconnect a paired controller**: power it on normally with its Home, PS, or Xbox button. - **8BitDo Ultimate Bluetooth reconnect**: leave its selector in Bluetooth mode, press Home once, then shake it. After an abrupt controller power-off, the Pico can remain solid for up to four seconds while Bluetooth link supervision confirms the disconnect; scanning restarts immediately afterward. - **Pair a new controller**: hold BOOTSEL until the LED double-blinks, then put the controller into its explicit Bluetooth pairing mode. -- **Pairing window expires**: new authentication and active discovery stop while a controller is active; remembered controllers may still initiate reconnects. +- **Pairing window expires**: new authentication and full discovery stop while a controller is active; remembered controllers may still initiate reconnects, and a solo Joy-Con 2 resumes low-duty discovery of its remembered opposite half. - **Clear all pairings**: hold BOOTSEL continuously for 10 seconds, through the initial double blink, until the rapid confirmation blink starts. All controllers are disconnected and must be paired again. +### Joy-Con 2 player arrangement + +In Controller Studio, use **Joy-Con 2 default mode** and **Apply default mode**. +This is a real adapter-wide setting, separate from the artwork preview: + +- **Paired:** ready opposite halves form one player and use their L+R bank. +- **Individual:** both halves can remain connected as separate sideways players, + each using its own solo bank. Select the L or R owner to edit its settings. + +Changes apply live without Bluetooth disconnection or fresh pairing. Affected +input, macro/capture and rumble epochs are cleared; unrelated players and all +saved banks remain intact. Unmatched halves stay solo. The default persists +across reboot: + +```sh +uv run switch-pico-config joycon-mode +uv run switch-pico-config joycon-mode individual +uv run switch-pico-config joycon-mode paired --json +``` + +#### Join or split a specific pair from the controllers + +- On the **left** Joy-Con 2, hold **ZL + Minus**. +- On the **right** Joy-Con 2, hold **ZR + Plus**. +- Hold both chords together for **2 seconds**, until the short confirmation + pulse, then **release all four buttons**. + +Two solo halves join; an existing pair splits. Other controllers are untouched. +An explicit join keeps the lower of the two participating player slots, not +whichever slot belongs to the left half. Both LEDs follow that retained slot; +the other slot is neutralized. It never takes a slot from an unrelated player. +The shortcut does not change the saved default: its connection-only override +ends when either participating half disconnects, or when the adapter default +changes. Existing solo/pair banks and active selections are reused. + +Each half's complete two-button chord is reserved while held and until both +buttons on that half release, even if the other half has not joined the +attempt. Those inputs do not reach profile macros or the console. Other inputs +remain available. Fresh held reports from both halves are required; stale, +failed or ambiguous attempts require release before retry. When several solo +halves are armed ambiguously, no arbitrary pair is chosen. A chord cannot steal +a member from a different already-joined pair. + +For a non-destructive disconnect/reset, Nintendo documents pressing +[SYNC once, then a normal button to wake the Joy-Con 2](https://en-americas-support.nintendo.com/app/answers/detail/a_id/68521/). + +Configuration schema 4 keeps the 232-byte object and stores the default mode +in byte 4 (`0` Paired, `1` Individual); bytes 5–7 remain reserved. Schemas 1–3 +migrate to Paired, preserving USB mode, pairing-window duration and existing +native approvals. Older firmware cannot save this setting. + ### Managing configuration, profiles, and pairings from a PC Connect the Pico 2 W to the PC while the AIO firmware is running normally; do not enter the ROM BOOTSEL drive. `switch-pico-config` uses versioned private vendor requests on USB endpoint 0, so it does not add an interface or depend on Linux `hidraw` nodes. @@ -240,10 +294,20 @@ Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and ` `profiles edit` starts a local-only browser editor at `http://127.0.0.1:8765/`. It exposes every profile field: all 16 buttons plus the L2/R2 analog triggers can be remapped to any button or trigger output; both sticks and triggers retain independent deadzone/saturation/curve settings; and rumble, confirmation, Turbo/Auto Burst, built-in action chords, and four custom macro sequences are editable. Its live playtest compares current raw stick and trigger input with the unsaved draft, shows deadzone/saturation boundaries and digital thresholds, and highlights pressed physical controls. Select a controller identity and one of its eight profile slots, use **Start from defaults** for a new draft, then **Save to Pico**. The backend validates the complete profile before using the existing chunked atomic transaction; invalid drafts never reach flash. Use `profiles edit --no-browser` for a printed URL or `profiles edit --port PORT` to choose another local port. -The editor selects Switch Pro, DualSense, or Xbox artwork from the connected controller's USB VID/PID and places each remappable control directly over the matching physical button. Controller artwork is from [AL2009man/Gamepad-Asset-Pack](https://github.com/AL2009man/Gamepad-Asset-Pack) under its MIT license; the bundled license and source revision are recorded beside the assets. +Switch 2's **C, GL, GR, Left SL/SR and Right SL/SR** are additional source-only controls. Map each to a normal button or trigger, assign a button-only alternate Shift mapping, or use it in action/macro chords, cancellation and modifiers. Extra mappings default to disabled. The live playtest shows raw extras separately; they are not fictitious output channels on the emulated Switch Pro/XInput controller. + +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. + +**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. Profile names and controller aliases are stored as independently checksummed -catalog metadata. Runtime profiles use schema 6 and 384-byte records; names remain separate. The +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 editor can rename and copy profiles across controllers and slots, import or export JSON backups, and reset one section without discarding the rest of the draft. Its response-curve cards provide named presets, exact Q8.8 fine @@ -254,7 +318,7 @@ rumble/light pulse only to the selected live controller. `profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns eight persistent profiles and one active index. The JSON export/import commands remain available for version-controlled or scripted profiles. Profile numbers shown to users are `1` through `8`; `--identity` uses the zero-based index from `profiles list`. -`pairings list` refreshes and prints stored Bluetooth Classic and BLE addresses. `pairings clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device. +`pairings list` refreshes and prints stored Bluetooth Classic and BLE addresses, including Switch 2's application-level authorizations. `pairings clear --yes` forgets them all, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device. `diagnostics` reports Bluetooth initialization stage, real BTstack timer callbacks, controller report traffic, host/local rumble requests and @@ -263,10 +327,16 @@ build services one CYW43 packet per poll and explicitly reschedules remaining input. Packet-level ring reads and bounded incoming-credit batching reduce bus work without disabling flow control. `haptics-experiment profile --json` adds transport timings, clock/voltage settings and packet-size diagnostics. +Switch 2 native output adds separate ingress and output-stage drop counters. +The management response extends from 32 to 40 bytes; the updated host tool +still reads older 32-byte responses and treats their missing counters as +unreported, not zero. These count firmware queue discards/rejections, not +physical actuator-delivery receipts. + ### Per-controller profiles -The profile editor lists **Cycle active profile**, **Toggle motion**, and **Run custom macro** as separate editable actions. Every action chord can contain any combination of the 16 buttons and the L2/R2 analog triggers. The default profile-switching chord is **L + R + Select + Start**; on DualSense, use **L1 + R1 + Create + Options**. A stored empty chord selects that default. +The profile editor lists **Cycle active profile**, **Toggle motion**, and **Run custom macro** as separate editable actions. Every action chord can contain any combination of the 16 buttons, L2/R2 analog triggers and seven Switch 2 extra inputs. The default profile-switching chord is **L + R + Select + Start**; on DualSense, use **L1 + R1 + Create + Options**. A stored empty chord selects that default. - The chord cycles persistent profiles `1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 1`. - Chord buttons are consumed locally and are not forwarded to the host. @@ -314,6 +384,8 @@ When a controller becomes ready, RGB-capable devices such as DualSense and DualS |---|---:|---:|---:| | DualSense / DualShock 4 | Yes | Yes | Yes | | Switch Pro / Joy-Con | Yes | Yes | Yes | +| 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 | | 8BitDo in Switch-compatible Bluetooth mode | Yes | Model-dependent | Yes when the mode exposes IMU | @@ -321,6 +393,148 @@ When a controller becomes ready, RGB-capable devices such as DualSense and DualS 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. +### 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. + +- **Pairing:** fresh SYNC pairing requires the existing bounded pairing window. A directed reconnect must target this adapter's Bluetooth address and match its persistent application-level authorization. These links are unencrypted and are **not authenticated SMP bonds**. No global Bluetooth security downgrade is made; automatic SMP requests for these devices fail closed while other controllers retain their existing policy. Public/static addresses can own profiles; transient private addresses are not promoted to persistent identities. +- **Joy-Con ownership:** the saved player-mode default and connection-only shortcut determine whether ready opposite halves join. Enrollment must finish before a pair is published; a failed join leaves the solos intact. Pair identity includes both typed member addresses, and the right half supplies paired motion. Splitting clears stale input/effects and restores sideways solo banks. Changing the default does not disconnect Bluetooth; a participant disconnect clears its temporary grouping override. Two pairs exhaust the four physical Bluetooth connections. New authentication remains restricted to the normal pairing window. +- **Protocol:** service, characteristic and CCCD UUIDs are discovered rather than trusting fixed ATT handles. Setup requires matching acknowledgements, reads user/factory stick calibration and gyro bias, and rejects malformed/failed transactions. Motion is normalized to the existing SDL-oriented units; sensor clock/range classification and physical axis accuracy still need wider model qualification. +- **Rumble:** Switch HD commands now retain independent left/right frequency/amplitude fields and up to three ordered subframes through the native Switch 2 encoder and bounded queues described below. XInput and local feedback retain their conventional fixed-carrier behavior. This is separate from the original Switch-native opt-in backend. +- **Not implemented:** Joy-Con mouse output, native GameChat signaling, NFC/IR and Switch 2 NSO GameCube support. C and back/rail inputs can instead be remapped to controls the selected USB mode supports. + +This is a scoped reimplementation informed by [Bluepad32 PR #219](https://github.com/ricardoquesada/bluepad32/pull/219), reviewed at `9c95e43a87d3bd8a68565da0836d8a758bd8d8af`, not a wholesale fork import. Protocol references: [ndeadly's research](https://github.com/ndeadly/switch2_controller_research), [Nadeflore](https://github.com/Nadeflore/switch2-controllers), [Switch2Connect](https://github.com/TommyWabg/Switch2Connect), and [SDL's Switch 2 sensor implementation](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch2.c). + +**Verification:** 349 tests passed; AIO, XInput/feasibility, HD-rumble, haptics and UART firmware variants built. Native protocol tests use real BTstack types/accessors. Lifecycle/storage tests cover player-mode transitions, both connection orders, two-pair membership, bank isolation and interrupted initialization. Gesture cases cover timing/freshness/wrap, input masking, real macro/capture cancellation, ambiguity, failures, and disconnect/default restoration. Studio controls, draft preservation, CLI refresh and desktop/mobile layouts were exercised in Chromium. + +On hardware, the new L + R owner copied all eight left-bank profiles and names, bringing the inventory to eight owners / 64 profiles without changing the original 56. A temporary edit and name on inactive pair profile 8 survived reboot while both solo banks stayed unchanged; pair activation was also independent. Test edits, names and active selections were restored. Studio selected the real composite owner and its Identify action produced two physical rumble dispatches. + +Hardware mode changes split the pair into two live USB slots and recombined it without fresh pairing; Individual remained selected after reboot. The user felt join and split confirmation pulses from the physical shortcut. Saved configuration generation stayed unchanged during each gesture. A temporary split under a Paired default ended after resetting/reconnecting the right half, restoring the original pair. The final saved default is Individual; all 64 profiles, names, aliases and active selections were preserved. + +At the initial Switch 2 input checkpoint, a real Pro (`3C:A9:AB:65:73:12`) completed setup, appeared in persistent pairing/profile inventories, and delivered live sticks, accelerometer, gyro and independent C/GL/GR presses. A 100-report USB rumble exercise retained its connection while 3,033 controller reports arrived. Schema-7 extra mappings were written/read and restored; all 32 then-existing profiles, metadata and active selections were preserved, with adapter configuration generation 13 / CRC `3af5ee18` unchanged. Subsequent native-rumble and pair measurements are documented here and below; wider sensor-axis, perceptual-equivalence and long-duration transport qualification remains open. Use schema-7-capable firmware after saving expanded profiles. + +### Switch 2 native HD rumble + +Switch-mode host commands use decoded HD parameters, not the compatibility +strong/weak peak values. Pro output preserves two independent actuators; +paired Joy-Con 2 output routes each source side to its physical half. +A solo Joy-Con uses the louder source independently for each band, retaining +that band's frequency, with left winning ties and shorter sequences holding +their final sample. Profiles scale amplitudes before this conversion. + +Physical microphone characterization on the Pro Controller established: + +- Each five-byte sample contains two **10-bit frequency + 10-bit amplitude** + fields. The measured frequency model is + `Hz ~= 10 * 2^((code - 1) / 96)`. + Original low/high indices map to `193 + 3*index` / `289 + 3*index`; + index 64 therefore produces codes 385/481 (160/320 Hz). +- Block headers `0x50`, `0x60`, `0x70`, plus the four-bit sequence counter, + select **one, two or three** valid samples. Unused slots are zeroed. + Filling three slots under `0x50` does not play the later slots. +- A randomized 90-packet run using the other actuator as an acoustic timing + reference measured **5.27 ms/frame, ±0.16 ms statistical 95% interval**. + Acoustic/threshold systematic error is not included. The sender uses + conservative **6/11/16 ms** submission guards, not a claim of exact onset. + +Linear Q0.15 amplitudes use SDL's conservative `29000/65535` envelope, producing +native codes 0–453. This preserves a linear input curve but is not calibrated +physical-force equivalence; it can feel different from compatibility rumble. +Source frequency indices are bounded to 1–127. + +Each logical slot has a 16-command cross-core ingress FIFO; each physical +Switch 2 controller has a 16-command transport FIFO. Commands keep their +original receipt time and connection/output generation. Native commands expire +after 50 ms; a batch that cannot fit its complete playback guard before that +deadline is discarded rather than started halfway stale. Expiring unplayed +history does not interrupt current playback or force a useless HOLD ahead of +fresh work. Consecutive identical one-sample holds may refresh a pending +command; multi-sample sequences are never coalesced. + +Stops flush older host work, including under backpressure. Local feedback owns +a separate bounded override while host state advances underneath it; resuming +uses the current valid final sample, not a replay of masked history. +Keepalives likewise send only the final sample with count 1. Pending ATT +write-request buffers remain immutable; late completion cannot resurrect an +old epoch after stop, reconnect or Joy-Con topology change. +Normal GATT-client busy responses are treated as transient backpressure, not +as controller disconnects. + +Final single-Pro hardware runs: + +| Workload | Result | +|---|---| +| Stereo, frequency sweep, and three-subframe patterns (195 USB reports) | Zero ingress/output drops; 1,193 input reports continued | +| 512 changing one-subframe commands at 125.14 Hz | Zero ingress/output drops; clean stop | +| 128 changing three-subframe commands at 125.11 Hz | Zero ingress drops; 69 output-stage commands discarded/superseded; clean stop and empty ingress | + +Paired Joy-Con 2 hardware runs exercised left-only, right-only, stereo and +ordered-subframe effects: 197 host requests produced 394 physical dispatches +with zero ingress/output drops. The user confirmed both sides vibrated as +intended. Held one-subframe commands at 125 Hz, changing one-subframe commands +at 62.5 Hz, and three-subframe commands at 50 Hz completed without drops. +Higher changing workloads discarded output-stage commands, but the pair +remained connected after the GATT backpressure fix. + +Three-subframe commands at 125 Hz exceed the native playback budget. These +results do **not** establish lossless arbitrary workloads or perceptual +equivalence. All 40 profiles, names, active selections and adapter +configuration were preserved during this upgrade. + +**Joy-Con 2 connection timing:** both halves now request **7.5 ms BLE connection +intervals**, in Paired and Individual modes, including while Classic controllers +are connected. This deliberately replaces their earlier automatic 30 ms slowdown +to favor Joy-Con responsiveness and rumble quality. Other Switch 2 models retain +the existing coexistence policy: 30 ms when at least two physical Switch 2 links +are present, otherwise 7.5 ms. Unrelated BLE controllers are not retimed. Bonds, +profiles, HD encoding and the DualSense timeout are unchanged. Negotiated +intervals are reconciled after asynchronous updates and topology changes. + +The BLE connection interval is not the rumble packet cadence: multiple packets +can travel per connection event. The normal active-output algorithm is unchanged +by this checkpoint; the direct-packet lab fixtures are not release features. +Lifecycle coverage includes Paired/Individual mode changes, Classic arrival and +departure, handle reuse, correcting a slow negotiated Joy-Con interval, unrelated +BLE isolation, and the retained Switch 2 Pro interval/retry policy. + +**Idle Switch 2 rumble traffic:** the parser sends three successful neutral +writes, then suppresses further idle output. Any successful non-neutral packet +re-arms that stop budget, including a late completion from an older logical +epoch. Failed or pending ATT writes do not count as completed stops. Repeated +host stops still discard queued history without restarting settled idle traffic. +Active-effect cadence, native subframes and watchdogs are unchanged; the parser +timer remains available for control and expiry work. + +**Qualification and known mixed-radio limitation:** this default does not claim +reliable native DualSense PCM alongside two fast Joy-Con links. + +- Earlier 15/30 ms trials favored 30 ms for transport continuity: at 15 ms the + same 125 Hz-per-slot workload timed out around four seconds; at 30 ms it + completed 30 seconds with 1,384 PCM sends, 47 skips and no send failures. + All three links stayed connected, but the user later clarified that Joy-Con + rumble felt weak at 30 ms and DualSense rumble was also weak/inconsistent. +- Idle suppression was measured at 30 ms: outgoing HCI ACL writes fell from 974 + to 469 per ten-second idle sample, while both samples sent 469 DualSense PCM + packets without skips. Total HCI writes did not fall because receive-credit + traffic increased. A subsequent 30-second workload sent 1,431 PCM packets + with zero skips/failures, two Switch 2 ingress drops and nine output-stage + drops. This was a transport improvement, not full perceptual qualification. +- In the isolated right-Joy-Con fixture, one- and three-sample packets at a + 20 ms packet cadence both felt weak. One-sample packets at a nominal 7.5 ms + cadence, with the same 320 Hz frequency and amplitude, produced user-confirmed + clear tone and good feel. Each two-second burst sent 267 packets with a + 7.504 ms average submission gap, not a measured on-air or actuator interval. +- Adding native DualSense PCM to that fast fixture failed in about 0.66 seconds, + both with one Joy-Con vibrating and with both vibrating. Both Joy-Cons remained + connected in each test. DualSense sent 11 PCM packets before timing out; with + both vibrating, Joy-Con submission gaps reached 32 ms. The fixture aborted and + completed explicit stop writes. + +The shared Classic/GATT scheduler trial was not qualified and is excluded from +this checkpoint. Temporary packet fixtures are also excluded. Native DualSense +buffer/cadence qualification remains separate; no gain increase or fallback mode +is silently applied to conceal transport loss. + ### Rumble per controller Commands remain bound to a USB slot and Bluetooth connection generation. Compatibility output uses a latest-value mailbox; native output keeps a bounded timestamped command history instead of collapsing substeps. @@ -354,9 +568,9 @@ uv run switch-pico-config config native-rumble revoke --identity N Use the physical controller's row from `profiles list`, not the global fallback. `config native-rumble list` also supplies approval indices; `revoke --approval N` can remove an approval after its profile-catalog entry has been forgotten. -Approvals persist in adapter configuration schema 3 (232 bytes). Schema 1/2 -migration preserves existing settings and starts with no approvals; profile -schema 6 and the profile catalog are unchanged. +Approvals persist in adapter configuration schema 4 (232 bytes), alongside the +Joy-Con default mode. Schema 1/2 migration starts with no approvals; schema 3 +migration preserves its approval list. Profile schema 7/catalog 3 are separate. The native encoder preserves safe unity bytes when synchronized, otherwise encodes independent actuator/band/substep state with documented quantization. @@ -784,35 +998,42 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file: | Resource | Used or reserved | Device capacity | |---|---:|---:| -| Executable flash image | 787,824 bytes | 4 MiB | +| Executable flash image | 833,672 bytes | 4 MiB | | Indexed profile arenas | 256 KiB | 4 MiB flash | | Adapter configuration | 8 KiB | 4 MiB flash | -| BTstack bonds | 8 KiB | 4 MiB flash | +| 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 | 139,616 bytes | 520 KiB | +| Allocated/reserved SRAM, including heap and stacks | 151,820 bytes | 520 KiB | -The executable plus persistent reservations consume 1,070,448 bytes of flash, -leaving 3,123,856 bytes. Allocated SRAM sections leave 392,864 bytes of link-time +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 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. Profiles use two 128 KiB append-only arenas and retain 248 physical record -slots. Catalog 2 uses a 128-byte header plus a 384-byte profile in the same +slots. Catalog 3 uses a 128-byte header plus a 384-byte profile in the same 512-byte stride. The second page is programmed before the header-containing first page, and records are read back before publication. Profile names remain 256-byte metadata payloads and aliases remain 32 bytes. The compact index stores locations and generations; active/fallback profiles for observed identities and the selected profile are decoded, not the entire database. +Pair initialization commits one seed record that snapshots the left bank's +immutable profile/name record references and active selection. Subsequent +writes are independent, and compaction materializes pair-owned records. +Interrupted initialization exposes either no pair bank or the complete bank, +never a partly copied bank. An ambiguous storage write/readback failure freezes +catalog access until reboot/replay; existing live profiles retain their last +committed cached settings. The catalog supports eight profiles for the global fallback and each of 16 -stable identities. Missing records resolve to defaults, so profiles 5–8 do +stable identities, including pair owners. Missing records resolve to defaults, so profiles 5–8 do not consume flash until changed. When an arena fills, the latest indexed records are compacted into its peer and the new superblock is published last. Interrupted or corrupt appends leave the previous valid record available. -Catalog 1 and retired four-profile banks migrate through the alternate arena; +Catalogs 1/2 and retired four-profile banks migrate through the alternate arena; the old published data is retained until all copies and the new superblock -verify. Schema 1–5 profiles retain their meaning when decoded as schema 6. +verify. Schema 1–6 profiles retain their meaning when decoded as schema 7. Keep a profile export before downgrading: older firmware cannot read the new catalog/profile format. diff --git a/bluepad32_config/parser/uni_hid_parser_switch2.c b/bluepad32_config/parser/uni_hid_parser_switch2.c new file mode 100644 index 0000000..36c3c8a --- /dev/null +++ b/bluepad32_config/parser/uni_hid_parser_switch2.c @@ -0,0 +1,1311 @@ +// SPDX-License-Identifier: Apache-2.0 +// Proprietary, unencrypted Switch 2 BLE transport. No SMP or bond-store changes. +// Protocol descriptions: https://github.com/ndeadly/switch2_controller_research +// Pairing/calibration/rumble payloads: https://github.com/Nadeflore/switch2-controllers +// Hold-frame cadence: https://github.com/TommyWabg/Switch2Connect +// Sensor conversion independently adapted from SDL_hidapi_switch2.c (SDL/Valve). + +#include "parser/uni_hid_parser_switch2.h" + +#include +#include +#include + +#include +#include "bt/uni_bt_defines.h" +#include "bt/uni_bt_le.h" +#include "parser/uni_hid_parser_imu.h" +#include "parser/uni_switch2_pairing.h" +#include "sdkconfig.h" +#include "uni_hid_device.h" +#include "uni_log.h" + +#define SW2_TIMEOUT_MS 2000 +#define SW2_OUTPUT_INTERVAL_MS 13 +#define SW2_NEUTRAL_WRITE_BUDGET 3 +#define SW2_HAPTICS_CAPACITY 16 +#define SW2_REPORT_SIZE 63 +#define SW2_ACK 0x78 +#define SW2_CCCD_UUID 0x2902 + +// BTstack deserializes UUIDs into canonical (not ATT wire) byte order. +static const uint8_t sw2_service_uuid[16] = { + 0xab, 0x7d, 0xe9, 0xbe, 0x89, 0xfe, 0x49, 0xad, 0x82, 0x8f, 0x11, 0x8f, 0x09, 0xdf, 0x7f, 0xd0}; +static const uint8_t sw2_input_uuid[16] = { + 0xab, 0x7d, 0xe9, 0xbe, 0x89, 0xfe, 0x49, 0xad, 0x82, 0x8f, 0x11, 0x8f, 0x09, 0xdf, 0x7f, 0xd2}; +static const uint8_t sw2_command_uuid[16] = { + 0x64, 0x9d, 0x4a, 0xc9, 0x8e, 0xb7, 0x4e, 0x6c, 0xaf, 0x44, 0x1e, 0xa5, 0x4f, 0xe5, 0xf0, 0x05}; +static const uint8_t sw2_response_uuid[16] = { + 0xc7, 0x65, 0xa9, 0x61, 0xd9, 0xd8, 0x4d, 0x36, 0xa2, 0x0a, 0x53, 0x15, 0xb1, 0x11, 0x83, 0x6a}; +static const uint8_t sw2_rumble_pro_uuid[16] = { + 0xcc, 0x48, 0x3f, 0x51, 0x92, 0x58, 0x42, 0x7d, 0xa9, 0x39, 0x63, 0x0c, 0x31, 0xf7, 0x2b, 0x05}; +static const uint8_t sw2_rumble_left_uuid[16] = { + 0x28, 0x93, 0x26, 0xcb, 0xa4, 0x71, 0x48, 0x5d, 0xa8, 0xf4, 0x24, 0x0c, 0x14, 0xf1, 0x82, 0x41}; +static const uint8_t sw2_rumble_right_uuid[16] = { + 0xfa, 0x19, 0xb0, 0xfb, 0xcd, 0x1f, 0x46, 0xa7, 0x84, 0xa1, 0xbb, 0xb0, 0x9e, 0x00, 0xc1, 0x49}; + +// Public application-protocol payloads from Nadeflore Controller.pair(), NOT +// secret keys or an authenticated BLE pairing mechanism. No security claim. +static const uint8_t sw2_pair_exchange[17] = { + 0x00, 0xea, 0xbd, 0x47, 0x13, 0x89, 0x35, 0x42, 0xc6, 0x79, 0xee, 0x07, 0xf2, 0x53, 0x2c, 0x6c, 0x31}; +static const uint8_t sw2_pair_confirm[17] = { + 0x00, 0x40, 0xb0, 0x8a, 0x5f, 0xcd, 0x1f, 0x9b, 0x41, 0x12, 0x5c, 0xac, 0xc6, 0x3f, 0x38, 0xa0, 0x73}; + +// Every state transition requires its own completed ATT operation and/or a +// matching application ACK. A timeout disconnects, never skips a setup step. +typedef enum { + SW2_OFF, SW2_ADMITTED, SW2_SERVICE, SW2_CHARACTERISTICS, + SW2_RESPONSE_DESCRIPTOR, SW2_INPUT_DESCRIPTOR, SW2_SUBSCRIBE_RESPONSE, + SW2_INFO, SW2_PAIR, SW2_CALIBRATION, SW2_GYRO_CALIBRATION, + SW2_SUBSCRIBE_INPUT, SW2_FEATURES, SW2_READY, +} sw2_state_t; +typedef enum { SW2_QUERY_NONE, SW2_QUERY_DISCOVERY, SW2_QUERY_CCCD, SW2_QUERY_COMMAND, SW2_QUERY_RUMBLE } sw2_query_t; +typedef struct { + uint16_t center[2]; + uint16_t positive[2]; + uint16_t negative[2]; +} sw2_stick_t; +typedef struct { + uni_switch2_haptics_frame_t frame; + uint32_t expires, serial; + bool held, native; +} sw2_host_command_t; +typedef struct { + uint8_t sample[5]; + uint32_t expires; + bool valid, held; +} sw2_host_side_t; +typedef struct { + uni_hid_device_t* device; + bd_addr_t address; + hci_con_handle_t handle; + bool allocated; + sw2_state_t state; + sw2_query_t query; + uint8_t address_type; + bool needs_pair; + gatt_client_service_t service; + gatt_client_characteristic_t input, response, command, rumble; + uint16_t response_cccd, input_cccd; + gatt_client_notification_t response_listener, input_listener; + bool response_listening, input_listening; + btstack_timer_source_t timeout_timer, output_timer; + bool timeout_active, output_active; + // ATT write-request buffers must outlive the asynchronous call. + uint8_t command_data[32], rumble_data[33], cccd_data[2]; + uint8_t command_length; + bool command_pending, command_sent, command_acked; + uint8_t step, calibration_slot; + bool factory_calibration, calibration_done; + uint32_t memory_address; + uint8_t memory_length; + sw2_stick_t sticks[2]; + int32_t gyro_bias[3]; + uint8_t extra_buttons, leds; + bool leds_pending; + uint8_t rumble_id, weak, strong; + bool rumble_scheduled, rumble_held; + uint32_t rumble_start, rumble_end; + sw2_host_command_t host_queue[SW2_HAPTICS_CAPACITY]; + sw2_host_side_t host_sides[2]; + uint8_t host_head, host_count; + uint32_t host_serial, haptics_epoch, output_revision; + uint32_t pending_serial, pending_epoch, pending_revision; + uint32_t playback_until; + uint8_t pending_guard_ms; + uint8_t neutral_writes; + bool pending_neutral; + bool pending_host, pending_barrier, barrier_pending, output_urgent; + bool feedback_active, playback_guard; + uint32_t sensor_start, sensor_host_start, sensor_last; + uint8_t sensor_warmup; + int32_t gyro_full_scale; +} sw2_instance_t; + +// Separate bounded storage: never squeeze transport resources into parser_data +// (256 bytes). Retired buffers are not reused until their old BLE link is gone. +static sw2_instance_t sw2_instances[CONFIG_BLUEPAD32_MAX_DEVICES]; +static atomic_uint_least32_t sw2_haptics_drops; + +static void sw2_gatt_handler(uint8_t packet_type, uint16_t channel, uint8_t* packet, uint16_t size); +static void sw2_output_tick(btstack_timer_source_t* timer); +static void sw2_continue(sw2_instance_t* ins); +static void sw2_complete_command(sw2_instance_t* ins); +static void sw2_rumble_complete(sw2_instance_t* ins); +static void sw2_discard_host(sw2_instance_t* ins); + +static bool sw2_product(uint16_t pid) { + return pid == UNI_SW2_PRO_PID || pid == UNI_SW2_JOYCON_L_PID || pid == UNI_SW2_JOYCON_R_PID; +} + +bool uni_hid_parser_switch2_is_ble_device(const uni_hid_device_t* d) { + return d && d->conn.protocol == UNI_BT_CONN_PROTOCOL_BLE && d->vendor_id == UNI_SW2_NINTENDO_VID && + sw2_product(d->product_id); +} + +static sw2_instance_t* sw2_instance(const uni_hid_device_t* d) { + if (!uni_hid_parser_switch2_is_ble_device(d)) + return NULL; + for (unsigned i = 0; i < CONFIG_BLUEPAD32_MAX_DEVICES; ++i) { + sw2_instance_t* ins = &sw2_instances[i]; + if (ins->device == d && ins->state != SW2_OFF && memcmp(ins->address, d->conn.btaddr, 6) == 0) + return ins; + } + return NULL; +} + +static bool sw2_live(sw2_instance_t* ins) { + return ins->device && sw2_instance(ins->device) == ins && ins->handle == ins->device->conn.handle; +} + +static void sw2_disarm_timeout(sw2_instance_t* ins) { + if (ins->timeout_active) + btstack_run_loop_remove_timer(&ins->timeout_timer); + ins->timeout_active = false; +} + +void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins) + return; + sw2_disarm_timeout(ins); + if (ins->output_active) + btstack_run_loop_remove_timer(&ins->output_timer); + ins->output_active = false; + if (ins->response_listening) + gatt_client_stop_listening_for_characteristic_value_updates(&ins->response_listener); + if (ins->input_listening) + gatt_client_stop_listening_for_characteristic_value_updates(&ins->input_listener); + ins->response_listening = ins->input_listening = false; + sw2_discard_host(ins); + ++ins->haptics_epoch; + ins->command_pending = ins->rumble_scheduled = false; + ins->extra_buttons = 0; + ins->state = SW2_OFF; + ins->device = NULL; +} + +static void sw2_fail(sw2_instance_t* ins, const char* reason, unsigned status) { + if (!ins->device) + return; + uni_hid_device_t* d = ins->device; + loge("Switch2: %s (state=%u, status=%u)\n", reason, (unsigned)ins->state, status); + uni_hid_parser_switch2_teardown(d); + uni_hid_device_disconnect(d); + uni_hid_device_delete(d); +} + +static void sw2_timeout(btstack_timer_source_t* timer) { + sw2_instance_t* ins = btstack_run_loop_get_timer_context(timer); + ins->timeout_active = false; + if (sw2_live(ins)) + sw2_fail(ins, "transaction timeout", 0); +} + +static void sw2_arm_timeout(sw2_instance_t* ins) { + sw2_disarm_timeout(ins); + btstack_run_loop_set_timer_handler(&ins->timeout_timer, sw2_timeout); + btstack_run_loop_set_timer_context(&ins->timeout_timer, ins); + btstack_run_loop_set_timer(&ins->timeout_timer, SW2_TIMEOUT_MS); + ins->timeout_active = true; + btstack_run_loop_add_timer(&ins->timeout_timer); +} + +static void sw2_schedule_output(sw2_instance_t* ins, uint32_t ms) { + if (ins->output_active) + btstack_run_loop_remove_timer(&ins->output_timer); + btstack_run_loop_set_timer_handler(&ins->output_timer, sw2_output_tick); + btstack_run_loop_set_timer_context(&ins->output_timer, ins); + btstack_run_loop_set_timer(&ins->output_timer, ms ? ms : 1); + ins->output_active = true; + btstack_run_loop_add_timer(&ins->output_timer); +} + +static bool sw2_check_query(sw2_instance_t* ins, uint8_t status) { + if (status != ERROR_CODE_SUCCESS) { + sw2_fail(ins, "GATT request rejected", status); + return false; + } + return true; +} + +static void sw2_discover_descriptors(sw2_instance_t* ins, bool input) { + ins->state = input ? SW2_INPUT_DESCRIPTOR : SW2_RESPONSE_DESCRIPTOR; + ins->query = SW2_QUERY_DISCOVERY; + sw2_arm_timeout(ins); + sw2_check_query(ins, gatt_client_discover_characteristic_descriptors( + sw2_gatt_handler, ins->handle, input ? &ins->input : &ins->response)); +} + +static void sw2_subscribe(sw2_instance_t* ins, bool input) { + ins->state = input ? SW2_SUBSCRIBE_INPUT : SW2_SUBSCRIBE_RESPONSE; + ins->query = SW2_QUERY_CCCD; + ins->cccd_data[0] = 1; + ins->cccd_data[1] = 0; + if (input) { + gatt_client_listen_for_characteristic_value_updates(&ins->input_listener, sw2_gatt_handler, ins->handle, &ins->input); + ins->input_listening = true; + } else { + gatt_client_listen_for_characteristic_value_updates(&ins->response_listener, sw2_gatt_handler, ins->handle, &ins->response); + ins->response_listening = true; + } + sw2_arm_timeout(ins); + sw2_check_query(ins, gatt_client_write_characteristic_descriptor_using_descriptor_handle( + sw2_gatt_handler, ins->handle, input ? ins->input_cccd : ins->response_cccd, 2, ins->cccd_data)); +} + +static bool sw2_transient_write_error(uint8_t status) { + return status == BTSTACK_ACL_BUFFERS_FULL || status == GATT_CLIENT_BUSY || + status == GATT_CLIENT_IN_WRONG_STATE; +} + +static void sw2_try_command(sw2_instance_t* ins) { + if (!ins->command_pending || ins->command_sent || ins->query != SW2_QUERY_NONE) + return; + // Re-check immediately before every application-pairing write, including a + // deferred write after ACL backpressure. A closed window cannot write MACs. + if (ins->command_data[0] == 0x15 && !switch_pico_switch2_pairing_allowed()) { + sw2_fail(ins, "pairing window closed", 0); + return; + } + bool no_response = (ins->command.properties & ATT_PROPERTY_WRITE_WITHOUT_RESPONSE) != 0; + uint8_t status; + if (no_response) { + status = gatt_client_write_value_of_characteristic_without_response( + ins->handle, ins->command.value_handle, ins->command_length, ins->command_data); + } else { + ins->query = SW2_QUERY_COMMAND; + status = gatt_client_write_value_of_characteristic(sw2_gatt_handler, ins->handle, + ins->command.value_handle, ins->command_length, ins->command_data); + } + if (status != ERROR_CODE_SUCCESS) { + ins->query = SW2_QUERY_NONE; + if (sw2_transient_write_error(status)) { + sw2_schedule_output(ins, SW2_OUTPUT_INTERVAL_MS); + return; + } + sw2_fail(ins, "command write failed", status); + return; + } + ins->command_sent = true; +} + +static void sw2_send_command(sw2_instance_t* ins, uint8_t command, uint8_t subcommand, + const uint8_t* data, uint8_t length) { + if (ins->command_pending || length > sizeof(ins->command_data) - 8) { + sw2_fail(ins, "overlapping or oversized command", length); + return; + } + uint8_t* out = ins->command_data; + memset(out, 0, 8); + out[0] = command; + out[1] = 0x91; + out[2] = 1; + out[3] = subcommand; + out[5] = length; + if (length) + memcpy(out + 8, data, length); + ins->command_length = 8 + length; + ins->command_pending = true; + ins->command_sent = ins->command_acked = false; + sw2_arm_timeout(ins); + sw2_try_command(ins); +} + +static void sw2_read_memory(sw2_instance_t* ins, uint32_t address, uint8_t length) { + uint8_t data[8] = {length, 0x7e, 0, 0}; + little_endian_store_32(data, 4, address); + ins->memory_address = address; + ins->memory_length = length; + sw2_send_command(ins, 0x02, 0x04, data, sizeof(data)); +} + +static void sw2_unpack_stick(const uint8_t* data, uint16_t out[2]) { + out[0] = data[0] | ((uint16_t)(data[1] & 15) << 8); + out[1] = (data[1] >> 4) | ((uint16_t)data[2] << 4); +} + +static bool sw2_calibration(sw2_stick_t* out, const uint8_t* data) { + sw2_stick_t value; + sw2_unpack_stick(data, value.center); + sw2_unpack_stick(data + 3, value.positive); + sw2_unpack_stick(data + 6, value.negative); + for (unsigned i = 0; i < 2; ++i) { + if (!value.center[i] || value.center[i] == 4095 || !value.positive[i] || !value.negative[i] || + value.positive[i] > 4095 - value.center[i] || value.negative[i] > value.center[i]) + return false; + } + *out = value; + return true; +} + +static void sw2_continue(sw2_instance_t* ins) { + switch (ins->state) { + case SW2_INFO: + sw2_read_memory(ins, 0x13000, 0x40); + break; + case SW2_PAIR: { + uint8_t data[14] = {0, 2}; + if (ins->step == 0) { + bd_addr_t local; + gap_local_bd_addr(local); + for (unsigned i = 0; i < 6; ++i) + data[2 + i] = data[8 + i] = local[5 - i]; + sw2_send_command(ins, 0x15, 0x01, data, sizeof(data)); + } else if (ins->step == 1) { + sw2_send_command(ins, 0x15, 0x04, sw2_pair_exchange, sizeof(sw2_pair_exchange)); + } else if (ins->step == 2) { + sw2_send_command(ins, 0x15, 0x02, sw2_pair_confirm, sizeof(sw2_pair_confirm)); + } else { + sw2_send_command(ins, 0x15, 0x03, data, 1); + } + break; + } + case SW2_CALIBRATION: { + uint32_t address = ins->factory_calibration ? 0x130a8 : 0x1fc042; + if (ins->calibration_slot) + address += ins->factory_calibration ? 0x40 : 0x20; + sw2_read_memory(ins, address, 0x0b); + break; + } + case SW2_GYRO_CALIBRATION: + sw2_read_memory(ins, 0x13044, 12); + break; + case SW2_FEATURES: { + const uint8_t features[4] = {0x04, 0, 0, 0}; + sw2_send_command(ins, 0x0c, ins->step ? 0x04 : 0x02, features, sizeof(features)); + break; + } + case SW2_READY: + if (ins->leds_pending && !ins->command_pending) { + const uint8_t data[8] = {ins->leds, 0, 0, 0, 0, 0, 0, 0}; + ins->leds_pending = false; + sw2_send_command(ins, 0x09, 0x07, data, sizeof(data)); + } + break; + default: + break; + } +} + +static void sw2_complete_command(sw2_instance_t* ins) { + if (!ins->command_pending || !ins->command_acked || ins->query == SW2_QUERY_COMMAND) + return; + ins->command_pending = false; + sw2_disarm_timeout(ins); + switch (ins->state) { + case SW2_INFO: + ins->state = ins->needs_pair ? SW2_PAIR : SW2_CALIBRATION; + break; + case SW2_PAIR: + if (++ins->step == 4) { + if (!uni_switch2_pairing_remember(ins->address_type, ins->address)) { + sw2_fail(ins, "could not persist application pairing", 0); + return; + } + ins->needs_pair = false; + ins->step = 0; + ins->state = SW2_CALIBRATION; + } + break; + case SW2_CALIBRATION: + if (ins->calibration_done) + ins->state = SW2_GYRO_CALIBRATION; + break; + case SW2_GYRO_CALIBRATION: + sw2_subscribe(ins, true); + return; + case SW2_FEATURES: + if (++ins->step == 2) { + ins->state = SW2_READY; + ins->device->controller.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + if (!uni_hid_device_set_ready_complete(ins->device)) + return; // Platform may have destroyed the device. + sw2_schedule_output(ins, 1); + } + break; + default: + break; + } + sw2_continue(ins); +} + +static void sw2_response(sw2_instance_t* ins, const uint8_t* data, uint16_t length) { + if (!ins->command_pending || !ins->command_sent || ins->command_acked || length < 8 || + data[0] != ins->command_data[0] || data[3] != ins->command_data[3] || data[1] != 1 || data[2] != 1) + return; // Unsolicited, truncated or stale ACK is not progress. + if (data[5] != SW2_ACK) { + sw2_fail(ins, "negative application ACK", data[5]); + return; + } + if (data[0] == 0x02) { + if (length < 16 || data[8] != ins->memory_length || + little_endian_read_32(data, 12) != ins->memory_address || length < 16 + ins->memory_length) + return; // Includes a stale memory response with the same cmd/subcmd. + const uint8_t* value = data + 16; + if (ins->state == SW2_INFO) { + if (little_endian_read_16(value, 18) != UNI_SW2_NINTENDO_VID || + little_endian_read_16(value, 20) != ins->device->product_id) { + sw2_fail(ins, "controller identity mismatch", 0); + return; + } + } else if (ins->state == SW2_CALIBRATION) { + // Both solo Joy-Cons store their one stick in calibration slot 1. + unsigned stick = ins->device->product_id == UNI_SW2_JOYCON_R_PID ? 1 : ins->calibration_slot; + bool valid = sw2_calibration(&ins->sticks[stick], value); + if (!valid && !ins->factory_calibration) { + ins->factory_calibration = true; + } else { + if (!valid) + logi("Switch2: invalid factory stick calibration, using full-range mapping\n"); + ins->factory_calibration = false; + if (ins->device->product_id == UNI_SW2_PRO_PID && ins->calibration_slot == 0) + ins->calibration_slot = 1; + else + ins->calibration_done = true; + } + } else if (ins->state == SW2_GYRO_CALIBRATION) { + for (unsigned i = 0; i < 3; ++i) { + uint32_t bits = little_endian_read_32(value, 4 * i); + float bias; + memcpy(&bias, &bits, sizeof(bias)); + // Erased/invalid flash is not a floating-point sensor value. + if (isfinite(bias) && bias >= -40.0f && bias <= 40.0f) + ins->gyro_bias[i] = (int32_t)(bias * (57.295779513f * UNI_IMU_GYRO_RES_PER_DEG_S)); + } + } + } else if (ins->state == SW2_PAIR) { + unsigned payload_length = ins->step == 0 ? 9 : ins->step == 3 ? 1 : 17; + if (length < 8 + payload_length) + return; + if (data[8] != 1) { + sw2_fail(ins, "pairing command rejected", data[8]); + return; + } + } + ins->command_acked = true; + sw2_complete_command(ins); +} + +static void sw2_query_complete(sw2_instance_t* ins, uint8_t status) { + sw2_query_t query = ins->query; + if (query == SW2_QUERY_NONE) + return; + ins->query = SW2_QUERY_NONE; + if (status != ATT_ERROR_SUCCESS) { + sw2_fail(ins, "GATT transaction failed", status); + return; + } + if (query == SW2_QUERY_COMMAND) { + sw2_complete_command(ins); + return; + } + if (query == SW2_QUERY_RUMBLE) { + sw2_rumble_complete(ins); + // A command queued behind this write has its own timeout already. + if (!ins->command_pending) + sw2_disarm_timeout(ins); + sw2_try_command(ins); + if (sw2_live(ins)) + sw2_schedule_output(ins, 1); + return; + } + sw2_disarm_timeout(ins); + switch (ins->state) { + case SW2_SERVICE: + if (!ins->service.start_group_handle) { + sw2_fail(ins, "missing proprietary service", 0); + return; + } + ins->state = SW2_CHARACTERISTICS; + ins->query = SW2_QUERY_DISCOVERY; + sw2_arm_timeout(ins); + sw2_check_query(ins, gatt_client_discover_characteristics_for_service(sw2_gatt_handler, ins->handle, &ins->service)); + break; + case SW2_CHARACTERISTICS: + if (!ins->input.value_handle || !ins->response.value_handle || !ins->command.value_handle || !ins->rumble.value_handle) { + sw2_fail(ins, "missing required characteristic", 0); + return; + } + sw2_discover_descriptors(ins, false); + break; + case SW2_RESPONSE_DESCRIPTOR: + if (!ins->response_cccd) { + sw2_fail(ins, "missing response CCCD", 0); + return; + } + sw2_discover_descriptors(ins, true); + break; + case SW2_INPUT_DESCRIPTOR: + if (!ins->input_cccd) { + sw2_fail(ins, "missing input CCCD", 0); + return; + } + sw2_subscribe(ins, false); + break; + case SW2_SUBSCRIBE_RESPONSE: + ins->state = SW2_INFO; + sw2_continue(ins); + break; + case SW2_SUBSCRIBE_INPUT: + ins->state = SW2_FEATURES; + ins->step = 0; + sw2_continue(ins); + break; + default: + sw2_fail(ins, "unexpected query completion", query); + break; + } +} + +static void sw2_characteristic(sw2_instance_t* ins, const gatt_client_characteristic_t* characteristic) { + if (ins->state != SW2_CHARACTERISTICS) + return; + const uint8_t* rumble_uuid = ins->device->product_id == UNI_SW2_PRO_PID ? sw2_rumble_pro_uuid : + ins->device->product_id == UNI_SW2_JOYCON_L_PID ? sw2_rumble_left_uuid : sw2_rumble_right_uuid; + gatt_client_characteristic_t* target = NULL; + uint16_t properties = ATT_PROPERTY_NOTIFY; + if (memcmp(characteristic->uuid128, sw2_input_uuid, 16) == 0) + target = &ins->input; + else if (memcmp(characteristic->uuid128, sw2_response_uuid, 16) == 0) + target = &ins->response; + else if (memcmp(characteristic->uuid128, sw2_command_uuid, 16) == 0) { + target = &ins->command; + properties = ATT_PROPERTY_WRITE | ATT_PROPERTY_WRITE_WITHOUT_RESPONSE; + } else if (memcmp(characteristic->uuid128, rumble_uuid, 16) == 0) { + target = &ins->rumble; + properties = ATT_PROPERTY_WRITE | ATT_PROPERTY_WRITE_WITHOUT_RESPONSE; + } + if (!target) + return; + if (target->value_handle || !(characteristic->properties & properties) || + characteristic->start_handle <= ins->service.start_group_handle || + characteristic->value_handle <= characteristic->start_handle || + characteristic->end_handle < characteristic->value_handle || + characteristic->end_handle > ins->service.end_group_handle) { + sw2_fail(ins, "invalid or ambiguous characteristic", characteristic->value_handle); + return; + } + *target = *characteristic; +} + +static void sw2_gatt_handler(uint8_t packet_type, uint16_t channel, uint8_t* packet, uint16_t size) { + (void)channel; + if (packet_type != HCI_EVENT_PACKET || !packet || size < 4) + return; + uint8_t event = packet[0]; + // BTstack GATT events have extended lengths: notification length is uint16 + // and event[1] may wrap. Validate the actual callback size before accessors. + uint16_t required; + switch (event) { + case GATT_EVENT_SERVICE_QUERY_RESULT: required = 28; break; + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: required = 32; break; + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: required = 26; break; + case GATT_EVENT_QUERY_COMPLETE: required = 9; break; + case GATT_EVENT_NOTIFICATION: required = 12; break; + default: return; + } + if (size < required) + return; + uni_hid_device_t* d = uni_hid_device_get_instance_for_connection_handle(little_endian_read_16(packet, 2)); + sw2_instance_t* ins = sw2_instance(d); + if (!ins || !sw2_live(ins)) + return; + switch (event) { + case GATT_EVENT_SERVICE_QUERY_RESULT: { + if (ins->state != SW2_SERVICE) + return; + gatt_client_service_t service; + gatt_event_service_query_result_get_service(packet, &service); + if (ins->service.start_group_handle || memcmp(service.uuid128, sw2_service_uuid, 16) != 0 || + !service.start_group_handle || service.end_group_handle <= service.start_group_handle) { + sw2_fail(ins, "invalid or ambiguous service", 0); + return; + } + ins->service = service; + break; + } + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: { + gatt_client_characteristic_t characteristic; + gatt_event_characteristic_query_result_get_characteristic(packet, &characteristic); + sw2_characteristic(ins, &characteristic); + break; + } + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: { + if (ins->state != SW2_RESPONSE_DESCRIPTOR && ins->state != SW2_INPUT_DESCRIPTOR) + return; + gatt_client_characteristic_descriptor_t descriptor; + gatt_event_all_characteristic_descriptors_query_result_get_characteristic_descriptor(packet, &descriptor); + if (descriptor.uuid16 != SW2_CCCD_UUID) + return; + bool input = ins->state == SW2_INPUT_DESCRIPTOR; + gatt_client_characteristic_t* characteristic = input ? &ins->input : &ins->response; + uint16_t* handle = input ? &ins->input_cccd : &ins->response_cccd; + if (*handle || descriptor.handle <= characteristic->value_handle || descriptor.handle > characteristic->end_handle) { + sw2_fail(ins, "invalid or ambiguous CCCD", descriptor.handle); + return; + } + *handle = descriptor.handle; + break; + } + case GATT_EVENT_QUERY_COMPLETE: + sw2_query_complete(ins, gatt_event_query_complete_get_att_status(packet)); + break; + case GATT_EVENT_NOTIFICATION: { + uint16_t length = gatt_event_notification_get_value_length(packet); + if (length > size - 12) + return; + uint16_t handle = gatt_event_notification_get_value_handle(packet); + const uint8_t* value = gatt_event_notification_get_value(packet); + if (ins->response_listening && handle == ins->response.value_handle) + sw2_response(ins, value, length); + else if (ins->state == SW2_READY && ins->input_listening && handle == ins->input.value_handle && length == SW2_REPORT_SIZE) { + uni_hid_parser_switch2_parse_input_report(d, value, length); + uni_hid_device_process_controller(d); + } + break; + } + } +} + +bool uni_bt_le_switch2_handle_advertisement(const uint8_t* packet, uint16_t size) { + if (!packet || size < 12 || packet[0] != GAP_EVENT_ADVERTISING_REPORT || + (unsigned)packet[1] + 2 > size || packet[1] < 10 || packet[11] > packet[1] - 10) + return false; + // Standard ADV_IND only; never connect to scan responses or wake-host data. + if (gap_event_advertising_report_get_advertising_event_type(packet) != 0) + return false; + const uint8_t* data = gap_event_advertising_report_get_data(packet); + unsigned length = gap_event_advertising_report_get_data_length(packet); + const uint8_t* manufacturer = NULL; + for (unsigned offset = 0; offset < length;) { + unsigned field_length = data[offset]; + if (!field_length) + break; + if (field_length > length - offset - 1) + return false; + if (data[offset + 1] == 0xff && field_length >= 19) { + const uint8_t* candidate = data + offset + 2; + if (little_endian_read_16(candidate, 0) == 0x0553 && + little_endian_read_16(candidate, 5) == UNI_SW2_NINTENDO_VID && + sw2_product(little_endian_read_16(candidate, 7))) { + if (manufacturer) + return false; + manufacturer = candidate; + } + } + offset += field_length + 1; + } + if (!manufacturer) + return false; + bd_addr_t local, address; + gap_local_bd_addr(local); + gap_event_advertising_report_get_address(packet, address); + bool fresh = true, reconnect = true; + for (unsigned i = 0; i < 6; ++i) { + fresh &= manufacturer[12 + i] == 0; + reconnect &= manufacturer[12 + i] == local[5 - i]; + } + // Returning true consumes recognized but disallowed Switch2 advertisements, + // preventing generic HID/SMP admission from bypassing this protocol policy. + if ((!fresh && !reconnect) || (fresh && !switch_pico_switch2_pairing_allowed())) + return true; + uint8_t address_type = gap_event_advertising_report_get_address_type(packet); + if (address_type != BD_ADDR_TYPE_LE_PUBLIC && + !(address_type == BD_ADDR_TYPE_LE_RANDOM && (address[0] & 0xc0) == 0xc0)) + return true; + if (!fresh && !uni_switch2_pairing_known(address_type, address)) + return true; + if (uni_hid_device_get_instance_for_address(address)) + return true; + uint8_t rssi = gap_event_advertising_report_get_rssi(packet); + const uint16_t cod = UNI_BT_COD_MAJOR_PERIPHERAL | UNI_BT_COD_MINOR_GAMEPAD; + sw2_instance_t* ins = NULL; + for (unsigned i = 0; i < CONFIG_BLUEPAD32_MAX_DEVICES; ++i) { + if (sw2_instances[i].state == SW2_OFF && + (!sw2_instances[i].allocated || gap_get_connection_type(sw2_instances[i].handle) == GAP_CONNECTION_INVALID)) { + ins = &sw2_instances[i]; + break; + } + } + if (!ins) + return true; + uni_hid_device_t* d = uni_hid_device_create(address); + if (!d) + return true; + memset(ins, 0, sizeof(*ins)); + ins->allocated = true; + ins->device = d; + memcpy(ins->address, address, sizeof(ins->address)); + ins->address_type = address_type; + ins->handle = UNI_BT_CONN_HANDLE_INVALID; + ins->needs_pair = fresh; + ins->state = SW2_ADMITTED; + for (unsigned stick = 0; stick < 2; ++stick) { + for (unsigned axis = 0; axis < 2; ++axis) { + ins->sticks[stick].center[axis] = 2048; + ins->sticks[stick].positive[axis] = 2047; + ins->sticks[stick].negative[axis] = 2048; + } + } + uni_hid_device_set_vendor_id(d, UNI_SW2_NINTENDO_VID); + uni_hid_device_set_product_id(d, little_endian_read_16(manufacturer, 7)); + uni_bt_conn_set_protocol(&d->conn, UNI_BT_CONN_PROTOCOL_BLE); + uni_hid_device_guess_controller_type_from_pid_vid(d); + d->sdp_query_type = SDP_QUERY_NOT_NEEDED; + uni_hid_device_set_cod(d, cod); + uni_hid_device_set_name(d, "Switch2"); + uni_bt_conn_set_state(&d->conn, UNI_BT_CONN_STATE_DEVICE_DISCOVERED); + d->conn.rssi = rssi; + // Platform admission needs the validated transport and identity metadata. + // Keep generic allowlist/RSSI filtering authoritative before touching scan. + if (uni_hid_device_on_device_discovered(address, "Switch2", cod, rssi) != UNI_ERROR_SUCCESS) { + uni_hid_device_delete(d); + return true; + } + gap_stop_scan(); // Existing BLE lifecycle owns resuming scan under policy. + uint8_t status = gap_connect(address, address_type); + if (status != ERROR_CODE_SUCCESS) { + sw2_fail(ins, "BLE connection request failed", status); + uni_bt_le_resume_scanning_if_enabled(); + } + return true; +} + +void uni_hid_parser_switch2_on_le_connected(uni_hid_device_t* d) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_ADMITTED) + return; + ins->handle = d->conn.handle; + if (!d->conn.connected) + uni_hid_device_connect(d); + // A platform callback can reject/disconnect and destroy this device. + if (sw2_instance(d) == ins && d->conn.connected) + uni_hid_device_set_ready(d); +} + +void uni_hid_parser_switch2_setup(uni_hid_device_t* d) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_ADMITTED) + return; + ins->handle = d->conn.handle; + // Standard-compliant 7.5ms minimum; negotiation failure is not setup failure. + int status = gap_update_connection_parameters(ins->handle, 6, 6, 0, 600); + if (status != ERROR_CODE_SUCCESS) + logi("Switch2: connection interval request declined (%d)\n", status); + ins->state = SW2_SERVICE; + ins->query = SW2_QUERY_DISCOVERY; + sw2_arm_timeout(ins); + sw2_check_query(ins, gatt_client_discover_primary_services_by_uuid128(sw2_gatt_handler, ins->handle, sw2_service_uuid)); +} + +uint8_t uni_hid_parser_switch2_extra_buttons(const uni_hid_device_t* d) { + sw2_instance_t* ins = sw2_instance(d); + return ins ? ins->extra_buttons : 0; +} + +bool uni_hid_parser_switch2_identity_address_type(const uni_hid_device_t* d, uint8_t* out) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || !out) + return false; + *out = ins->address_type; + return true; +} + +void uni_hid_parser_switch2_init_report(uni_hid_device_t* d) { + (void)d; // Full snapshots replace state only after their complete length is validated. +} + +static int32_t sw2_axis(uint16_t raw, const sw2_stick_t* stick, unsigned axis, bool invert) { + int32_t delta = (int32_t)raw - stick->center[axis]; + int32_t result = delta * 512 / (delta < 0 ? stick->negative[axis] : stick->positive[axis]); + if (invert) + result = -result; + return result < -512 ? -512 : result > 511 ? 511 : result; +} + +static void sw2_motion(sw2_instance_t* ins, uni_gamepad_t* gp, const uint8_t* report) { + uint32_t timestamp = little_endian_read_32(report, 42); + if (!timestamp || (timestamp == ins->sensor_last && !ins->gyro_full_scale)) + return; + ins->sensor_last = timestamp; + if (!ins->gyro_full_scale) { + // SDL detects two sensor clock/range variants. BLE reports need not be + // 4ms apart as USB reports are: compare sensor ticks against elapsed + // host time over >=400ms instead of assuming USB's 100 * 4ms interval. + uint32_t now = btstack_run_loop_get_time_ms(); + if (ins->sensor_warmup < 5) { + if (++ins->sensor_warmup == 5) { + ins->sensor_start = timestamp; + ins->sensor_host_start = now; + } + return; + } + uint32_t elapsed = now - ins->sensor_host_start; + if (elapsed < 400) + return; + uint32_t ticks = timestamp - ins->sensor_start; // Defined wraparound. + if (!ticks || elapsed > 2000) { + ins->sensor_start = timestamp; + ins->sensor_host_start = now; + return; + } + uint32_t rate = ticks / elapsed; + // round(34.8 or 40 radians/s * 180/pi * q10). + ins->gyro_full_scale = rate >= 900 && rate <= 1100 ? 2041747 : 2346835; + } + // The common report is SDL's USB 0x05 report without the report-ID byte. + // SDL vertical/paired axes: X, Z, -Y. Backend alone rotates solo Joy-Cons. + static const uint8_t axes[3] = {0, 2, 1}; + for (unsigned i = 0; i < 3; ++i) { + unsigned axis = axes[i]; + int32_t accel = (int16_t)little_endian_read_16(report, 48 + 2 * axis); + int32_t gyro = (int16_t)little_endian_read_16(report, 54 + 2 * axis); + gp->accel[i] = uni_imu_scale(accel, 32767, 8 * UNI_IMU_ACCEL_RES_PER_G); + gp->gyro[i] = uni_imu_scale(gyro, 32767, ins->gyro_full_scale) - ins->gyro_bias[axis]; + if (i == 2) { + gp->accel[i] = -gp->accel[i]; + gp->gyro[i] = -gp->gyro[i]; + } + } +} + +void uni_hid_parser_switch2_parse_input_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_READY || !report || len != SW2_REPORT_SIZE) + return; + uni_gamepad_t* gp = &d->controller.gamepad; + memset(gp, 0, sizeof(*gp)); + uint32_t buttons = little_endian_read_32(report, 4); + gp->buttons = ((buttons & 0x04) ? BUTTON_A : 0) | ((buttons & 0x08) ? BUTTON_B : 0) | + ((buttons & 0x01) ? BUTTON_X : 0) | ((buttons & 0x02) ? BUTTON_Y : 0) | + ((buttons & 0x40) ? BUTTON_SHOULDER_R : 0) | ((buttons & 0x400000) ? BUTTON_SHOULDER_L : 0) | + ((buttons & 0x80) ? BUTTON_TRIGGER_R : 0) | ((buttons & 0x800000) ? BUTTON_TRIGGER_L : 0) | + ((buttons & 0x400) ? BUTTON_THUMB_R : 0) | ((buttons & 0x800) ? BUTTON_THUMB_L : 0); + gp->dpad = ((buttons & 0x10000) ? DPAD_DOWN : 0) | ((buttons & 0x20000) ? DPAD_UP : 0) | + ((buttons & 0x40000) ? DPAD_RIGHT : 0) | ((buttons & 0x80000) ? DPAD_LEFT : 0); + gp->misc_buttons = ((buttons & 0x100) ? MISC_BUTTON_SELECT : 0) | ((buttons & 0x200) ? MISC_BUTTON_START : 0) | + ((buttons & 0x1000) ? MISC_BUTTON_SYSTEM : 0) | ((buttons & 0x2000) ? MISC_BUTTON_CAPTURE : 0); + gp->brake = (buttons & 0x800000) ? 1023 : 0; + gp->throttle = (buttons & 0x80) ? 1023 : 0; + ins->extra_buttons = (buttons & 0x4000) ? UNI_SW2_BUTTON_C : 0; + if (d->product_id == UNI_SW2_PRO_PID) + ins->extra_buttons |= ((buttons & 0x2000000) ? UNI_SW2_BUTTON_GL : 0) | ((buttons & 0x1000000) ? UNI_SW2_BUTTON_GR : 0); + else if (d->product_id == UNI_SW2_JOYCON_L_PID) + ins->extra_buttons |= ((buttons & 0x200000) ? UNI_SW2_BUTTON_LEFT_SL : 0) | ((buttons & 0x100000) ? UNI_SW2_BUTTON_LEFT_SR : 0); + else + ins->extra_buttons |= ((buttons & 0x20) ? UNI_SW2_BUTTON_RIGHT_SL : 0) | ((buttons & 0x10) ? UNI_SW2_BUTTON_RIGHT_SR : 0); + uint16_t axes[2]; + if (d->product_id != UNI_SW2_JOYCON_R_PID) { + sw2_unpack_stick(report + 10, axes); + gp->axis_x = sw2_axis(axes[0], &ins->sticks[0], 0, false); + gp->axis_y = sw2_axis(axes[1], &ins->sticks[0], 1, true); + } + if (d->product_id != UNI_SW2_JOYCON_L_PID) { + sw2_unpack_stick(report + 13, axes); + gp->axis_rx = sw2_axis(axes[0], &ins->sticks[1], 0, false); + gp->axis_ry = sw2_axis(axes[1], &ins->sticks[1], 1, true); + } + sw2_motion(ins, gp, report); + d->controller.klass = UNI_CONTROLLER_CLASS_GAMEPAD; +} + +void uni_hid_parser_switch2_set_player_leds(uni_hid_device_t* d, uint8_t leds) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins) + return; + ins->leds = leds & 0x0f; // Bluepad32 passes a bitmask, not a player number. + ins->leds_pending = true; + if (ins->state == SW2_READY) + sw2_continue(ins); +} + +uint32_t uni_hid_parser_switch2_haptics_dropped(void) { + return atomic_load_explicit(&sw2_haptics_drops, memory_order_relaxed); +} + +static void sw2_count_drops(unsigned count) { + atomic_fetch_add_explicit(&sw2_haptics_drops, count, memory_order_relaxed); +} + +static bool sw2_due(uint32_t now, uint32_t deadline) { + return (int32_t)(now - deadline) >= 0; +} + +static unsigned sw2_output_sides(const sw2_instance_t* ins) { + return ins->device->product_id == UNI_SW2_PRO_PID ? 2 : 1; +} + +static void sw2_pop_host(sw2_instance_t* ins) { + ins->host_head = (ins->host_head + 1) % SW2_HAPTICS_CAPACITY; + --ins->host_count; +} + +static void sw2_discard_host(sw2_instance_t* ins) { + sw2_count_drops(ins->host_count); + ins->host_head = ins->host_count = 0; + memset(ins->host_sides, 0, sizeof(ins->host_sides)); +} + +static void sw2_retain_host(sw2_instance_t* ins, const sw2_host_command_t* command) { + for (unsigned i = 0; i < sw2_output_sides(ins); ++i) { + const uni_switch2_haptics_side_t* side = &command->frame.sides[i]; + if (!side->count) + continue; + sw2_host_side_t* retained = &ins->host_sides[i]; + memcpy(retained->sample, side->samples[side->count - 1], sizeof(retained->sample)); + retained->expires = command->expires; + retained->held = command->held; + retained->valid = true; + } +} + +static bool sw2_local_active(const sw2_instance_t* ins, uint32_t now) { + return ins->rumble_scheduled && sw2_due(now, ins->rumble_start) && + (ins->rumble_held || !sw2_due(now, ins->rumble_end)); +} + +// Logical host time keeps advancing even when ATT or the local overlay owns +// the physical output. Masked sequences become final holds, never a replay log. +static void sw2_update_haptics(sw2_instance_t* ins, uint32_t now) { + bool active = sw2_local_active(ins, now); + bool masked = active || ins->feedback_active; + while (ins->host_count) { + sw2_host_command_t* command = &ins->host_queue[ins->host_head]; + if (!command->held && sw2_due(now, command->expires)) { + sw2_count_drops(1); + sw2_pop_host(ins); + // Unplayed history is not a reason to interrupt the current packet. + // A matching in-flight packet can still complete after its expiry. + if (!active && ins->pending_host && + ins->pending_epoch == ins->haptics_epoch && + ins->pending_serial == command->serial) { + ins->output_urgent = true; + ++ins->output_revision; + } + } else if (masked) { + sw2_retain_host(ins, command); + sw2_pop_host(ins); + } else { + break; + } + } + if (masked) + ins->barrier_pending = false; + for (unsigned i = 0; i < sw2_output_sides(ins); ++i) { + sw2_host_side_t* side = &ins->host_sides[i]; + if (side->valid && !side->held && sw2_due(now, side->expires)) { + side->valid = false; + if (!active) { + ins->output_urgent = true; + ++ins->output_revision; + } + } + } + if (ins->feedback_active != active) { + if (!active) + ins->output_urgent = true; // Resume only the current per-side hold. + ++ins->output_revision; + } + ins->feedback_active = active; + if (ins->rumble_scheduled && !ins->rumble_held && sw2_due(now, ins->rumble_end)) + ins->rumble_scheduled = false; +} + +static void sw2_compat_side(uni_switch2_haptics_side_t* side, uint8_t weak, uint8_t strong) { + // Preserve conventional/local frequency and strength, but advertise exactly + // one sample. The other ten bytes are padding, not repeated substeps. + uint64_t frame = 0x0e1u | ((uint64_t)strong * 4 << 10) | ((uint64_t)0x1e1 << 20) | + ((uint64_t)weak * 4 << 30); + memset(side, 0, sizeof(*side)); + side->count = 1; + for (unsigned i = 0; i < 5; ++i) + side->samples[0][i] = (uint8_t)(frame >> (8 * i)); +} + +static void sw2_host_hold(const sw2_instance_t* ins, uni_switch2_haptics_frame_t* frame) { + uni_switch2_haptics_silence(frame); + for (unsigned i = 0; i < sw2_output_sides(ins); ++i) { + if (ins->host_sides[i].valid) + memcpy(frame->sides[i].samples[0], ins->host_sides[i].sample, 5); + } +} + +static void sw2_host_stop(sw2_instance_t* ins) { + sw2_update_haptics(ins, btstack_run_loop_get_time_ms()); + sw2_discard_host(ins); + ++ins->haptics_epoch; + ++ins->output_revision; + ins->barrier_pending = true; + ins->output_urgent = true; + sw2_schedule_output(ins, 1); +} + +static bool sw2_physical_stop(const sw2_instance_t* ins, const uni_switch2_haptics_frame_t* frame) { + for (unsigned i = 0; i < sw2_output_sides(ins); ++i) { + const uni_switch2_haptics_side_t* side = &frame->sides[i]; + if (!side->count) + return false; + for (unsigned j = 0; j < side->count; ++j) { + const uint8_t* sample = side->samples[j]; + // Amplitudes occupy bits10..19 and30..39 of each 40-bit sample. + if ((sample[1] & 0xfc) || (sample[2] & 0x0f) || (sample[3] & 0xc0) || sample[4]) + return false; + } + } + return true; +} + +static bool sw2_same_hold(const uni_switch2_haptics_frame_t* a, const uni_switch2_haptics_frame_t* b) { + for (unsigned i = 0; i < 2; ++i) { + if (a->sides[i].count > 1 || a->sides[i].count != b->sides[i].count) + return false; + if (a->sides[i].count && memcmp(a->sides[i].samples[0], b->sides[i].samples[0], 5) != 0) + return false; + } + return true; +} + +static bool sw2_queue_host(sw2_instance_t* ins, const uni_switch2_haptics_frame_t* frame, + uint32_t received_ms, uint16_t duration_ms, bool native) { + uint32_t now = btstack_run_loop_get_time_ms(); + sw2_update_haptics(ins, now); + bool held = duration_ms == UINT16_MAX; + uint32_t lifetime = duration_ms < UNI_SWITCH2_HAPTICS_WATCHDOG_MS ? + duration_ms : UNI_SWITCH2_HAPTICS_WATCHDOG_MS; + uint32_t expires = received_ms + lifetime; + if (!held && sw2_due(now, expires)) { + sw2_count_drops(1); + return true; + } + if (ins->host_count) { + unsigned previous = (ins->host_head + ins->host_count - 1) % SW2_HAPTICS_CAPACITY; + sw2_host_command_t* command = &ins->host_queue[previous]; + if (command->native == native && sw2_same_hold(&command->frame, frame)) { + // Same ordered hold, new source lifetime. Keep its serial and wire + // bytes intact even when ATT is still borrowing this queue head. + command->expires = expires; + command->held = held; + sw2_schedule_output(ins, 1); + return true; + } + } + if (ins->host_count == SW2_HAPTICS_CAPACITY) + return false; + unsigned tail = (ins->host_head + ins->host_count) % SW2_HAPTICS_CAPACITY; + ins->host_queue[tail] = (sw2_host_command_t){ + .frame = *frame, .expires = expires, .serial = ++ins->host_serial, .held = held, .native = native, + }; + ++ins->host_count; + sw2_schedule_output(ins, 1); + return true; +} + +bool uni_hid_parser_switch2_queue_haptics(uni_hid_device_t* d, + const uni_switch2_haptics_frame_t* frame, uint32_t received_ms) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_READY) + return false; + if (!uni_switch2_haptics_valid(frame) || + (sw2_output_sides(ins) == 1 && !frame->sides[0].count)) { + sw2_count_drops(1); + return true; + } + if (sw2_physical_stop(ins, frame)) { + sw2_host_stop(ins); // A stop is a barrier, including when full or stale. + return true; + } + return sw2_queue_host(ins, frame, received_ms, UNI_SWITCH2_HAPTICS_WATCHDOG_MS, true); +} + +bool uni_hid_parser_switch2_queue_rumble(uni_hid_device_t* d, uint8_t weak, uint8_t strong, + uint16_t duration_ms, uint32_t received_ms) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_READY) + return false; + if ((!weak && !strong) || !duration_ms) { + sw2_host_stop(ins); + return true; + } + uni_switch2_haptics_frame_t frame; + sw2_compat_side(&frame.sides[0], weak, strong); + frame.sides[1] = frame.sides[0]; + return sw2_queue_host(ins, &frame, received_ms, duration_ms, false); +} + +void uni_hid_parser_switch2_reset_haptics(uni_hid_device_t* d) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins) + return; + sw2_discard_host(ins); + ++ins->haptics_epoch; + ++ins->output_revision; + ins->rumble_scheduled = ins->feedback_active = false; + ins->barrier_pending = ins->output_urgent = true; + // Do not touch rumble_data or pending metadata: ATT can still borrow them. + if (ins->state == SW2_READY) + sw2_schedule_output(ins, 1); +} + +static void sw2_rumble_complete(sw2_instance_t* ins) { + uint32_t now = btstack_run_loop_get_time_ms(); + ++ins->rumble_id; // Only a successful write consumes the physical sequence. + // Track transport success even for an old logical epoch: a late active + // packet invalidates prior silence and must be followed by fresh stops. + if (!ins->pending_neutral) + ins->neutral_writes = 0; + else if (ins->neutral_writes < SW2_NEUTRAL_WRITE_BUDGET) + ++ins->neutral_writes; + ins->playback_until = now + ins->pending_guard_ms; + ins->playback_guard = true; + sw2_update_haptics(ins, now); + if (ins->pending_epoch != ins->haptics_epoch) + return; + if (ins->pending_host && ins->host_count) { + sw2_host_command_t* command = &ins->host_queue[ins->host_head]; + if (command->serial == ins->pending_serial) { + sw2_retain_host(ins, command); + sw2_pop_host(ins); + } + } + if (ins->pending_barrier) + ins->barrier_pending = false; + if (ins->pending_revision == ins->output_revision) + ins->output_urgent = false; +} + +static bool sw2_send_rumble(sw2_instance_t* ins, uint32_t now) { + // Never overwrite the persistent packet while a write request borrows it. + if (ins->query != SW2_QUERY_NONE || ins->command_pending) + return false; + if (ins->playback_guard && !sw2_due(now, ins->playback_until) && !ins->output_urgent) + return false; + uni_switch2_haptics_frame_t frame; + ins->pending_host = false; + ins->pending_guard_ms = 6; + unsigned sides = sw2_output_sides(ins); + if (ins->feedback_active) { + sw2_compat_side(&frame.sides[0], ins->weak, ins->strong); + frame.sides[1] = frame.sides[0]; + } else { + sw2_host_hold(ins, &frame); + while (!ins->barrier_pending && ins->host_count) { + const sw2_host_command_t* command = &ins->host_queue[ins->host_head]; + unsigned count = 1; + for (unsigned i = 0; i < sides; ++i) + if (command->frame.sides[i].count > count) count = command->frame.sides[i].count; + uint8_t guard_ms = (count * 16 + 2) / 3; + // Do not begin a native sequence that its original watchdog would + // cut off halfway through. Skip it and keep servicing fresh work. + if (command->native && !command->held && + (int32_t)(command->expires - now) < guard_ms) { + sw2_count_drops(1); + sw2_pop_host(ins); + continue; + } + for (unsigned i = 0; i < sides; ++i) { + if (command->frame.sides[i].count) + frame.sides[i] = command->frame.sides[i]; + } + ins->pending_guard_ms = guard_ms; + ins->pending_host = true; + ins->pending_serial = command->serial; + break; + } + } + ins->pending_neutral = sw2_physical_stop(ins, &frame); + if (ins->pending_neutral && !ins->pending_host && + ins->neutral_writes == SW2_NEUTRAL_WRITE_BUDGET) { + // Three successful neutral writes settle idle output. Repeated host + // stops still discard queued history, but need no further radio work. + // Never suppress a queued native sequence, even if its endpoint is zero. + ins->barrier_pending = false; + ins->output_urgent = false; + return false; + } + ins->pending_epoch = ins->haptics_epoch; + ins->pending_revision = ins->output_revision; + ins->pending_barrier = ins->barrier_pending; + ins->rumble_data[0] = 0; + for (unsigned i = 0; i < sides; ++i) { + uni_switch2_haptics_write_block(ins->rumble_data + 1 + 16 * i, &frame.sides[i], ins->rumble_id); + } + uint16_t length = 1 + 16 * sides; + bool no_response = (ins->rumble.properties & ATT_PROPERTY_WRITE_WITHOUT_RESPONSE) != 0; + uint8_t status; + if (no_response) { + status = gatt_client_write_value_of_characteristic_without_response(ins->handle, ins->rumble.value_handle, + length, ins->rumble_data); + } else { + ins->query = SW2_QUERY_RUMBLE; + sw2_arm_timeout(ins); + status = gatt_client_write_value_of_characteristic(sw2_gatt_handler, ins->handle, ins->rumble.value_handle, + length, ins->rumble_data); + } + if (status == ERROR_CODE_SUCCESS) { + if (no_response) + sw2_rumble_complete(ins); + return true; + } + ins->query = SW2_QUERY_NONE; + if (!ins->command_pending) + sw2_disarm_timeout(ins); + if (!sw2_transient_write_error(status)) + sw2_fail(ins, "rumble write failed", status); + return false; +} + +static void sw2_next_boundary(uint32_t now, uint32_t boundary, uint32_t* next) { + if (!sw2_due(now, boundary) && boundary - now < *next) + *next = boundary - now; +} + +static void sw2_output_tick(btstack_timer_source_t* timer) { + sw2_instance_t* ins = btstack_run_loop_get_timer_context(timer); + ins->output_active = false; + if (!sw2_live(ins)) + return; + sw2_try_command(ins); + if (!ins->device) + return; + if (ins->state != SW2_READY) + return; // A blocked setup command rescheduled itself, or awaits its ACK. + uint32_t now = btstack_run_loop_get_time_ms(); + sw2_update_haptics(ins, now); + bool sent = sw2_send_rumble(ins, now); + if (!ins->device) + return; + uint32_t next = SW2_OUTPUT_INTERVAL_MS; + if (ins->playback_guard && (ins->host_count || !sent)) + sw2_next_boundary(now, ins->playback_until, &next); + if (ins->rumble_scheduled) { + sw2_next_boundary(now, ins->rumble_start, &next); + if (!ins->rumble_held) + sw2_next_boundary(now, ins->rumble_end, &next); + } + for (unsigned i = 0; i < sw2_output_sides(ins); ++i) { + if (ins->host_sides[i].valid && !ins->host_sides[i].held) + sw2_next_boundary(now, ins->host_sides[i].expires, &next); + } + if (ins->host_count && !ins->host_queue[ins->host_head].held) + sw2_next_boundary(now, ins->host_queue[ins->host_head].expires, &next); + sw2_schedule_output(ins, next); +} + +void uni_hid_parser_switch2_play_dual_rumble(uni_hid_device_t* d, uint16_t delay_ms, uint16_t duration_ms, + uint8_t weak, uint8_t strong) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_READY) + return; + uint32_t now = btstack_run_loop_get_time_ms(); + sw2_update_haptics(ins, now); + ins->weak = weak; + ins->strong = strong; + ins->rumble_start = now + delay_ms; + ins->rumble_end = ins->rumble_start + duration_ms; + ins->rumble_held = duration_ms == UINT16_MAX; + ins->rumble_scheduled = duration_ms != 0 && (weak != 0 || strong != 0); + ++ins->output_revision; + sw2_update_haptics(ins, now); + sw2_schedule_output(ins, 1); +} diff --git a/bluepad32_config/parser/uni_hid_parser_switch2.h b/bluepad32_config/parser/uni_hid_parser_switch2.h new file mode 100644 index 0000000..e7632ca --- /dev/null +++ b/bluepad32_config/parser/uni_hid_parser_switch2.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include "parser/uni_switch2_haptics.h" + +#ifdef __cplusplus +extern "C" { +#endif + +struct uni_hid_device_s; + +#define UNI_SW2_NINTENDO_VID 0x057e +#define UNI_SW2_PRO_PID 0x2069 +#define UNI_SW2_JOYCON_L_PID 0x2067 +#define UNI_SW2_JOYCON_R_PID 0x2066 + +enum { + UNI_SW2_BUTTON_C = 1u << 0, + UNI_SW2_BUTTON_GL = 1u << 1, + UNI_SW2_BUTTON_GR = 1u << 2, + UNI_SW2_BUTTON_LEFT_SL = 1u << 3, + UNI_SW2_BUTTON_LEFT_SR = 1u << 4, + UNI_SW2_BUTTON_RIGHT_SL = 1u << 5, + UNI_SW2_BUTTON_RIGHT_SR = 1u << 6, +}; + +// Supplied by the platform's existing bounded pairing-window policy. +bool switch_pico_switch2_pairing_allowed(void); + +bool uni_bt_le_switch2_handle_advertisement(const uint8_t* packet, uint16_t size); +bool uni_hid_parser_switch2_is_ble_device(const struct uni_hid_device_s* d); +void uni_hid_parser_switch2_on_le_connected(struct uni_hid_device_s* d); +void uni_hid_parser_switch2_setup(struct uni_hid_device_s* d); +void uni_hid_parser_switch2_teardown(struct uni_hid_device_s* d); +void uni_hid_parser_switch2_init_report(struct uni_hid_device_s* d); +void uni_hid_parser_switch2_parse_input_report(struct uni_hid_device_s* d, const uint8_t* report, uint16_t len); +void uni_hid_parser_switch2_set_player_leds(struct uni_hid_device_s* d, uint8_t leds); +void uni_hid_parser_switch2_play_dual_rumble(struct uni_hid_device_s* d, uint16_t delay_ms, uint16_t duration_ms, + uint8_t weak, uint8_t strong); +// BTstack-core-only host queues. False means unavailable/full: nothing accepted. +// Invalid/stale input is consumed and counted. received_ms is the source clock, +// never a retry timestamp. The callback above is an independent local overlay. +bool uni_hid_parser_switch2_queue_haptics(struct uni_hid_device_s* d, + const uni_switch2_haptics_frame_t* frame, uint32_t received_ms); +bool uni_hid_parser_switch2_queue_rumble(struct uni_hid_device_s* d, uint8_t weak, uint8_t strong, + uint16_t duration_ms, uint32_t received_ms); +void uni_hid_parser_switch2_reset_haptics(struct uni_hid_device_s* d); +// Atomic cumulative transport losses; safe to read without looking up a device. +uint32_t uni_hid_parser_switch2_haptics_dropped(void); +uint8_t uni_hid_parser_switch2_extra_buttons(const struct uni_hid_device_s* d); +// Validated public/static-random advertisement address, never an RPA or SMP identity. +bool uni_hid_parser_switch2_identity_address_type(const struct uni_hid_device_s* d, uint8_t* out); + +#ifdef __cplusplus +} +#endif diff --git a/bluepad32_config/parser/uni_switch2_haptics.c b/bluepad32_config/parser/uni_switch2_haptics.c new file mode 100644 index 0000000..f243b66 --- /dev/null +++ b/bluepad32_config/parser/uni_switch2_haptics.c @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "parser/uni_switch2_haptics.h" +#include +#include + +static uint8_t frequency_index(uint8_t index) { + if (index < 1) return 1; + return index > 127 ? 127 : index; +} + +static uint16_t amplitude_code(uint16_t q15) { + if (q15 > 32767) q15 = 32767; + // Match SDL's conservative native envelope while retaining a linear input + // curve. This is transport gain, not a claim of calibrated physical force. + return (uint16_t)(((uint32_t)q15 * 29000u / 32767u) >> 6); +} + +static uint64_t load_sample(const uint8_t data[5]) { + uint64_t value = 0; + for (unsigned i = 0; i < 5; ++i) value |= (uint64_t)data[i] << (8 * i); + return value; +} + +static bool valid_side(const uni_switch2_haptics_side_t* side) { + if (side->count > UNI_SWITCH2_HAPTICS_MAX_SAMPLES) return false; + for (unsigned i = 0; i < side->count; ++i) { + uint64_t value = load_sample(side->samples[i]); + unsigned first_frequency = value & 1023u; + unsigned second_frequency = (value >> 20) & 1023u; + if (first_frequency == 0 || first_frequency > 670 || + second_frequency == 0 || second_frequency > 670 || + ((value >> 10) & 1023u) > UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE || + ((value >> 30) & 1023u) > UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE) + return false; + } + return true; +} + +void uni_switch2_haptics_encode_sample(uint8_t out[5], uint8_t low_index, uint8_t high_index, + uint16_t low_q15, uint16_t high_q15) { + if (!out) return; + // The two physical frequency fields share one measured logarithmic scale. + // Original Switch indices have32 steps/octave; Switch2 has96 steps/octave. + uint64_t value = 193u + 3u * frequency_index(low_index); + value |= (uint64_t)amplitude_code(low_q15) << 10; + value |= (uint64_t)(289u + 3u * frequency_index(high_index)) << 20; + value |= (uint64_t)amplitude_code(high_q15) << 30; + for (unsigned i = 0; i < 5; ++i) out[i] = (uint8_t)(value >> (8 * i)); +} + +void uni_switch2_haptics_silence(uni_switch2_haptics_frame_t* frame) { + if (!frame) return; + memset(frame, 0, sizeof(*frame)); + for (unsigned side = 0; side < 2; ++side) { + frame->sides[side].count = 1; + uni_switch2_haptics_encode_sample(frame->sides[side].samples[0], 64, 64, 0, 0); + } +} + +bool uni_switch2_haptics_valid(const uni_switch2_haptics_frame_t* frame) { + return frame && (frame->sides[0].count || frame->sides[1].count) && + valid_side(&frame->sides[0]) && valid_side(&frame->sides[1]); +} + +bool uni_switch2_haptics_is_stop(const uni_switch2_haptics_frame_t* frame) { + if (!uni_switch2_haptics_valid(frame) || !frame->sides[0].count || !frame->sides[1].count) + return false; + for (unsigned side = 0; side < 2; ++side) { + for (unsigned i = 0; i < frame->sides[side].count; ++i) { + uint64_t value = load_sample(frame->sides[side].samples[i]); + if (((value >> 10) & 1023u) || ((value >> 30) & 1023u)) return false; + } + } + return true; +} + +bool uni_switch2_haptics_write_block(uint8_t out[16], const uni_switch2_haptics_side_t* side, + uint8_t sequence) { + if (!out || !side || !side->count || side->count > UNI_SWITCH2_HAPTICS_MAX_SAMPLES) return false; + memset(out, 0, 16); + out[0] = (uint8_t)(0x40u | (side->count << 4) | (sequence & 15u)); + memcpy(out + 1, side->samples, 5u * side->count); + return true; +} diff --git a/bluepad32_config/parser/uni_switch2_haptics.h b/bluepad32_config/parser/uni_switch2_haptics.h new file mode 100644 index 0000000..5ec9395 --- /dev/null +++ b/bluepad32_config/parser/uni_switch2_haptics.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once +#include +#include +#ifdef __cplusplus +extern "C" { +#endif + +#define UNI_SWITCH2_HAPTICS_MAX_SAMPLES 3 +#define UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE 453 +#define UNI_SWITCH2_HAPTICS_WATCHDOG_MS 50 + +typedef struct { + uint8_t count; // Zero leaves this side's host state/watchdog untouched. + uint8_t samples[UNI_SWITCH2_HAPTICS_MAX_SAMPLES][5]; +} uni_switch2_haptics_side_t; +typedef struct { + uni_switch2_haptics_side_t sides[2]; // Physical left/right; Joy-Con uses side 0. +} uni_switch2_haptics_frame_t; + +// Source indices64 mean160Hz(low)/320Hz(high); amplitudes are linear Q0.15. +// Measured native frequency law: Hz ~=10*2^((code-1)/96), hence exact index mapping. +// Linear amplitude scaling uses SDL's29000/65535 safety envelope (10-bit max453). +void uni_switch2_haptics_encode_sample(uint8_t out[5], uint8_t low_index, uint8_t high_index, + uint16_t low_q15, uint16_t high_q15); +void uni_switch2_haptics_silence(uni_switch2_haptics_frame_t* frame); +bool uni_switch2_haptics_valid(const uni_switch2_haptics_frame_t* frame); +bool uni_switch2_haptics_is_stop(const uni_switch2_haptics_frame_t* frame); +// Native callers validate the host envelope separately; conventional feedback +// retains its existing full10-bit amplitude codes. +// Requires count1..3; unused slots are zero, header selects only the valid samples. +bool uni_switch2_haptics_write_block(uint8_t out[16], const uni_switch2_haptics_side_t* side, + uint8_t sequence); +#ifdef __cplusplus +} +#endif diff --git a/bluepad32_config/parser/uni_switch2_pairing.c b/bluepad32_config/parser/uni_switch2_pairing.c new file mode 100644 index 0000000..7a3af4e --- /dev/null +++ b/bluepad32_config/parser/uni_switch2_pairing.c @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "parser/uni_switch2_pairing.h" + +#include +#include +#include + +#define PAIRING_TAG 0x53325052u // S2PR, separate from BTstack BTD/BTL records. +#define HEADER_SIZE 2u +#define RECORD_SIZE 7u +#define STORE_SIZE (HEADER_SIZE + UNI_SWITCH2_PAIRING_CAPACITY * RECORD_SIZE) + +static bool stable_address(uint8_t type, const uint8_t address[6]) { + if (address == NULL || type > 1) return false; + unsigned any = 0; + unsigned not_ff = 0; + for (unsigned i = 0; i < 6; ++i) { + any |= address[i]; + not_ff |= address[i] ^ 0xffu; + } + if (any == 0 || not_ff == 0) return false; + if (type == 0) return true; + if ((address[0] & 0xc0u) != 0xc0u) return false; + // The random portion of a static random address cannot be all zero/one. + any = address[0] & 0x3fu; + not_ff = (address[0] & 0x3fu) ^ 0x3fu; + for (unsigned i = 1; i < 6; ++i) { + any |= address[i]; + not_ff |= address[i] ^ 0xffu; + } + return any != 0 && not_ff != 0; +} + +static bool load(const btstack_tlv_t** tlv, void** context, uint8_t data[STORE_SIZE]) { + btstack_tlv_get_instance(tlv, context); + if (*tlv == NULL || (*tlv)->get_tag == NULL || (*tlv)->store_tag == NULL) return false; + int size = (*tlv)->get_tag(*context, PAIRING_TAG, data, STORE_SIZE); + if (size == 0) { + data[0] = 1; + data[1] = 0; + return true; + } + if (size < (int)HEADER_SIZE || data[0] != 1 || data[1] > UNI_SWITCH2_PAIRING_CAPACITY || + size != (int)(HEADER_SIZE + data[1] * RECORD_SIZE)) return false; + for (uint8_t i = 0; i < data[1]; ++i) { + const uint8_t* row = data + HEADER_SIZE + i * RECORD_SIZE; + if (!stable_address(row[0], row + 1)) return false; + if (i != 0 && memcmp(row - RECORD_SIZE, row, RECORD_SIZE) >= 0) return false; + } + return true; +} + +bool uni_switch2_pairing_known(uint8_t type, const uint8_t address[6]) { + if (!stable_address(type, address)) return false; + const btstack_tlv_t* tlv; + void* context; + uint8_t data[STORE_SIZE]; + if (!load(&tlv, &context, data)) return false; + for (uint8_t i = 0; i < data[1]; ++i) { + const uint8_t* row = data + HEADER_SIZE + i * RECORD_SIZE; + if (row[0] == type && memcmp(row + 1, address, 6) == 0) return true; + } + return false; +} + +bool uni_switch2_pairing_remember(uint8_t type, const uint8_t address[6]) { + if (!stable_address(type, address)) return false; + const btstack_tlv_t* tlv; + void* context; + uint8_t data[STORE_SIZE]; + if (!load(&tlv, &context, data)) return false; + uint8_t record[RECORD_SIZE]; + record[0] = type; + memcpy(record + 1, address, 6); + uint8_t index = 0; + for (; index < data[1]; ++index) { + int order = memcmp(record, data + HEADER_SIZE + index * RECORD_SIZE, RECORD_SIZE); + if (order == 0) return true; + if (order < 0) break; + } + if (data[1] == UNI_SWITCH2_PAIRING_CAPACITY) return false; + uint8_t* insertion = data + HEADER_SIZE + index * RECORD_SIZE; + memmove(insertion + RECORD_SIZE, insertion, (data[1] - index) * RECORD_SIZE); + memcpy(insertion, record, RECORD_SIZE); + ++data[1]; + return tlv->store_tag(context, PAIRING_TAG, data, HEADER_SIZE + data[1] * RECORD_SIZE) == 0; +} + +bool uni_switch2_pairing_clear(void) { + const btstack_tlv_t* tlv; + void* context; + btstack_tlv_get_instance(&tlv, &context); + if (tlv == NULL || tlv->store_tag == NULL) return false; + // Store an empty record rather than the void delete API: callers must know + // whether forgetting was persisted before acknowledging clear-pairings. + const uint8_t empty[HEADER_SIZE] = {1, 0}; + return tlv->store_tag(context, PAIRING_TAG, empty, sizeof(empty)) == 0; +} + +bool uni_switch2_pairing_get(uint8_t index, uint8_t* type, uint8_t address[6]) { + if (type == NULL || address == NULL) return false; + const btstack_tlv_t* tlv; + void* context; + uint8_t data[STORE_SIZE]; + if (!load(&tlv, &context, data) || index >= data[1]) return false; + const uint8_t* row = data + HEADER_SIZE + index * RECORD_SIZE; + *type = row[0]; + memcpy(address, row + 1, 6); + return true; +} diff --git a/bluepad32_config/parser/uni_switch2_pairing.h b/bluepad32_config/parser/uni_switch2_pairing.h new file mode 100644 index 0000000..91e4202 --- /dev/null +++ b/bluepad32_config/parser/uni_switch2_pairing.h @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Application-level trust, not SMP bonds or cryptographic authentication. +// Call on the Bluetooth/storage core after the BTstack TLV backend is ready. +#define UNI_SWITCH2_PAIRING_CAPACITY 16 +bool uni_switch2_pairing_known(uint8_t address_type, const uint8_t address[6]); +bool uni_switch2_pairing_remember(uint8_t address_type, const uint8_t address[6]); +bool uni_switch2_pairing_clear(void); +bool uni_switch2_pairing_get(uint8_t index, uint8_t* address_type, uint8_t address[6]); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index 8bcd5b1..be38a70 100755 Binary files a/firmware/switch-pico-adapter-feasibility.elf and b/firmware/switch-pico-adapter-feasibility.elf differ diff --git a/firmware/switch-pico-adapter-feasibility.uf2 b/firmware/switch-pico-adapter-feasibility.uf2 index 3eabf8e..ded9a30 100644 Binary files a/firmware/switch-pico-adapter-feasibility.uf2 and b/firmware/switch-pico-adapter-feasibility.uf2 differ diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index e99b233..1798330 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index d0b92a5..97f860e 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/firmware/switch-pico.elf b/firmware/switch-pico.elf index fe578af..18e52eb 100755 Binary files a/firmware/switch-pico.elf and b/firmware/switch-pico.elf differ diff --git a/firmware/switch-pico.uf2 b/firmware/switch-pico.uf2 index fdbad07..ea65ae4 100644 Binary files a/firmware/switch-pico.uf2 and b/firmware/switch-pico.uf2 differ diff --git a/patches/bluepad32-sdl3-imu.patch b/patches/bluepad32-sdl3-imu.patch index a9225fa..beb99a1 100644 --- a/patches/bluepad32-sdl3-imu.patch +++ b/patches/bluepad32-sdl3-imu.patch @@ -1,8 +1,34 @@ +diff --git a/src/components/bluepad32/bt/uni_bt.c b/src/components/bluepad32/bt/uni_bt.c +--- a/src/components/bluepad32/bt/uni_bt.c ++++ b/src/components/bluepad32/bt/uni_bt.c +@@ -73,6 +73,12 @@ + #include "uni_log.h" + #include "uni_property.h" + ++#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT ++// The opt-in PCM sender owns its CAN_SEND_NOW event, not the generic HID FIFO. ++extern bool uni_platform_on_l2cap_can_send_now(uni_hid_device_t* device, ++ uint16_t local_cid); ++#endif ++ + // globals + bd_addr_t uni_local_bd_addr; + +@@ -473,6 +479,10 @@ + if (device == NULL) { + loge("--->>> CANNOT FIND DEVICE"); + } else { ++#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT ++ if (uni_platform_on_l2cap_can_send_now(device, local_cid)) ++ break; ++#endif + uni_hid_device_send_queued_reports(device); + } + break; diff --git a/src/components/bluepad32/bt/uni_bt_bredr.c b/src/components/bluepad32/bt/uni_bt_bredr.c -index 955cc6f..4013cc1 100644 --- a/src/components/bluepad32/bt/uni_bt_bredr.c +++ b/src/components/bluepad32/bt/uni_bt_bredr.c -@@ -423,13 +423,14 @@ void uni_bt_bredr_on_l2cap_channel_opened(uint16_t channel, const uint8_t* packe +@@ -423,13 +423,14 @@ status = l2cap_event_channel_opened_get_status(packet); if (status) { logi("L2CAP Connection failed: 0x%02x.\n", status); @@ -21,8 +47,233 @@ index 955cc6f..4013cc1 100644 uni_hid_device_disconnect(device); uni_hid_device_delete(device); /* 'device' is destroyed, don't use */ +diff --git a/src/components/bluepad32/bt/uni_bt_le.c b/src/components/bluepad32/bt/uni_bt_le.c +--- a/src/components/bluepad32/bt/uni_bt_le.c ++++ b/src/components/bluepad32/bt/uni_bt_le.c +@@ -68,6 +68,7 @@ + #include "bt/uni_bt_conn.h" + #include "bt/uni_bt_defines.h" + #include "parser/uni_hid_parser.h" ++#include "parser/uni_hid_parser_switch2.h" + #include "uni_common.h" + #include "uni_config.h" + #include "uni_hid_device.h" +@@ -93,7 +94,7 @@ + gap_connect(addr, addr_type); + } + +-static void resume_scanning_hint(void) { ++void uni_bt_le_resume_scanning_if_enabled(void) { + // Resume scanning, only if it was scanning before connecting + if (is_scanning) { + gap_start_scan(); +@@ -107,7 +108,7 @@ + uni_hid_device_t* device; + + device = uni_hid_device_get_instance_for_connection_handle(con_handle); +- if (device) { ++ if (device && !uni_hid_parser_switch2_is_ble_device(device)) { + status = hids_client_disconnect(device->hids_cid); + if (status != ERROR_CODE_SUCCESS) { + loge("Failed to disconnect HIDS client for hids_cid=%d, status=%d\n", device->hids_cid, status); +@@ -118,7 +119,7 @@ + if (gap_get_connection_type(con_handle) != GAP_CONNECTION_INVALID) + gap_disconnect(con_handle); + +- resume_scanning_hint(); ++ uni_bt_le_resume_scanning_if_enabled(); + } + + static void get_advertisement_data(const uint8_t* adv_data, uint8_t adv_size, uint16_t* appearance, char* name) { +@@ -297,7 +298,7 @@ + uni_hid_device_connect(device); + uni_hid_device_set_ready(device); + +- resume_scanning_hint(); ++ uni_bt_le_resume_scanning_if_enabled(); + break; + default: + loge("HID service client connection failed, err 0x%02x.\n", status); +@@ -560,10 +561,22 @@ + type = hci_event_packet_get_type(packet); + switch (type) { + case SM_EVENT_JUST_WORKS_REQUEST: ++ con_handle = sm_event_just_works_request_get_handle(packet); ++ device = uni_hid_device_get_instance_for_connection_handle(con_handle); ++ if (uni_hid_parser_switch2_is_ble_device(device)) { ++ hog_disconnect(con_handle); ++ break; ++ } + logi("Just works requested\n"); + sm_just_works_confirm(sm_event_just_works_request_get_handle(packet)); + break; + case SM_EVENT_NUMERIC_COMPARISON_REQUEST: ++ con_handle = sm_event_numeric_comparison_request_get_handle(packet); ++ device = uni_hid_device_get_instance_for_connection_handle(con_handle); ++ if (uni_hid_parser_switch2_is_ble_device(device)) { ++ hog_disconnect(con_handle); ++ break; ++ } + logi("Confirming numeric comparison: %" PRIu32 "\n", + sm_event_numeric_comparison_request_get_passkey(packet)); + sm_numeric_comparison_confirm(sm_event_passkey_display_number_get_handle(packet)); +@@ -598,6 +611,12 @@ + break; + case SM_EVENT_REENCRYPTION_COMPLETE: + con_handle = sm_event_reencryption_complete_get_handle(packet); ++ device = uni_hid_device_get_instance_for_connection_handle(con_handle); ++ if (uni_hid_parser_switch2_is_ble_device(device)) { ++ // Proprietary Switch 2 GATT must never enter HOGP or re-pair SMP. ++ hog_disconnect(con_handle); ++ break; ++ } + switch (sm_event_reencryption_complete_get_status(packet)) { + case ERROR_CODE_SUCCESS: + logi("Re-encryption complete, success\n"); +@@ -630,6 +649,10 @@ + con_handle = sm_event_pairing_complete_get_handle(packet); + if (!device) { + loge("SM_EVENT_PAIRING_COMPLETE: Invalid device for addr %s\n", bd_addr_to_str(addr)); ++ hog_disconnect(con_handle); ++ break; ++ } ++ if (uni_hid_parser_switch2_is_ble_device(device)) { + hog_disconnect(con_handle); + break; + } +@@ -700,7 +723,18 @@ + con_handle = hci_subevent_le_connection_complete_get_connection_handle(packet); + logi("Using con_handle: %#x\n", con_handle); + ++ if (uni_hid_parser_switch2_is_ble_device(device) && ++ hci_subevent_le_connection_complete_get_status(packet) != ERROR_CODE_SUCCESS) { ++ // A failed event has no valid connection handle to disconnect. ++ uni_hid_device_delete(device); ++ uni_bt_le_resume_scanning_if_enabled(); ++ break; ++ } + uni_hid_device_set_connection_handle(device, con_handle); ++ if (uni_hid_parser_switch2_is_ble_device(device)) { ++ uni_hid_parser_switch2_on_le_connected(device); ++ break; ++ } + sm_request_pairing(con_handle); + + // Resume scanning +@@ -739,6 +773,8 @@ + if (device->conn.protocol != UNI_BT_CONN_PROTOCOL_BLE) + // Abort on non BLE connections + return; ++ if (uni_hid_parser_switch2_is_ble_device(device)) ++ return; // Switch 2 uses its own application-level pairing and GATT setup. + + logi("Connection encrypted: %u\n", hci_event_encryption_change_get_encryption_enabled(packet)); + if (hci_event_encryption_change_get_encryption_enabled(packet) == 0) { +@@ -758,7 +794,11 @@ + appearance = 0; + name[0] = 0; + +- ARG_UNUSED(size); ++ if (packet == NULL || size < 12 || ++ (uint16_t)gap_event_advertising_report_get_data_length(packet) > size - 12u) ++ return; ++ if (uni_bt_le_switch2_handle_advertisement(packet, size)) ++ return; + + gap_event_advertising_report_get_address(packet, addr); + if (uni_hid_device_get_instance_for_address(addr)) { +@@ -826,7 +866,7 @@ + ARG_UNUSED(packet); + ARG_UNUSED(size); + +- resume_scanning_hint(); ++ uni_bt_le_resume_scanning_if_enabled(); + } + + void uni_bt_le_list_bonded_keys(void) { +@@ -872,6 +912,11 @@ + gap_delete_bonding((bd_addr_type_t)entry_address_type, entry_address); + } + logi(".\n"); ++} ++ ++void uni_bt_le_set_background_scan(bool enabled) { ++ gap_set_scan_parameters(0 /* type: passive */, enabled ? 160 : 48 /* interval */, ++ enabled ? 16 : 48 /* window */); + } + + void uni_bt_le_setup(void) { +@@ -921,7 +966,7 @@ + // scan_parameters_service_client_init(); + device_information_service_client_init(); + +- gap_set_scan_parameters(0 /* type: passive */, 48 /* interval */, 48 /* window */); ++ uni_bt_le_set_background_scan(false); + } + + void uni_bt_le_scan_start(void) { +diff --git a/src/components/bluepad32/controller/uni_gamepad.c b/src/components/bluepad32/controller/uni_gamepad.c +--- a/src/components/bluepad32/controller/uni_gamepad.c ++++ b/src/components/bluepad32/controller/uni_gamepad.c +@@ -35,6 +35,9 @@ + {CONTROLLER_TYPE_SwitchJoyConRight, "Switch JoyCon Right"}, + {CONTROLLER_TYPE_SwitchJoyConPair, "Switch JoyCon Pair"}, + {CONTROLLER_TYPE_SwitchInputOnlyController, "Switch Input Only"}, ++ {CONTROLLER_TYPE_Switch2ProController, "Switch 2 Pro"}, ++ {CONTROLLER_TYPE_Switch2JoyConLeft, "Joy-Con 2 Left"}, ++ {CONTROLLER_TYPE_Switch2JoyConRight, "Joy-Con 2 Right"}, + {CONTROLLER_TYPE_MobileTouch, "Mobile Touch"}, + {CONTROLLER_TYPE_XInputSwitchController, "XInput Switch"}, + {CONTROLLER_TYPE_PS5Controller, "DualSense"}, +diff --git a/src/components/bluepad32/include/bt/uni_bt_le.h b/src/components/bluepad32/include/bt/uni_bt_le.h +--- a/src/components/bluepad32/include/bt/uni_bt_le.h ++++ b/src/components/bluepad32/include/bt/uni_bt_le.h +@@ -25,6 +25,8 @@ + + void uni_bt_le_scan_start(void); + void uni_bt_le_scan_stop(void); ++void uni_bt_le_set_background_scan(bool enabled); ++void uni_bt_le_resume_scanning_if_enabled(void); + + // Called from uni_hid_device_disconnect() + void uni_bt_le_disconnect(uni_hid_device_t* d); +diff --git a/src/components/bluepad32/include/controller/uni_controller_list.h b/src/components/bluepad32/include/controller/uni_controller_list.h +--- a/src/components/bluepad32/include/controller/uni_controller_list.h ++++ b/src/components/bluepad32/include/controller/uni_controller_list.h +@@ -568,6 +568,9 @@ + // * ZhiXu Gamepad Wireless + // * Sunwaytek Wireless Motion Controller for Nintendo Switch + { MAKE_CONTROLLER_ID( 0x057e, 0x2009 ), k_eControllerType_SwitchProController, NULL }, // Nintendo Switch Pro Controller ++ { MAKE_CONTROLLER_ID( 0x057e, 0x2069 ), k_eControllerType_Switch2ProController, NULL }, ++ { MAKE_CONTROLLER_ID( 0x057e, 0x2067 ), k_eControllerType_Switch2JoyConLeft, NULL }, ++ { MAKE_CONTROLLER_ID( 0x057e, 0x2066 ), k_eControllerType_Switch2JoyConRight, NULL }, + //{ MAKE_CONTROLLER_ID( 0x057e, 0x2017 ), k_eControllerType_SwitchProController, NULL }, // Nintendo Online SNES Controller + //{ MAKE_CONTROLLER_ID( 0x057e, 0x2019 ), k_eControllerType_SwitchProController, NULL }, // Nintendo Online N64 Controller + //{ MAKE_CONTROLLER_ID( 0x057e, 0x201e ), k_eControllerType_SwitchProController, NULL }, // Nintendo Online SEGA Genesis Controller +diff --git a/src/components/bluepad32/include/controller/uni_controller_type.h b/src/components/bluepad32/include/controller/uni_controller_type.h +--- a/src/components/bluepad32/include/controller/uni_controller_type.h ++++ b/src/components/bluepad32/include/controller/uni_controller_type.h +@@ -70,6 +70,9 @@ + k_eControllerType_OUYAController = 55, // (Bluepad32) + k_eControllerType_PSMoveController = 56, // (Bluepad32) + k_eControllerType_AtariJoystick = 57, // (Bluepad32) ++ k_eControllerType_Switch2ProController = 58, ++ k_eControllerType_Switch2JoyConLeft = 59, ++ k_eControllerType_Switch2JoyConRight = 60, + + k_eControllerType_LastController, // Don't add game controllers below this enumeration - this enumeration can + // change value +@@ -120,6 +123,9 @@ + #define CONTROLLER_TYPE_OUYAController k_eControllerType_OUYAController + #define CONTROLLER_TYPE_PSMoveController k_eControllerType_PSMoveController + #define CONTROLLER_TYPE_AtariJoystick k_eControllerType_AtariJoystick ++#define CONTROLLER_TYPE_Switch2ProController k_eControllerType_Switch2ProController ++#define CONTROLLER_TYPE_Switch2JoyConLeft k_eControllerType_Switch2JoyConLeft ++#define CONTROLLER_TYPE_Switch2JoyConRight k_eControllerType_Switch2JoyConRight + #define CONTROLLER_TYPE_LastController k_eControllerType_LastController + #define CONTROLLER_TYPE_GenericKeyboard k_eControllertype_GenericKeyboard + #define CONTROLLER_TYPE_GenericMouse k_eControllertype_GenericMouse diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h b/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h -index 6af4969..0aebb0a 100644 --- a/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h +++ b/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h @@ -14,6 +14,8 @@ @@ -34,11 +285,47 @@ index 6af4969..0aebb0a 100644 void uni_hid_parser_psmove_set_lightbar_color(struct uni_hid_device_s* d, uint8_t r, uint8_t g, uint8_t b); void uni_hid_parser_psmove_play_dual_rumble(struct uni_hid_device_s* d, uint16_t start_delay_ms, +diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_switch.h b/src/components/bluepad32/include/parser/uni_hid_parser_switch.h +--- a/src/components/bluepad32/include/parser/uni_hid_parser_switch.h ++++ b/src/components/bluepad32/include/parser/uni_hid_parser_switch.h +@@ -23,4 +23,22 @@ + bool uni_hid_parser_switch_does_name_match(struct uni_hid_device_s* d, const char* name); + void uni_hid_parser_switch_device_dump(struct uni_hid_device_s* d); + ++// BTstack-thread-only native owner. Qualification belongs to the caller: ++// type and firmware are unverified bytes from a successful device-info reply. ++bool uni_hid_parser_switch_native_info(struct uni_hid_device_s* d, ++ uint8_t* type, uint8_t* firmware_hi, ++ uint8_t* firmware_lo); ++bool uni_hid_parser_switch_native_acquire(struct uni_hid_device_s* d); ++// Retires compatibility timers and requests neutral; no audio is queued. ++void uni_hid_parser_switch_native_release(struct uni_hid_device_s* d); ++// Exactly 11 bytes on interrupt L2CAP. False means NOT submitted, never queued. ++// Caller must retry/resynchronize; true is transport acceptance, not playback. ++bool uni_hid_parser_switch_native_send(struct uni_hid_device_s* d, const uint8_t rumble[8]); ++ ++// Internal device-send/lifecycle hooks, including queued subcommand retries. ++// Returns the L2CAP status, or success when discarding an obsolete writer. ++int uni_hid_parser_switch_send_report(struct uni_hid_device_s* d, uint16_t cid, ++ const uint8_t* report, uint16_t len); ++void uni_hid_parser_switch_teardown(struct uni_hid_device_s* d); ++ + #endif // UNI_HID_PARSER_SWITCH_H +diff --git a/src/components/bluepad32/include/uni.h b/src/components/bluepad32/include/uni.h +--- a/src/components/bluepad32/include/uni.h ++++ b/src/components/bluepad32/include/uni.h +@@ -34,6 +34,7 @@ + #include "parser/uni_hid_parser_ds5.h" + #include "parser/uni_hid_parser_keyboard.h" + #include "parser/uni_hid_parser_mouse.h" ++#include "parser/uni_hid_parser_switch2.h" + #include "parser/uni_hid_parser_xboxone.h" + #include "platform/uni_platform.h" + #include "uni_circular_buffer.h" diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds4.c b/src/components/bluepad32/parser/uni_hid_parser_ds4.c -index ea063b8..7670caf 100644 --- a/src/components/bluepad32/parser/uni_hid_parser_ds4.c +++ b/src/components/bluepad32/parser/uni_hid_parser_ds4.c -@@ -297,17 +297,17 @@ void uni_hid_parser_ds4_parse_feature_report(uni_hid_device_t* d, const uint8_t* +@@ -297,17 +297,17 @@ // Set gyroscope calibration and normalization parameters. // Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s. speed_2x = r->gyro_speed_plus + r->gyro_speed_minus; @@ -59,7 +346,7 @@ index ea063b8..7670caf 100644 ins->gyro_calib_data[2].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S; ins->gyro_calib_data[2].sens_denom = abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias); -@@ -476,7 +476,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor +@@ -476,7 +476,7 @@ // Gyro for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) { @@ -68,7 +355,7 @@ index ea063b8..7670caf 100644 int32_t calib_data = mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom); ctl->gamepad.gyro[i] = calib_data; -@@ -484,7 +484,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor +@@ -484,7 +484,7 @@ // Accel for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) { @@ -78,10 +365,9 @@ index ea063b8..7670caf 100644 mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom); ctl->gamepad.accel[i] = calib_data; diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds5.c b/src/components/bluepad32/parser/uni_hid_parser_ds5.c -index a22ef26..3d5ecef 100644 --- a/src/components/bluepad32/parser/uni_hid_parser_ds5.c +++ b/src/components/bluepad32/parser/uni_hid_parser_ds5.c -@@ -487,17 +487,17 @@ void uni_hid_parser_ds5_parse_feature_report(uni_hid_device_t* d, const uint8_t* +@@ -487,17 +487,17 @@ // Set gyroscope calibration and normalization parameters. // Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s. speed_2x = r->gyro_speed_plus + r->gyro_speed_minus; @@ -102,7 +388,8 @@ index a22ef26..3d5ecef 100644 ins->gyro_calib_data[2].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S; ins->gyro_calib_data[2].sens_denom = abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias); -@@ -618,11 +618,11 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { +@@ -617,12 +617,12 @@ + ctl->gamepad.buttons |= BUTTON_THUMB_R; // Thumb R if (r->buttons[2] & 0x01) ctl->gamepad.misc_buttons |= MISC_BUTTON_SYSTEM; // PS - if (r->buttons[2] & 0x04) @@ -117,7 +404,7 @@ index a22ef26..3d5ecef 100644 int32_t calib_data = mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom); ctl->gamepad.gyro[i] = calib_data; -@@ -630,7 +630,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r +@@ -630,7 +630,7 @@ // Accel for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) { @@ -127,7 +414,6 @@ index a22ef26..3d5ecef 100644 mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom); ctl->gamepad.accel[i] = calib_data; diff --git a/src/components/bluepad32/parser/uni_hid_parser_psmove.c b/src/components/bluepad32/parser/uni_hid_parser_psmove.c -index 0265f93..5c0f2bb 100644 --- a/src/components/bluepad32/parser/uni_hid_parser_psmove.c +++ b/src/components/bluepad32/parser/uni_hid_parser_psmove.c @@ -8,6 +8,7 @@ @@ -138,7 +424,7 @@ index 0265f93..5c0f2bb 100644 #include -@@ -27,11 +28,6 @@ typedef enum psmove_fsm { +@@ -27,11 +28,6 @@ PSMOVE_FSM_LED_UPDATED, // LED updated } psmove_fsm_t; @@ -150,7 +436,7 @@ index 0265f93..5c0f2bb 100644 typedef enum { PSMOVE_STATE_RUMBLE_DISABLED, -@@ -41,9 +37,10 @@ typedef enum { +@@ -41,9 +37,10 @@ // psmove_instance_t represents data used by the psmove driver instance. typedef struct psmove_instance_s { @@ -162,7 +448,7 @@ index 0265f93..5c0f2bb 100644 btstack_timer_source_t rumble_timer_duration; btstack_timer_source_t rumble_timer_delayed_start; -@@ -127,6 +124,7 @@ static void psmove_send_output_report(uni_hid_device_t* d, psmove_output_report_ +@@ -127,6 +124,7 @@ static void on_psmove_set_rumble_on(btstack_timer_source_t* ts); static void on_psmove_set_rumble_off(btstack_timer_source_t* ts); static void psmove_play_dual_rumble_now(uni_hid_device_t* d, uint16_t duration_ms, uint8_t magnitude); @@ -170,7 +456,7 @@ index 0265f93..5c0f2bb 100644 void uni_hid_parser_psmove_init_report(uni_hid_device_t* d) { uni_controller_t* ctl = &d->controller; -@@ -154,6 +152,7 @@ void uni_hid_parser_psmove_parse_input_report(uni_hid_device_t* d, const uint8_t +@@ -154,6 +152,7 @@ } uni_controller_t* ctl = &d->controller; @@ -178,7 +464,7 @@ index 0265f93..5c0f2bb 100644 // Buttons if (r->buttons[0] & 0x01) -@@ -187,18 +186,39 @@ void uni_hid_parser_psmove_parse_input_report(uni_hid_device_t* d, const uint8_t +@@ -187,16 +186,37 @@ ctl->gamepad.throttle = r->trigger * 4; @@ -205,8 +491,8 @@ index 0265f93..5c0f2bb 100644 if (r->battery <= 5) ctl->battery = r->battery * 51; - } - ++} ++ +void uni_hid_parser_psmove_parse_feature_report( + uni_hid_device_t* d, const uint8_t* report, uint16_t len) { + psmove_instance_t* ins = get_psmove_instance(d); @@ -220,12 +506,10 @@ index 0265f93..5c0f2bb 100644 + } else if (result == UNI_PSMOVE_CALIBRATION_INVALID) { + loge("psmove: invalid IMU calibration; motion disabled\n"); + } -+} -+ + } + void uni_hid_parser_psmove_play_dual_rumble(struct uni_hid_device_s* d, - uint16_t start_delay_ms, - uint16_t duration_ms, -@@ -261,25 +281,34 @@ void uni_hid_parser_psmove_setup(struct uni_hid_device_s* d) { +@@ -261,25 +281,34 @@ switch (d->product_id) { case ZCM1_PID: @@ -241,11 +525,14 @@ index 0265f93..5c0f2bb 100644 default: - loge("psmove: Unknown PSMove PID = %#x, assuming ZCM1\n", ins->model); - ins->model = PSMOVE_MODEL_ZCM1; +- break; +- } +- + loge("psmove: Unknown PSMove PID = %#x, assuming ZCM1\n", d->product_id); + ins->model = UNI_PSMOVE_IMU_MODEL_ZCM1; - break; - } - ++ break; ++ } ++ + psmove_request_calibration_report(d); uni_hid_device_set_ready_complete(d); } @@ -816,7 +1103,6 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_switch.c b/src/compo process_fsm(d); } diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/components/bluepad32/parser/uni_hid_parser_wii.c -index be2103e..4819639 100644 --- a/src/components/bluepad32/parser/uni_hid_parser_wii.c +++ b/src/components/bluepad32/parser/uni_hid_parser_wii.c @@ -19,6 +19,7 @@ @@ -827,7 +1113,7 @@ index be2103e..4819639 100644 #include "controller/uni_controller.h" #include "hid_usage.h" -@@ -585,9 +586,7 @@ static void process_drm_ka(uni_hid_device_t* d, const uint8_t* report, uint16_t +@@ -585,9 +586,7 @@ uni_controller_t* ctl = &d->controller; @@ -841,27 +1127,39 @@ index be2103e..4819639 100644 diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad32/uni_hid_device.c --- a/src/components/bluepad32/uni_hid_device.c +++ b/src/components/bluepad32/uni_hid_device.c -@@ -447,6 +447,9 @@ +@@ -32,6 +32,7 @@ + #include "parser/uni_hid_parser_stadia.h" + #include "parser/uni_hid_parser_steam.h" + #include "parser/uni_hid_parser_switch.h" ++#include "parser/uni_hid_parser_switch2.h" + #include "parser/uni_hid_parser_wii.h" + #include "parser/uni_hid_parser_xboxone.h" + #include "platform/uni_platform.h" +@@ -447,6 +448,11 @@ return; } + if (d->report_parser.setup == uni_hid_parser_switch_setup) + uni_hid_parser_switch_teardown(d); ++ if (uni_hid_parser_switch2_is_ble_device(d)) ++ uni_hid_parser_switch2_teardown(d); + // Disconnect child first if (d->child) uni_hid_device_disconnect(d->child); -@@ -490,6 +493,9 @@ +@@ -490,6 +496,11 @@ loge("uni_hid_device_delete: invalid hid device: NULL\n"); return; } + if (d->report_parser.setup == uni_hid_parser_switch_setup) + uni_hid_parser_switch_teardown(d); ++ if (uni_hid_parser_switch2_is_ble_device(d)) ++ uni_hid_parser_switch2_teardown(d); + // Delete child first if (d->child) -@@ -655,6 +661,7 @@ +@@ -655,6 +666,7 @@ d->report_parser.setup = uni_hid_parser_psmove_setup; d->report_parser.init_report = uni_hid_parser_psmove_init_report; d->report_parser.parse_input_report = uni_hid_parser_psmove_parse_input_report; @@ -869,7 +1167,23 @@ diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad3 d->report_parser.set_lightbar_color = uni_hid_parser_psmove_set_lightbar_color; d->report_parser.play_dual_rumble = uni_hid_parser_psmove_play_dual_rumble; logi("Device detected as PS Move: 0x%02x\n", type); -@@ -809,7 +816,11 @@ +@@ -718,6 +730,15 @@ + d->report_parser.device_dump = uni_hid_parser_switch_device_dump; + logi("Device detected as Nintendo Switch Pro controller: 0x%02x\n", type); + break; ++ case CONTROLLER_TYPE_Switch2ProController: ++ case CONTROLLER_TYPE_Switch2JoyConLeft: ++ case CONTROLLER_TYPE_Switch2JoyConRight: ++ d->report_parser.setup = uni_hid_parser_switch2_setup; ++ d->report_parser.init_report = uni_hid_parser_switch2_init_report; ++ d->report_parser.parse_input_report = uni_hid_parser_switch2_parse_input_report; ++ d->report_parser.set_player_leds = uni_hid_parser_switch2_set_player_leds; ++ d->report_parser.play_dual_rumble = uni_hid_parser_switch2_play_dual_rumble; ++ break; + case CONTROLLER_TYPE_SteamController: + d->report_parser.setup = uni_hid_parser_steam_setup; + d->report_parser.init_report = uni_hid_parser_steam_init_report; +@@ -809,7 +830,11 @@ return; } @@ -882,56 +1196,3 @@ diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad3 if (err != 0) { logd("Could not send report (error=0x%04x). Adding it to queue\n", err); if (uni_circular_buffer_put(&d->outgoing_buffer, cid, report, len) != 0) { -diff --git a/src/components/bluepad32/bt/uni_bt.c b/src/components/bluepad32/bt/uni_bt.c ---- a/src/components/bluepad32/bt/uni_bt.c -+++ b/src/components/bluepad32/bt/uni_bt.c -@@ -73,6 +73,12 @@ - #include "uni_log.h" - #include "uni_property.h" - -+#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT -+// The opt-in PCM sender owns its CAN_SEND_NOW event, not the generic HID FIFO. -+extern bool uni_platform_on_l2cap_can_send_now(uni_hid_device_t* device, -+ uint16_t local_cid); -+#endif -+ - // globals - bd_addr_t uni_local_bd_addr; - -@@ -473,6 +479,10 @@ void uni_bt_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t* pack - if (device == NULL) { - loge("--->>> CANNOT FIND DEVICE"); - } else { -+#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT -+ if (uni_platform_on_l2cap_can_send_now(device, local_cid)) -+ break; -+#endif - uni_hid_device_send_queued_reports(device); - } - break; -diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_switch.h b/src/components/bluepad32/include/parser/uni_hid_parser_switch.h ---- a/src/components/bluepad32/include/parser/uni_hid_parser_switch.h -+++ b/src/components/bluepad32/include/parser/uni_hid_parser_switch.h -@@ -23,4 +23,22 @@ - bool uni_hid_parser_switch_does_name_match(struct uni_hid_device_s* d, const char* name); - void uni_hid_parser_switch_device_dump(struct uni_hid_device_s* d); - -+// BTstack-thread-only native owner. Qualification belongs to the caller: -+// type and firmware are unverified bytes from a successful device-info reply. -+bool uni_hid_parser_switch_native_info(struct uni_hid_device_s* d, -+ uint8_t* type, uint8_t* firmware_hi, -+ uint8_t* firmware_lo); -+bool uni_hid_parser_switch_native_acquire(struct uni_hid_device_s* d); -+// Retires compatibility timers and requests neutral; no audio is queued. -+void uni_hid_parser_switch_native_release(struct uni_hid_device_s* d); -+// Exactly 11 bytes on interrupt L2CAP. False means NOT submitted, never queued. -+// Caller must retry/resynchronize; true is transport acceptance, not playback. -+bool uni_hid_parser_switch_native_send(struct uni_hid_device_s* d, const uint8_t rumble[8]); -+ -+// Internal device-send/lifecycle hooks, including queued subcommand retries. -+// Returns the L2CAP status, or success when discarding an obsolete writer. -+int uni_hid_parser_switch_send_report(struct uni_hid_device_s* d, uint16_t cid, -+ const uint8_t* report, uint16_t len); -+void uni_hid_parser_switch_teardown(struct uni_hid_device_s* d); -+ - #endif // UNI_HID_PARSER_SWITCH_H diff --git a/src/firmware/configuration/adapter_configuration.cpp b/src/firmware/configuration/adapter_configuration.cpp index 1e8ddb7..49cfbce 100644 --- a/src/firmware/configuration/adapter_configuration.cpp +++ b/src/firmware/configuration/adapter_configuration.cpp @@ -8,6 +8,10 @@ bool pairing_window_valid(uint16_t pairing_window_seconds) { pairing_window_seconds <= ADAPTER_PAIRING_WINDOW_SECONDS_MAX; } +bool joycon_mode_valid(JoyConMode mode) { + return mode == JoyConMode::kPaired || mode == JoyConMode::kIndividual; +} + bool native_switch_identity_eligible(const ControllerIdentity& identity) { return identity.stable && identity.transport == ControllerTransport::kClassic && @@ -77,6 +81,7 @@ bool adapter_configuration_encode(const AdapterConfiguration& configuration, output_size != ADAPTER_CONFIGURATION_ENCODED_SIZE || !pairing_window_valid(configuration.pairing_window_seconds) || !adapter_requested_mode_valid(configuration.requested_mode) || + !joycon_mode_valid(configuration.joycon_mode) || configuration.native_switch_controller_count > ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY) { return false; @@ -88,6 +93,7 @@ bool adapter_configuration_encode(const AdapterConfiguration& configuration, static_cast(configuration.pairing_window_seconds >> 8); output[2] = static_cast(configuration.requested_mode); output[3] = configuration.native_switch_controller_count; + output[4] = static_cast(configuration.joycon_mode); for (size_t index = 0; index < configuration.native_switch_controller_count; ++index) { const ControllerIdentity& identity = @@ -136,15 +142,19 @@ bool adapter_configuration_decode(uint16_t schema_version, } decoded.requested_mode = AdapterRequestedMode::kAuto; } else if (schema_version == ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION || + schema_version == ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION || schema_version == ADAPTER_CONFIGURATION_SCHEMA_VERSION) { - const bool current = + const bool has_approvals = + schema_version >= ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION; + const bool has_joycon_mode = schema_version == ADAPTER_CONFIGURATION_SCHEMA_VERSION; const size_t expected_size = - current ? ADAPTER_CONFIGURATION_ENCODED_SIZE - : ADAPTER_CONFIGURATION_V2_ENCODED_SIZE; - if (payload_size != expected_size || payload[4] != 0 || + has_approvals ? ADAPTER_CONFIGURATION_ENCODED_SIZE + : ADAPTER_CONFIGURATION_V2_ENCODED_SIZE; + if (payload_size != expected_size || + (!has_joycon_mode && payload[4] != 0) || payload[5] != 0 || payload[6] != 0 || payload[7] != 0 || - (!current && payload[3] != 0) || + (!has_approvals && payload[3] != 0) || payload[3] > ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY) { return false; @@ -154,7 +164,13 @@ bool adapter_configuration_decode(uint16_t schema_version, if (!adapter_requested_mode_valid(decoded.requested_mode)) { return false; } - if (current) { + if (has_joycon_mode) { + decoded.joycon_mode = static_cast(payload[4]); + if (!joycon_mode_valid(decoded.joycon_mode)) { + return false; + } + } + if (has_approvals) { decoded.native_switch_controller_count = payload[3]; size_t offset = ADAPTER_CONFIGURATION_HEADER_SIZE; for (size_t index = 0; diff --git a/src/firmware/configuration/adapter_configuration.h b/src/firmware/configuration/adapter_configuration.h index 62b9679..c04565d 100644 --- a/src/firmware/configuration/adapter_configuration.h +++ b/src/firmware/configuration/adapter_configuration.h @@ -8,7 +8,8 @@ constexpr uint16_t ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION = 1; constexpr uint16_t ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION = 2; -constexpr uint16_t ADAPTER_CONFIGURATION_SCHEMA_VERSION = 3; +constexpr uint16_t ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION = 3; +constexpr uint16_t ADAPTER_CONFIGURATION_SCHEMA_VERSION = 4; constexpr size_t ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE = 4; constexpr size_t ADAPTER_CONFIGURATION_V2_ENCODED_SIZE = 8; constexpr size_t ADAPTER_CONFIGURATION_HEADER_SIZE = 8; @@ -21,6 +22,11 @@ constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_MIN = 10; constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_MAX = 300; constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT = 60; +enum class JoyConMode : uint8_t { + kPaired = 0, + kIndividual = 1, +}; + struct AdapterConfiguration { uint16_t pairing_window_seconds = ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT; @@ -28,6 +34,7 @@ struct AdapterConfiguration { uint8_t native_switch_controller_count = 0; ControllerIdentity native_switch_controllers[ ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY]{}; + JoyConMode joycon_mode = JoyConMode::kPaired; }; struct AdapterModeAvailability { diff --git a/src/firmware/core/controller_identity.cpp b/src/firmware/core/controller_identity.cpp index eb8de61..2c0df88 100644 --- a/src/firmware/core/controller_identity.cpp +++ b/src/firmware/core/controller_identity.cpp @@ -3,9 +3,27 @@ namespace { +constexpr uint16_t kNintendoVendorId = 0x057e; +constexpr uint16_t kJoyConLeftProductId = 0x2067; +constexpr uint16_t kJoyConRightProductId = 0x2066; + +bool member_address_valid(uint8_t type, const uint8_t* address) { + return type == 0 || (type == 1 && (address[0] & 0xc0) == 0xc0); +} + +bool partner_empty(const ControllerIdentity& identity) { + constexpr uint8_t empty[6]{}; + return identity.partner_address_type == 0 && + memcmp(identity.partner_address, empty, sizeof(empty)) == 0; +} + bool controller_identity_valid(const ControllerIdentity& identity) { const uint8_t transport = static_cast(identity.transport); - if (transport > static_cast(ControllerTransport::kBle)) { + if (identity.transport == ControllerTransport::kJoyConPair) { + return controller_identity_is_joycon_pair(identity); + } + if (transport > static_cast(ControllerTransport::kBle) || + !partner_empty(identity)) { return false; } if (identity.stable) { @@ -32,7 +50,71 @@ bool controller_identity_equal(const ControllerIdentity& first, first.address_type == second.address_type && memcmp(first.address, second.address, sizeof(first.address)) == 0 && first.vendor_id == second.vendor_id && - first.product_id == second.product_id; + first.product_id == second.product_id && + first.partner_address_type == second.partner_address_type && + memcmp(first.partner_address, second.partner_address, + sizeof(first.partner_address)) == 0; +} + +bool controller_identity_is_joycon_pair(const ControllerIdentity& identity) { + return identity.stable && + identity.transport == ControllerTransport::kJoyConPair && + identity.vendor_id == kNintendoVendorId && + identity.product_id == kJoyConLeftProductId && + member_address_valid(identity.address_type, identity.address) && + member_address_valid(identity.partner_address_type, + identity.partner_address) && + (identity.address_type != identity.partner_address_type || + memcmp(identity.address, identity.partner_address, + sizeof(identity.address)) != 0); +} + +bool controller_identity_make_joycon_pair(const ControllerIdentity& left, + const ControllerIdentity& right, + ControllerIdentity* output) { + if (output == nullptr || !left.stable || !right.stable || + left.transport != ControllerTransport::kBle || + right.transport != ControllerTransport::kBle || + left.vendor_id != kNintendoVendorId || + right.vendor_id != kNintendoVendorId || + left.product_id != kJoyConLeftProductId || + right.product_id != kJoyConRightProductId || + !partner_empty(left) || !partner_empty(right)) { + return false; + } + ControllerIdentity pair = left; + pair.transport = ControllerTransport::kJoyConPair; + pair.partner_address_type = right.address_type; + memcpy(pair.partner_address, right.address, sizeof(pair.partner_address)); + if (!controller_identity_is_joycon_pair(pair)) { + return false; + } + *output = pair; + return true; +} + +bool controller_identity_joycon_pair_members(const ControllerIdentity& pair, + ControllerIdentity* left, + ControllerIdentity* right) { + if (left == nullptr || right == nullptr || left == right || + !controller_identity_is_joycon_pair(pair)) { + return false; + } + ControllerIdentity decoded_left{}; + decoded_left.stable = true; + decoded_left.transport = ControllerTransport::kBle; + decoded_left.address_type = pair.address_type; + memcpy(decoded_left.address, pair.address, sizeof(decoded_left.address)); + decoded_left.vendor_id = kNintendoVendorId; + decoded_left.product_id = kJoyConLeftProductId; + ControllerIdentity decoded_right = decoded_left; + decoded_right.address_type = pair.partner_address_type; + memcpy(decoded_right.address, pair.partner_address, + sizeof(decoded_right.address)); + decoded_right.product_id = kJoyConRightProductId; + *left = decoded_left; + *right = decoded_right; + return true; } bool controller_identity_encode(const ControllerIdentity& identity, @@ -41,6 +123,15 @@ bool controller_identity_encode(const ControllerIdentity& identity, !controller_identity_valid(identity)) { return false; } + if (identity.transport == ControllerTransport::kJoyConPair) { + output[0] = 1 | (identity.address_type << 1) | + (identity.partner_address_type << 2); + output[1] = static_cast(identity.transport); + memcpy(&output[2], identity.address, sizeof(identity.address)); + memcpy(&output[8], identity.partner_address, + sizeof(identity.partner_address)); + return true; + } output[0] = identity.stable ? 1 : 0; output[1] = static_cast(identity.transport); @@ -57,21 +148,36 @@ bool controller_identity_encode(const ControllerIdentity& identity, bool controller_identity_decode(const uint8_t* input, size_t input_size, ControllerIdentity* output) { if (input == nullptr || output == nullptr || - input_size != CONTROLLER_IDENTITY_ENCODED_SIZE || input[0] > 1 || - input[1] > static_cast(ControllerTransport::kBle) || - input[3] != 0) { + input_size != CONTROLLER_IDENTITY_ENCODED_SIZE) { return false; } ControllerIdentity decoded{}; - decoded.stable = input[0] != 0; decoded.transport = static_cast(input[1]); - decoded.address_type = input[2]; - memcpy(decoded.address, &input[4], sizeof(decoded.address)); - decoded.vendor_id = static_cast(input[10]) | - static_cast(input[11] << 8); - decoded.product_id = static_cast(input[12]) | - static_cast(input[13] << 8); + if (decoded.transport == ControllerTransport::kJoyConPair) { + if ((input[0] & 0xf9) != 1) { + return false; + } + decoded.stable = true; + decoded.address_type = (input[0] >> 1) & 1; + decoded.partner_address_type = (input[0] >> 2) & 1; + memcpy(decoded.address, &input[2], sizeof(decoded.address)); + memcpy(decoded.partner_address, &input[8], + sizeof(decoded.partner_address)); + decoded.vendor_id = kNintendoVendorId; + decoded.product_id = kJoyConLeftProductId; + } else { + if (input[0] > 1 || input[3] != 0) { + return false; + } + decoded.stable = input[0] != 0; + decoded.address_type = input[2]; + memcpy(decoded.address, &input[4], sizeof(decoded.address)); + decoded.vendor_id = static_cast(input[10]) | + static_cast(input[11] << 8); + decoded.product_id = static_cast(input[12]) | + static_cast(input[13] << 8); + } if (!controller_identity_valid(decoded)) { return false; } diff --git a/src/firmware/core/controller_identity.h b/src/firmware/core/controller_identity.h index c363991..61e319f 100644 --- a/src/firmware/core/controller_identity.h +++ b/src/firmware/core/controller_identity.h @@ -9,6 +9,7 @@ enum class ControllerTransport : uint8_t { kUnknown = 0, kClassic = 1, kBle = 2, + kJoyConPair = 3, }; struct ControllerIdentity { @@ -18,12 +19,21 @@ struct ControllerIdentity { uint8_t address[6]{}; uint16_t vendor_id = 0; uint16_t product_id = 0; + uint8_t partner_address_type = 0; + uint8_t partner_address[6]{}; }; ControllerIdentity controller_identity_global(); bool controller_identity_is_global(const ControllerIdentity& identity); bool controller_identity_equal(const ControllerIdentity& first, const ControllerIdentity& second); +bool controller_identity_is_joycon_pair(const ControllerIdentity& identity); +bool controller_identity_make_joycon_pair(const ControllerIdentity& left, + const ControllerIdentity& right, + ControllerIdentity* output); +bool controller_identity_joycon_pair_members(const ControllerIdentity& pair, + ControllerIdentity* left, + ControllerIdentity* right); bool controller_identity_encode(const ControllerIdentity& identity, uint8_t* output, size_t output_size); bool controller_identity_decode(const uint8_t* input, size_t input_size, diff --git a/src/firmware/core/controller_state.h b/src/firmware/core/controller_state.h index b2b8392..3989db8 100644 --- a/src/firmware/core/controller_state.h +++ b/src/firmware/core/controller_state.h @@ -44,6 +44,9 @@ struct ControllerState { bool button_left_stick; bool button_right_stick; + // Source-only controls: C, GL, GR, left SL/SR, right SL/SR. + uint8_t extra_buttons = 0; + uint16_t left_trigger; uint16_t right_trigger; diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index b49f426..6a0de7d 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -23,6 +23,9 @@ #include #include #include +#include "parser/uni_hid_parser_switch2.h" +#include "parser/uni_switch2_haptics.h" +#include "parser/uni_switch2_pairing.h" #ifdef SWITCH_PICO_USB_OUTPUT_MODES #include "adapter/adapter_usb_mode.h" #endif @@ -45,6 +48,10 @@ constexpr uint32_t kDefaultPairingWindowDurationMs = constexpr uint32_t kPairingResetFeedbackDurationMs = 2000; // Bluetooth Classic units are 0.625 ms: 0x1900 = 4 seconds. constexpr uint16_t kClassicLinkSupervisionTimeout = 0x1900; +// LE units are 1.25 ms. Joy-Cons retain the fast interval even with Classic. +constexpr uint16_t kSwitch2FastInterval = 6; +constexpr uint16_t kSwitch2MixedInterval = 24; +constexpr uint32_t kSwitch2IntervalSettleMs = 1000; constexpr uint8_t kAllBlePairingMethods = SM_STK_GENERATION_METHOD_JUST_WORKS | SM_STK_GENERATION_METHOD_OOB | @@ -53,6 +60,9 @@ constexpr uint8_t kAllBlePairingMethods = constexpr uint16_t kProfileFeedbackPhaseDurationMs = 75; constexpr uint8_t kProfileFeedbackWeakMagnitude = UINT8_MAX; constexpr uint8_t kProfileFeedbackStrongMagnitude = UINT8_MAX; +constexpr uint32_t kJoyConGestureHoldMs = 2000; +constexpr uint32_t kJoyConGestureFreshMs = 250; +constexpr uint16_t kJoyConGestureFeedbackMs = 75; // One initial indication can be followed by one committed switch before the // Core 1 timer drains the queue. Profile commits are rate-limited well beyond // the longest feedback sequence. @@ -110,10 +120,26 @@ struct RumbleEnvelope { uint32_t connection_generation; ControllerRumbleOutput rumble; uint16_t duration_ms; + uint32_t received_ms = 0; #if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE) uint64_t received_us = 0; #endif }; + +constexpr uint8_t kSwitch2IngressCapacity = 16; +struct Switch2HostCommand { + RumbleEnvelope envelope; + uint32_t generation; + uint8_t accepted_halves; +}; +struct Switch2Ingress { + Switch2HostCommand commands[kSwitch2IngressCapacity]; + uint32_t generation; + uint8_t host_mode; + uint8_t head; + uint8_t count; + bool reset_pending; +}; struct FeedbackEnvelope { uint32_t connection_generation; uint16_t duration_ms; @@ -156,6 +182,13 @@ struct BackendSlot { ControllerIdentity identity; // Non-null with active=false is a connected device still becoming ready. uni_hid_device_t* device; + // A pair occupies one logical profile owner and output, but still consumes + // two Bluepad32 physical device indices. device is always the left half. + uni_hid_device_t* companion; + uni_gamepad_t gamepad; + uni_gamepad_t companion_gamepad; + uint8_t extra_buttons; + uint8_t companion_extra_buttons; uint32_t state_generation; uint32_t connection_generation; bool active; @@ -167,6 +200,7 @@ struct BackendSlot { RumbleEnvelope pending_rumble; bool retained_host_rumble_valid; RumbleEnvelope retained_host_rumble; + Switch2Ingress switch2_ingress; FeedbackEnvelope pending_feedback; ProfileFeedbackEnvelope pending_profile_feedback[kProfileFeedbackQueueCapacity]; @@ -174,6 +208,7 @@ struct BackendSlot { }; critical_section_t g_state_lock; +uni_hid_device_t* g_retired_devices[kSlotCount]{}; BackendSlot g_slots[kSlotCount]; ControllerMacroCapture g_macro_capture; // Catalog migration/compaction needs more than the 4 KiB scratch bank. @@ -201,6 +236,48 @@ btstack_packet_callback_registration_t g_pairing_event_callback{}; btstack_packet_callback_registration_t g_identity_event_callback{}; ConnectionPolicyState g_connection_policy_state = ConnectionPolicyState::Uninitialized; +bool g_background_scan_active = false; +struct Switch2IntervalRequest { + hci_con_handle_t handle = HCI_CON_HANDLE_INVALID; + uint16_t interval = 0; + uint32_t requested_ms = 0; +}; +Switch2IntervalRequest g_switch2_interval_requests[kSlotCount]{}; +JoyConMode g_joycon_mode = JoyConMode::kPaired; +bool g_joycon_reconcile_requested = false; +// Live-link hints only: splitting two pairs must not exchange their members +// when the next Paired preference is applied. +struct JoyConPairHint { + uni_hid_device_t* mate = nullptr; + uint8_t owner_slot = 0; +}; +JoyConPairHint g_joycon_pair_hints[kSlotCount]{}; + +enum class JoyConGroupingOverride : uint8_t { + Default, + Individual, + Paired, +}; +struct JoyConConnectionOverride { + JoyConGroupingOverride mode = JoyConGroupingOverride::Default; + uni_hid_device_t* mate = nullptr; +}; +JoyConConnectionOverride g_joycon_overrides[kSlotCount]{}; + +// Core 1 physical-link state survives logical slot moves. Raw reports stay in +// BackendSlot; only the derived logical view consumes the reserved buttons. +struct JoyConGesture { + uni_hid_device_t* device = nullptr; + uint32_t last_report_ms = 0; + uint32_t started_ms = 0; + uint8_t participants = 0; + bool held = false; + bool released = true; + bool masked = false; + bool blocked = false; + bool joining = false; +}; +JoyConGesture g_joycon_gestures[kSlotCount]{}; uint32_t g_pairing_window_deadline_ms = 0; uint32_t g_pairing_window_duration_ms = kDefaultPairingWindowDurationMs; @@ -216,6 +293,7 @@ uint32_t g_controller_reports = 0; uint32_t g_host_rumble_requests = 0; uint32_t g_local_feedback_requests = 0; uint32_t g_rumble_dispatches = 0; +uint32_t g_switch2_ingress_drops = 0; #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT uint32_t g_seeded_native_run_id = 0; #endif @@ -229,6 +307,14 @@ uint16_t host_rumble_duration_ms() { return kSwitchHostRumbleDurationMs; } +uint8_t switch2_host_mode() { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + return static_cast(adapter_host_probe_mode()); +#else + return 0; +#endif +} + ControllerState make_neutral_state() { return controller_neutral_state(); } @@ -239,12 +325,13 @@ bool valid_slot(uint8_t slot) { bool has_free_slot() { critical_section_enter_blocking(&g_state_lock); - bool free_slot = false; + unsigned physical_count = 0; for (const BackendSlot& slot : g_slots) { - free_slot = free_slot || slot.device == nullptr; + physical_count += slot.device != nullptr; + physical_count += slot.companion != nullptr; } critical_section_exit(&g_state_lock); - return free_slot; + return physical_count < kSlotCount; } bool has_active_controller() { @@ -257,12 +344,449 @@ bool has_active_controller() { return active_controller; } -int slot_for_device(const uni_hid_device_t* device) { +int physical_index_for_device(const uni_hid_device_t* device) { if (device == nullptr) { return -1; } - const int slot = uni_hid_device_get_idx_for_instance(device); - return slot >= 0 && slot < kSlotCount ? slot : -1; + const int index = uni_hid_device_get_idx_for_instance(device); + return index >= 0 && index < kSlotCount ? index : -1; +} + +int slot_for_device(const uni_hid_device_t* device) { + if (device != nullptr) { + for (uint8_t index = 0; index < kSlotCount; ++index) { + if (g_slots[index].device == device || + g_slots[index].companion == device) { + return index; + } + } + } + return -1; +} + +// Called under the state lock. Bluepad32's index is a transport resource, not +// an output index once two Joy-Cons merge. Never evict an unrelated output. +int reserve_device_slot(uni_hid_device_t* device) { + const int physical_index = physical_index_for_device(device); + if (physical_index < 0) { + return -1; + } + const int tracked = slot_for_device(device); + if (g_retired_devices[physical_index] == device && + uni_hid_parser_switch2_is_ble_device(device)) { + return -1; + } + if (tracked >= 0) { + return tracked; + } + for (const BackendSlot& slot : g_slots) { + if ((slot.device != nullptr && + physical_index_for_device(slot.device) == physical_index) || + (slot.companion != nullptr && + physical_index_for_device(slot.companion) == physical_index)) { + return -1; + } + } + if (g_slots[physical_index].device == nullptr) { + return physical_index; + } + for (uint8_t index = 0; index < kSlotCount; ++index) { + if (g_slots[index].device == nullptr) { + return index; + } + } + return -1; +} + +int joycon_side(const uni_hid_device_t* device) { + if (!uni_hid_parser_switch2_is_ble_device(device)) { + return 0; + } + if (device->product_id == UNI_SW2_JOYCON_L_PID) { + return -1; + } + return device->product_id == UNI_SW2_JOYCON_R_PID ? 1 : 0; +} + +bool joycon_default_pairing_allowed(const uni_hid_device_t* device) { + const int index = physical_index_for_device(device); + return g_joycon_mode == JoyConMode::kPaired && index >= 0 && + g_joycon_overrides[index].mode == JoyConGroupingOverride::Default; +} + +// Include the entire old attempt when another held solo makes selection +// ambiguous. None of its members may retry until all have released. +void block_joycon_gesture(uint8_t participants) { + for (uint8_t pass = 0; pass < kSlotCount; ++pass) { + for (uint8_t index = 0; index < kSlotCount; ++index) { + if (participants & (1u << index)) { + participants |= g_joycon_gestures[index].participants; + } + } + } + for (uint8_t index = 0; index < kSlotCount; ++index) { + if (!(participants & (1u << index))) continue; + g_joycon_gestures[index].participants = participants; + g_joycon_gestures[index].blocked = true; + } +} + +bool joycon_gesture_live(uint8_t index) { + const JoyConGesture& gesture = g_joycon_gestures[index]; + const int slot = slot_for_device(gesture.device); + return gesture.device != nullptr && slot >= 0 && g_slots[slot].active && + physical_index_for_device(gesture.device) == index && + joycon_side(gesture.device) != 0; +} + +void arm_joycon_gesture(uint8_t left, uint8_t right, uint32_t now_ms, + bool joining) { + JoyConGesture& l = g_joycon_gestures[left]; + JoyConGesture& r = g_joycon_gestures[right]; + if (!l.held || !r.held || l.blocked || r.blocked || + l.participants != (1u << left) || + r.participants != (1u << right)) return; + l.participants = r.participants = (1u << left) | (1u << right); + l.started_ms = r.started_ms = now_ms; + l.joining = r.joining = joining; +} + +// Caller holds the state lock. Reports perform admission; the timer also +// expires attempts, but elapsed cached input alone can never complete a hold. +void refresh_joycon_gestures(uint32_t now_ms) { + for (uint8_t index = 0; index < kSlotCount; ++index) { + JoyConGesture& gesture = g_joycon_gestures[index]; + if (gesture.participants == 0 || gesture.blocked) continue; + if (!joycon_gesture_live(index) || !gesture.held || + now_ms - gesture.last_report_ms > kJoyConGestureFreshMs) { + block_joycon_gesture(gesture.participants); + } + } + for (JoyConGesture& gesture : g_joycon_gestures) { + if (gesture.participants == 0) continue; + bool released = true; + for (uint8_t index = 0; index < kSlotCount; ++index) { + if ((gesture.participants & (1u << index)) && + !g_joycon_gestures[index].released) released = false; + } + if (!released) continue; + const uint8_t participants = gesture.participants; + for (uint8_t index = 0; index < kSlotCount; ++index) { + if (!(participants & (1u << index))) continue; + g_joycon_gestures[index].participants = 0; + g_joycon_gestures[index].blocked = false; + } + } + + int left = -1; + int right = -1; + uint8_t solos = 0; + bool ambiguous = false; + for (const BackendSlot& slot : g_slots) { + if (!slot.active || joycon_side(slot.device) == 0) continue; + const int index = physical_index_for_device(slot.device); + if (index < 0) continue; + if (slot.companion != nullptr) { + const int mate = physical_index_for_device(slot.companion); + if (mate >= 0) arm_joycon_gesture(index, mate, now_ms, false); + continue; + } + const JoyConGesture& gesture = g_joycon_gestures[index]; + if (gesture.device != slot.device || !gesture.held || gesture.blocked) continue; + solos |= 1u << index; + int& side = joycon_side(slot.device) < 0 ? left : right; + ambiguous = ambiguous || side >= 0; + side = index; + } + if (ambiguous) { + block_joycon_gesture(solos); + } else if (left >= 0 && right >= 0) { + arm_joycon_gesture(left, right, now_ms, true); + } +} + +void observe_joycon_gesture(uni_hid_device_t* device, + const uni_gamepad_t& raw, uint32_t now_ms) { + const int side = joycon_side(device); + const int index = physical_index_for_device(device); + if (side == 0 || index < 0) return; + // Expire before updating the timestamp: a returning stale report must not + // hide a gap, even when no timer ran during it. + refresh_joycon_gestures(now_ms); + JoyConGesture& gesture = g_joycon_gestures[index]; + gesture.device = device; + const bool trigger = (raw.buttons & + (side < 0 ? BUTTON_TRIGGER_L : BUTTON_TRIGGER_R)) != 0; + const bool menu = (raw.misc_buttons & + (side < 0 ? MISC_BUTTON_SELECT : MISC_BUTTON_START)) != 0; + gesture.last_report_ms = now_ms; + gesture.held = trigger && menu; + gesture.released = !trigger && !menu; + if (gesture.held) { + gesture.masked = true; + if (gesture.participants == 0) gesture.participants = 1u << index; + } else if (gesture.released) { + gesture.masked = false; + } + refresh_joycon_gestures(now_ms); +} + +bool joycon_gesture_masked(const uni_hid_device_t* device) { + const int index = physical_index_for_device(device); + return index >= 0 && g_joycon_gestures[index].device == device && + g_joycon_gestures[index].masked; +} + +void mask_joycon_gesture(uni_gamepad_t& gamepad, + const uni_hid_device_t* device) { + if (!joycon_gesture_masked(device)) return; + if (joycon_side(device) < 0) { + gamepad.buttons &= ~BUTTON_TRIGGER_L; + gamepad.misc_buttons &= ~MISC_BUTTON_SELECT; + gamepad.brake = 0; + } else { + gamepad.buttons &= ~BUTTON_TRIGGER_R; + gamepad.misc_buttons &= ~MISC_BUTTON_START; + gamepad.throttle = 0; + } +} + +bool waiting_for_joycon_mate(int side = 0) { + critical_section_enter_blocking(&g_state_lock); + unsigned physical_count = 0; + bool pending = false; + unsigned left_count = 0; + unsigned right_count = 0; + for (const BackendSlot& slot : g_slots) { + physical_count += slot.device != nullptr; + physical_count += slot.companion != nullptr; + pending = pending || (slot.device != nullptr && !slot.active); + // An explicit solo choice is complete, not a request for another + // default-paired mate. Individual defaults retain balanced reconnects. + if (slot.active && slot.companion == nullptr && + (g_joycon_mode == JoyConMode::kIndividual || + joycon_default_pairing_allowed(slot.device))) { + const int candidate_side = joycon_side(slot.device); + left_count += candidate_side < 0; + right_count += candidate_side > 0; + } + } + critical_section_exit(&g_state_lock); + // Individual players still reconnect their remembered opposite half, but + // a balanced set is complete even though no logical pair was created. + const bool missing_left = g_joycon_mode == JoyConMode::kIndividual + ? right_count > left_count + : right_count != 0; + const bool missing_right = g_joycon_mode == JoyConMode::kIndividual + ? left_count > right_count + : left_count != 0; + return physical_count < kSlotCount && !pending && + ((side <= 0 && missing_left) || (side >= 0 && missing_right)); +} + +void stop_background_scan() { + if (g_background_scan_active) { + // Direct LE scans do not update Bluepad32's aggregate scanning flag. + uni_bt_le_scan_stop(); + g_background_scan_active = false; + } +} +// Core 1 only. Count physical links, not logical players. Joy-Cons always keep +// their fast default; retain preconnection coexistence timing for other Switch 2 +// models when multiple physical Switch 2 links share the radio. +void apply_radio_connection_policy() { + unsigned switch2_links = 0; + uni_hid_device_t* ready[kSlotCount]{}; + for (const BackendSlot& slot : g_slots) { + uni_hid_device_t* targets[] = {slot.device, slot.companion}; + for (uni_hid_device_t* target : targets) { + const int index = physical_index_for_device(target); + if (index < 0) continue; + const auto type = gap_get_connection_type(target->conn.handle); + if (type == GAP_CONNECTION_LE && + uni_hid_parser_switch2_is_ble_device(target)) { + ++switch2_links; + // The parser requests its initial interval during setup. + // Do not race that request by changing a pending device here. + if (slot.active) ready[index] = target; + } + } + } + const uint32_t now_ms = btstack_run_loop_get_time_ms(); + for (uint8_t index = 0; index < kSlotCount; ++index) { + auto& request = g_switch2_interval_requests[index]; + if (ready[index] == nullptr) { + request = {}; + continue; + } + const uint16_t desired = switch2_links >= 2 && joycon_side(ready[index]) == 0 + ? kSwitch2MixedInterval + : kSwitch2FastInterval; + const auto handle = ready[index]->conn.handle; + if (request.handle != handle) request = {}; + const uint16_t actual = gap_le_connection_interval(handle); + if (request.interval != 0 && actual != request.interval && + now_ms - request.requested_ms < kSwitch2IntervalSettleMs) { + // Let an accepted asynchronous update settle before reversing it. + // API success alone does not prove that negotiation completed. + continue; + } + request.interval = 0; + if (actual == desired) continue; + gap_update_connection_parameters(handle, desired, desired, 0, 600); + // Reconcile negotiated state on the configuration timer. Rejected or + // incomplete requests are retried at most once per second per link. + request = {handle, desired, now_ms}; + } +} + + +// Caller holds the cross-core state lock. Only Core 1 resets parser state. +void clear_switch2_ingress(BackendSlot& slot) { + Switch2Ingress& ingress = slot.switch2_ingress; + __atomic_add_fetch(&g_switch2_ingress_drops, ingress.count, __ATOMIC_RELAXED); + ingress.head = 0; + ingress.count = 0; + ++ingress.generation; + ingress.reset_pending = true; +} + +void reset_switch2_outputs(BackendSlot& slot) { + uni_hid_device_t* targets[] = {slot.device, slot.companion}; + for (uni_hid_device_t* target : targets) { + if (uni_hid_parser_switch2_is_ble_device(target)) { + uni_hid_parser_switch2_reset_haptics(target); + } + } + slot.switch2_ingress.reset_pending = false; +} + +bool switch2_has_hd(const ControllerRumbleOutput& rumble) { + return rumble.hd.actuators[0].sample_count != 0 || + rumble.hd.actuators[1].sample_count != 0; +} + +bool switch2_host_stop(const ControllerRumbleOutput& rumble) { + if (!switch2_has_hd(rumble)) { + return (rumble.low_frequency_magnitude | rumble.high_frequency_magnitude) == 0; + } + for (const SwitchHapticsActuatorFrame& side : rumble.hd.actuators) { + if (side.sample_count == 0 || side.sample_count > 3) return false; + for (uint8_t index = 0; index < side.sample_count; ++index) { + if (side.samples[index].low_amplitude_q15 != 0 || + side.samples[index].high_amplitude_q15 != 0) return false; + } + } + return true; +} + +void encode_switch2_side(uni_switch2_haptics_side_t& output, + const SwitchHapticsActuatorFrame& input) { + output.count = input.sample_count; + for (uint8_t index = 0; index < input.sample_count; ++index) { + const SwitchHapticsSample& sample = input.samples[index]; + uni_switch2_haptics_encode_sample( + output.samples[index], sample.low_frequency_index, + sample.high_frequency_index, sample.low_amplitude_q15, + sample.high_amplitude_q15); + } +} + +uni_switch2_haptics_frame_t switch2_physical_frame( + const ControllerRumbleOutput& rumble, const uni_hid_device_t* target, + bool paired) { + uni_switch2_haptics_frame_t frame{}; + const SwitchHapticsActuatorFrame& left = rumble.hd.actuators[0]; + const SwitchHapticsActuatorFrame& right = rumble.hd.actuators[1]; + const int side = joycon_side(target); + if (side == 0) { + encode_switch2_side(frame.sides[0], left); + encode_switch2_side(frame.sides[1], right); + } else if (paired) { + encode_switch2_side(frame.sides[0], side < 0 ? left : right); + } else { + // Mono chooses each band's louder source independently. A short side + // holds its final substep; an absent side contributes no update. + frame.sides[0].count = + left.sample_count > right.sample_count ? left.sample_count : right.sample_count; + for (uint8_t index = 0; index < frame.sides[0].count; ++index) { + const SwitchHapticsSample* l = left.sample_count == 0 ? nullptr : + &left.samples[index < left.sample_count ? index : left.sample_count - 1]; + const SwitchHapticsSample* r = right.sample_count == 0 ? nullptr : + &right.samples[index < right.sample_count ? index : right.sample_count - 1]; + const SwitchHapticsSample* low = !r || (l && l->low_amplitude_q15 >= r->low_amplitude_q15) ? l : r; + const SwitchHapticsSample* high = !r || (l && l->high_amplitude_q15 >= r->high_amplitude_q15) ? l : r; + uni_switch2_haptics_encode_sample( + frame.sides[0].samples[index], low->low_frequency_index, + high->high_frequency_index, low->low_amplitude_q15, + high->high_amplitude_q15); + } + } + return frame; +} + +void drain_switch2_ingress(uint8_t slot_index, uint32_t now_ms) { + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (!slot.active || !uni_hid_parser_switch2_is_ble_device(slot.device)) { + critical_section_exit(&g_state_lock); + return; + } + Switch2Ingress& ingress = slot.switch2_ingress; + const uint16_t duration_ms = host_rumble_duration_ms(); + const uint8_t host_mode = switch2_host_mode(); + if (ingress.host_mode != host_mode) { + clear_switch2_ingress(slot); + ingress.host_mode = host_mode; + } + if (ingress.reset_pending) reset_switch2_outputs(slot); + for (uint8_t budget = 0; budget < kSwitch2IngressCapacity && ingress.count != 0; ++budget) { + Switch2HostCommand& command = ingress.commands[ingress.head]; + const RumbleEnvelope& envelope = command.envelope; + const bool hd = switch2_has_hd(envelope.rumble); + const bool stop = switch2_host_stop(envelope.rumble); + const bool stale = command.generation != ingress.generation || + envelope.connection_generation != slot.connection_generation || + envelope.duration_ms != duration_ms || + (!stop && (hd || duration_ms != kXInputHostRumbleDurationMs) && + static_cast(now_ms - envelope.received_ms) >= UNI_SWITCH2_HAPTICS_WATCHDOG_MS); + const bool invalid = envelope.rumble.hd.actuators[0].sample_count > 3 || + envelope.rumble.hd.actuators[1].sample_count > 3; + if (stale || invalid) { + __atomic_add_fetch(&g_switch2_ingress_drops, 1, __ATOMIC_RELAXED); + } else { + uni_hid_device_t* targets[] = {slot.device, slot.companion}; + const uint8_t target_mask = slot.companion == nullptr ? 1 : 3; + for (uint8_t half = 0; half < 2; ++half) { + const uint8_t bit = 1u << half; + if (!(target_mask & bit) || (command.accepted_halves & bit)) continue; + bool accepted; + if (hd) { + const uni_switch2_haptics_frame_t frame = + switch2_physical_frame(envelope.rumble, targets[half], slot.companion != nullptr); + if (frame.sides[0].count == 0 && frame.sides[1].count == 0) { + command.accepted_halves |= bit; + continue; + } + accepted = uni_hid_parser_switch2_queue_haptics( + targets[half], &frame, envelope.received_ms); + } else { + accepted = uni_hid_parser_switch2_queue_rumble( + targets[half], envelope.rumble.high_frequency_magnitude, + envelope.rumble.low_frequency_magnitude, + stop ? 0 : envelope.duration_ms, envelope.received_ms); + } + if (accepted) { + command.accepted_halves |= bit; + __atomic_add_fetch(&g_rumble_dispatches, 1, __ATOMIC_RELAXED); + } + } + if (command.accepted_halves != target_mask) break; + } + ingress.head = (ingress.head + 1u) % kSwitch2IngressCapacity; + --ingress.count; + } + critical_section_exit(&g_state_lock); } bool addresses_equal(const bd_addr_t first, const bd_addr_t second) { @@ -344,6 +868,15 @@ ControllerIdentity identity_for_device(const uni_hid_device_t* device) { if (mapping != nullptr) { return make_ble_identity(*mapping, device); } + uint8_t address_type = BD_ADDR_TYPE_UNKNOWN; + if (uni_hid_parser_switch2_identity_address_type( + device, &address_type)) { + BleIdentityMapping proprietary{}; + proprietary.identity_address_type = address_type; + memcpy(proprietary.identity_address, device->conn.btaddr, + sizeof(proprietary.identity_address)); + return make_ble_identity(proprietary, device); + } break; } case GAP_CONNECTION_INVALID: @@ -356,15 +889,19 @@ ControllerIdentity identity_for_device(const uni_hid_device_t* device) { void publish_ble_identity(const BleIdentityMapping& mapping) { ControllerIdentity observed_identity{}; bool observe_identity = false; + bool joycon_identity_changed = false; critical_section_enter_blocking(&g_state_lock); for (BackendSlot& slot : g_slots) { - if (slot.device != nullptr && + if (slot.device != nullptr && slot.companion == nullptr && gap_get_connection_type(slot.device->conn.handle) == GAP_CONNECTION_LE && slot.device->conn.handle == mapping.connection_handle && addresses_equal(slot.device->conn.btaddr, mapping.connection_address)) { - slot.identity = make_ble_identity(mapping, slot.device); + const ControllerIdentity identity = make_ble_identity(mapping, slot.device); + joycon_identity_changed = slot.active && joycon_side(slot.device) != 0 && + !controller_identity_equal(slot.identity, identity); + slot.identity = identity; if (slot.active) { observed_identity = slot.identity; observe_identity = true; @@ -375,6 +912,9 @@ void publish_ble_identity(const BleIdentityMapping& mapping) { if (observe_identity) { profile_service_observe_identity_on_storage_core( observed_identity); + if (joycon_identity_changed && g_joycon_mode == JoyConMode::kPaired) { + g_joycon_reconcile_requested = true; + } } } @@ -408,7 +948,7 @@ void clear_ble_identity_for_handle(hci_con_handle_t connection_handle) { critical_section_enter_blocking(&g_state_lock); for (BackendSlot& slot : g_slots) { - if (slot.device != nullptr && + if (slot.device != nullptr && slot.companion == nullptr && gap_get_connection_type(slot.device->conn.handle) == GAP_CONNECTION_LE && slot.device->conn.handle == connection_handle) { @@ -492,8 +1032,10 @@ bool lighting_target_is_current( const uni_hid_device_t* device) { critical_section_enter_blocking(&g_state_lock); const bool current = - slot_index < kSlotCount && g_slots[slot_index].active && - g_slots[slot_index].device == device && + device != nullptr && slot_index < kSlotCount && + g_slots[slot_index].active && + (g_slots[slot_index].device == device || + g_slots[slot_index].companion == device) && g_slots[slot_index].connection_generation == connection_generation; critical_section_exit(&g_state_lock); @@ -506,14 +1048,17 @@ ConnectionStatus compute_connection_status() { critical_section_enter_blocking(&g_state_lock); bool all_ready = true; bool any_connecting = false; + unsigned physical_count = 0; for (const BackendSlot& slot : g_slots) { const bool has_device = slot.device != nullptr; - all_ready = all_ready && slot.active && has_device; + physical_count += has_device; + physical_count += slot.companion != nullptr; + all_ready = all_ready && (!has_device || slot.active); any_connecting = any_connecting || (!slot.active && has_device); } critical_section_exit(&g_state_lock); - if (all_ready) { + if (all_ready && physical_count == kSlotCount) { return ConnectionStatus::Ready; } return any_connecting ? ConnectionStatus::Connecting @@ -540,10 +1085,17 @@ void publish_device_state(uint8_t slot, uni_hid_device_t* device, void publish_all_neutral() { critical_section_enter_blocking(&g_state_lock); for (BackendSlot& slot : g_slots) { + clear_switch2_ingress(slot); + reset_switch2_outputs(slot); slot.state = make_neutral_state(); slot.pre_hotkey_button_mask = 0; slot.identity = controller_identity_global(); slot.device = nullptr; + slot.companion = nullptr; + slot.gamepad = {}; + slot.companion_gamepad = {}; + slot.extra_buttons = 0; + slot.companion_extra_buttons = 0; slot.active = false; slot.rumble_pending = false; slot.retained_host_rumble_valid = false; @@ -559,11 +1111,17 @@ void publish_all_neutral() { ++slot.state_generation; ++slot.connection_generation; } + for (uint8_t index = 0; index < kSlotCount; ++index) { + g_joycon_gestures[index] = {}; + g_joycon_overrides[index] = {}; + g_joycon_pair_hints[index] = {}; + } critical_section_exit(&g_state_lock); for (BleIdentityMapping& mapping : g_ble_identity_mappings) { mapping = {}; } g_connection_status = ConnectionStatus::Initializing; + stop_background_scan(); g_connection_policy_state = ConnectionPolicyState::FailedClosed; g_pairing_window_open = false; g_status_led_tick = 0; @@ -837,6 +1395,85 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad, return state; } +int32_t negate_motion_axis(int32_t value) { + return value == INT32_MIN ? INT32_MAX : -value; +} + +void rotate_solo_joycon(uni_gamepad_t& gamepad, int side, + uint8_t extras) { + const uint32_t buttons = gamepad.buttons; + if (side < 0) { + const int32_t x = gamepad.axis_x; + gamepad.axis_x = clamp_axis(gamepad.axis_y); + gamepad.axis_y = clamp_axis(-clamp_axis(x)); + gamepad.buttons &= ~(BUTTON_A | BUTTON_B | BUTTON_X | BUTTON_Y); + gamepad.buttons |= + ((gamepad.dpad & DPAD_LEFT) ? uint32_t{BUTTON_A} : 0u) | + ((gamepad.dpad & DPAD_DOWN) ? uint32_t{BUTTON_B} : 0u) | + ((gamepad.dpad & DPAD_UP) ? uint32_t{BUTTON_X} : 0u) | + ((gamepad.dpad & DPAD_RIGHT) ? uint32_t{BUTTON_Y} : 0u) | + ((extras & UNI_SW2_BUTTON_LEFT_SL) ? uint32_t{BUTTON_SHOULDER_L} : 0u) | + ((extras & UNI_SW2_BUTTON_LEFT_SR) ? uint32_t{BUTTON_SHOULDER_R} : 0u); + } else { + gamepad.axis_x = clamp_axis(-clamp_axis(gamepad.axis_ry)); + gamepad.axis_y = clamp_axis(gamepad.axis_rx); + gamepad.buttons &= + ~(BUTTON_A | BUTTON_B | BUTTON_X | BUTTON_Y | BUTTON_THUMB_R); + gamepad.buttons |= + ((buttons & BUTTON_B) ? uint32_t{BUTTON_A} : 0u) | + ((buttons & BUTTON_Y) ? uint32_t{BUTTON_B} : 0u) | + ((buttons & BUTTON_A) ? uint32_t{BUTTON_X} : 0u) | + ((buttons & BUTTON_X) ? uint32_t{BUTTON_Y} : 0u) | + ((buttons & BUTTON_THUMB_R) ? uint32_t{BUTTON_THUMB_L} : 0u) | + ((extras & UNI_SW2_BUTTON_RIGHT_SL) ? uint32_t{BUTTON_SHOULDER_L} : 0u) | + ((extras & UNI_SW2_BUTTON_RIGHT_SR) ? uint32_t{BUTTON_SHOULDER_R} : 0u); + } + gamepad.dpad = 0; + gamepad.axis_rx = 0; + gamepad.axis_ry = 0; + int32_t* motion_axes[] = {gamepad.accel, gamepad.gyro}; + for (int32_t* axes : motion_axes) { + const int32_t x = axes[0]; + axes[0] = side < 0 ? negate_motion_axis(axes[1]) : axes[1]; + axes[1] = side < 0 ? x : negate_motion_axis(x); + } +} +uni_gamepad_t logical_gamepad(const BackendSlot& slot) { + uni_gamepad_t gamepad = slot.gamepad; + mask_joycon_gesture(gamepad, slot.device); + if (slot.companion != nullptr) { + const uni_gamepad_t& right = slot.companion_gamepad; + gamepad.dpad |= right.dpad; + const bool masked = joycon_gesture_masked(slot.companion); + gamepad.buttons |= + right.buttons & ~(masked ? uint32_t{BUTTON_TRIGGER_R} : 0u); + gamepad.misc_buttons |= + right.misc_buttons & ~(masked ? uint32_t{MISC_BUTTON_START} : 0u); + gamepad.axis_rx = right.axis_rx; + gamepad.axis_ry = right.axis_ry; + gamepad.throttle = masked ? 0 : right.throttle; + // The right half is the sole aim source. A left report must not + // republish an already consumed right-hand motion sample. + memcpy(gamepad.accel, right.accel, sizeof(gamepad.accel)); + memcpy(gamepad.gyro, right.gyro, sizeof(gamepad.gyro)); + } else { + const int side = joycon_side(slot.device); + if (side != 0) { + rotate_solo_joycon(gamepad, side, slot.extra_buttons); + } + } + return gamepad; +} + +void refresh_topology_input(BackendSlot& slot) { + const uni_gamepad_t gamepad = logical_gamepad(slot); + slot.pre_hotkey_button_mask = logical_button_mask(gamepad); + // Topology changes release the lost half immediately; motion stays neutral + // until a fresh report from the newly selected source arrives. + slot.state = map_gamepad(gamepad, false, slot.pre_hotkey_button_mask); + slot.state.extra_buttons = + slot.extra_buttons | slot.companion_extra_buttons; +} struct HotkeyDecision { bool motion_enabled; }; @@ -868,6 +1505,29 @@ void reset_slot_hotkeys(BackendSlot& slot) { slot.retained_host_rumble = {}; } +void invalidate_slot(BackendSlot& slot) { + clear_switch2_ingress(slot); + reset_switch2_outputs(slot); + reset_slot_hotkeys(slot); + slot.rumble_pending = false; + slot.pending_rumble = {}; + slot.state = make_neutral_state(); + ++slot.state_generation; + ++slot.connection_generation; +} + +void release_slot(BackendSlot& slot) { + invalidate_slot(slot); + slot.identity = controller_identity_global(); + slot.device = nullptr; + slot.companion = nullptr; + slot.gamepad = {}; + slot.companion_gamepad = {}; + slot.extra_buttons = 0; + slot.companion_extra_buttons = 0; + slot.active = false; +} + HotkeyDecision update_controller_hotkeys( uint8_t slot_index, uni_hid_device_t* device) { @@ -1010,6 +1670,9 @@ bool update_pairing_window(uint32_t now_ms) { const bool requested = g_pairing_window_requested; g_pairing_window_requested = false; critical_section_exit(&g_state_lock); + if (g_connection_policy_state == ConnectionPolicyState::FailedClosed) { + return false; + } if (requested) { ConfigurationServiceSnapshot configuration{}; @@ -1079,8 +1742,19 @@ void refresh_pairing_snapshot() { snapshot, Bluepad32PairingTransport::kBle, static_cast(address_type), address); } + for (uint8_t index = 0; index < UNI_SWITCH2_PAIRING_CAPACITY; ++index) { + uint8_t address_type = BD_ADDR_TYPE_UNKNOWN; + bd_addr_t address{}; + if (uni_switch2_pairing_get(index, &address_type, address)) { + append_pairing_record( + snapshot, Bluepad32PairingTransport::kBle, address_type, address); + } + } critical_section_enter_blocking(&g_state_lock); + if (g_pairing_snapshot.status == Bluepad32PairingSnapshotStatus::kFailed) { + snapshot.status = Bluepad32PairingSnapshotStatus::kFailed; + } snapshot.generation = g_pairing_snapshot.generation + 1; snapshot.completed_clear_pairings_token = g_pairing_snapshot.completed_clear_pairings_token; @@ -1103,6 +1777,7 @@ void recompute_connection_status(); void process_clear_pairings(uint32_t now_ms) { uni_hid_device_t* devices[kSlotCount]{}; + uint8_t device_count = 0; critical_section_enter_blocking(&g_state_lock); const uint32_t request_token = g_clear_pairings_requested_token; @@ -1114,17 +1789,20 @@ void process_clear_pairings(uint32_t now_ms) { g_pairing_window_requested = false; for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { BackendSlot& slot = g_slots[slot_index]; - devices[slot_index] = slot.device; - slot.state = make_neutral_state(); - slot.identity = controller_identity_global(); - slot.device = nullptr; - slot.active = false; - slot.rumble_pending = false; - slot.feedback_pending = false; - slot.feedback_until_ms = 0; - reset_slot_hotkeys(slot); - ++slot.state_generation; - ++slot.connection_generation; + if (slot.device != nullptr) { + g_retired_devices[physical_index_for_device(slot.device)] = + slot.device; + devices[device_count++] = slot.device; + } + if (slot.companion != nullptr) { + g_retired_devices[physical_index_for_device(slot.companion)] = + slot.companion; + devices[device_count++] = slot.companion; + } + release_slot(slot); + g_joycon_gestures[slot_index] = {}; + g_joycon_overrides[slot_index] = {}; + g_joycon_pair_hints[slot_index] = {}; } } critical_section_exit(&g_state_lock); @@ -1138,18 +1816,41 @@ void process_clear_pairings(uint32_t now_ms) { g_pairing_window_open = false; gap_set_bondable_mode(false); sm_set_accepted_stk_generation_methods(0); + const bool proprietary_cleared = uni_switch2_pairing_clear(); uni_bt_del_keys_unsafe(); for (uni_hid_device_t* device : devices) { if (device != nullptr) { +#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE + switch_native_output_detach(device); +#endif +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + haptics_experiment_detach(device); +#endif uni_hid_device_disconnect(device); } } refresh_pairing_snapshot(); + if (!proprietary_cleared) { + stop_background_scan(); + uni_bt_stop_scanning_unsafe(); + uni_bt_allow_incoming_connections(false); + g_connection_policy_state = ConnectionPolicyState::FailedClosed; + critical_section_enter_blocking(&g_state_lock); + g_pairing_snapshot.status = Bluepad32PairingSnapshotStatus::kFailed; + g_clear_pairings_in_progress_token = 0; + g_clear_pairings_requested_token = 0; + g_pairing_window_requested = false; + critical_section_exit(&g_state_lock); + return; + } g_connection_status = ConnectionStatus::Scanning; g_status_led_tick = 0; g_pairing_reset_feedback_deadline_ms = now_ms + kPairingResetFeedbackDurationMs; + if (g_connection_policy_state == ConnectionPolicyState::FailedClosed) { + g_connection_policy_state = ConnectionPolicyState::Uninitialized; + } apply_connection_policy(); critical_section_enter_blocking(&g_state_lock); g_pairing_snapshot.completed_clear_pairings_token = @@ -1160,23 +1861,31 @@ void process_clear_pairings(uint32_t now_ms) { void apply_connection_policy() { + if (g_connection_policy_state == ConnectionPolicyState::FailedClosed) { + return; + } + apply_radio_connection_policy(); const bool free_slot = has_free_slot(); const bool active_controller = has_active_controller(); const bool pairing_open = pairing_window_active_at(btstack_run_loop_get_time_ms()); const bool active_scan = free_slot && (!active_controller || pairing_open); + const bool background_scan = free_slot && !active_scan && + waiting_for_joycon_mate(); const ConnectionPolicyState desired_state = !free_slot ? ConnectionPolicyState::Paused : (active_scan ? ConnectionPolicyState::Open : ConnectionPolicyState::Passive); - if (g_connection_policy_state == desired_state) { + if (g_connection_policy_state == desired_state && + g_background_scan_active == background_scan) { return; } - // Classic inquiry and BLE scanning consume radio time and measurably delay - // active controller HID traffic. Stop them before every policy transition. + // Leave low-duty LE explicitly before the aggregate stop, which otherwise + // does nothing when its own scanning flag is already clear. + stop_background_scan(); uni_bt_stop_scanning_unsafe(); if (!free_slot) { @@ -1185,15 +1894,20 @@ void apply_connection_policy() { return; } - // Passive mode still accepts controller-initiated reconnects without - // running inquiry. Active discovery is reserved for zero-controller idle - // state and the explicit BOOTSEL pairing window. + // Passive mode permits incoming Classic reconnects, with LE discovery + // limited to a remembered opposite half for a ready solo Joy-Con2. uni_bt_allow_incoming_connections(true); if (active_scan) { + uni_bt_le_set_background_scan(false); uni_bt_start_scanning_and_autoconnect_unsafe(); g_connection_policy_state = ConnectionPolicyState::Open; } else { g_connection_policy_state = ConnectionPolicyState::Passive; + if (background_scan) { + uni_bt_le_set_background_scan(true); + uni_bt_le_scan_start(); + g_background_scan_active = true; + } } } @@ -1266,17 +1980,22 @@ void update_status_led() { } } +void apply_joycon_configuration(const ConfigurationServiceSnapshot& configuration); +void process_joycon_gestures(uint32_t now_ms); + void process_configuration_timer(btstack_timer_source_t* timer) { __atomic_add_fetch( &g_configuration_timer_ticks, 1, __ATOMIC_RELAXED); btstack_run_loop_set_timer(timer, kConfigurationPollIntervalMs); btstack_run_loop_add_timer(timer); const uint32_t now_ms = btstack_run_loop_get_time_ms(); + apply_radio_connection_policy(); configuration_service_task_on_storage_core(now_ms); profile_service_task_on_storage_core(now_ms); -#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE ConfigurationServiceSnapshot configuration{}; configuration_service_snapshot(&configuration); + apply_joycon_configuration(configuration); +#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE if (configuration.state == ConfigurationServiceState::kReady) { uint8_t previously_owned = 0; for (uint8_t i = 0; i < kSlotCount; ++i) @@ -1390,10 +2109,12 @@ void seed_native_host_rumble() { void process_rumble_timer(btstack_timer_source_t* timer) { __atomic_add_fetch(&g_rumble_timer_ticks, 1, __ATOMIC_RELAXED); - const uint32_t now_ms = btstack_run_loop_get_time_ms(); + uint32_t now_ms = btstack_run_loop_get_time_ms(); process_clear_pairings(now_ms); process_pairing_snapshot_request(); + process_joycon_gestures(now_ms); + now_ms = btstack_run_loop_get_time_ms(); const bool wake_identity_ready = switch2_wake_ready_for_connections(); if (g_connection_policy_state == @@ -1412,12 +2133,15 @@ void process_rumble_timer(btstack_timer_source_t* timer) { #endif for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { + drain_switch2_ingress(slot_index, now_ms); RumbleEnvelope envelope{}; FeedbackEnvelope feedback{}; ProfileFeedbackEnvelope profile_feedback{}; uni_hid_device_t* device = nullptr; uni_hid_device_t* profile_lighting_device = nullptr; uint32_t profile_lighting_generation = 0; + uni_hid_device_t* companion = nullptr; + uint32_t dispatch_generation = 0; bool profile_lighting_dispatch = false; bool profile_lighting_restore = false; bool profile_rumble_dispatch = false; @@ -1571,6 +2295,8 @@ void process_rumble_timer(btstack_timer_source_t* timer) { device = slot.device; } } + companion = slot.companion; + dispatch_generation = slot.connection_generation; critical_section_exit(&g_state_lock); #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE if (host_dispatch && switch_native_output_owns(device)) @@ -1585,39 +2311,45 @@ void process_rumble_timer(btstack_timer_source_t* timer) { } #endif - if (profile_lighting_restore && - lighting_target_is_current( - slot_index, profile_lighting_generation, - profile_lighting_device)) { - apply_slot_lighting(slot_index, - profile_lighting_device); + uni_hid_device_t* lighting_targets[] = { + profile_lighting_device, companion}; + for (uni_hid_device_t* target : lighting_targets) { + if (!lighting_target_is_current( + slot_index, profile_lighting_generation, target)) { + continue; + } + if (profile_lighting_restore) { + apply_slot_lighting(slot_index, target); + } + if (profile_lighting_dispatch) { + apply_profile_lighting( + profile_feedback.active_profile_number, target); + } } - if (profile_lighting_dispatch && - lighting_target_is_current( - slot_index, profile_lighting_generation, - profile_lighting_device)) { - apply_profile_lighting( - profile_feedback.active_profile_number, - profile_lighting_device); - } - if (profile_rumble_dispatch && device != nullptr && - device->report_parser.play_dual_rumble != nullptr) { - __atomic_add_fetch( - &g_rumble_dispatches, 1, __ATOMIC_RELAXED); - dispatch_rumble( - device, kProfileFeedbackPhaseDurationMs, - kProfileFeedbackWeakMagnitude, kProfileFeedbackStrongMagnitude); - } else if (feedback_dispatch) { - __atomic_add_fetch( - &g_rumble_dispatches, 1, __ATOMIC_RELAXED); - dispatch_rumble( - device, feedback.duration_ms, - feedback.weak_magnitude, feedback.strong_magnitude); - } else if (host_dispatch && - device->report_parser.play_dual_rumble != nullptr) { - __atomic_add_fetch( - &g_rumble_dispatches, 1, __ATOMIC_RELAXED); - dispatch_host_rumble(device, envelope.duration_ms, envelope.rumble); + uni_hid_device_t* rumble_targets[] = {device, companion}; + for (uni_hid_device_t* target : rumble_targets) { + if (!lighting_target_is_current( + slot_index, dispatch_generation, target) || + target->report_parser.play_dual_rumble == nullptr) { + continue; + } + if (profile_rumble_dispatch) { + __atomic_add_fetch( + &g_rumble_dispatches, 1, __ATOMIC_RELAXED); + dispatch_rumble( + target, kProfileFeedbackPhaseDurationMs, + kProfileFeedbackWeakMagnitude, kProfileFeedbackStrongMagnitude); + } else if (feedback_dispatch) { + __atomic_add_fetch( + &g_rumble_dispatches, 1, __ATOMIC_RELAXED); + dispatch_rumble( + target, feedback.duration_ms, + feedback.weak_magnitude, feedback.strong_magnitude); + } else if (host_dispatch) { + __atomic_add_fetch( + &g_rumble_dispatches, 1, __ATOMIC_RELAXED); + dispatch_host_rumble(target, envelope.duration_ms, envelope.rumble); + } } } @@ -1634,6 +2366,367 @@ void recompute_connection_status() { apply_connection_policy(); } +void forget_joycon_pair_hint(uni_hid_device_t* device) { + const int physical_index = physical_index_for_device(device); + if (physical_index < 0) return; + for (JoyConPairHint& hint : g_joycon_pair_hints) { + if (hint.mate == device) hint = {}; + } + g_joycon_pair_hints[physical_index] = {}; +} + +// Replacing an explicit association does not revoke its former partner's solo +// choice. Only the new current association participates in disconnect reset. +void set_joycon_override(uni_hid_device_t* left, uni_hid_device_t* right, + JoyConGroupingOverride mode) { + uni_hid_device_t* devices[] = {left, right}; + for (uni_hid_device_t* device : devices) { + for (JoyConConnectionOverride& current : g_joycon_overrides) { + if (current.mate == device) current.mate = nullptr; + } + } + g_joycon_overrides[physical_index_for_device(left)] = {mode, right}; + g_joycon_overrides[physical_index_for_device(right)] = {mode, left}; +} + +// Caller holds the state lock; used for disconnect and physical index reuse. +void reset_joycon_connection(uni_hid_device_t* device) { + const int index = physical_index_for_device(device); + if (index < 0) return; + bool changed = g_joycon_overrides[index].mode != + JoyConGroupingOverride::Default; + for (JoyConConnectionOverride& current : g_joycon_overrides) { + if (current.mate == device) { + current = {}; + changed = true; + } + } + g_joycon_overrides[index] = {}; + if (changed) g_joycon_reconcile_requested = true; + block_joycon_gesture(g_joycon_gestures[index].participants); + g_joycon_gestures[index] = {}; + for (JoyConGesture& gesture : g_joycon_gestures) { + gesture.participants &= ~(1u << index); + } + forget_joycon_pair_hint(device); +} + +bool joycon_gesture_mature(uni_hid_device_t* first, + uni_hid_device_t* second, bool joining, + uint32_t now_ms) { + const int first_index = physical_index_for_device(first); + const int second_index = physical_index_for_device(second); + if (first_index < 0 || second_index < 0 || first_index == second_index || + joycon_side(first) == 0 || joycon_side(first) != -joycon_side(second)) { + return false; + } + const uint8_t participants = (1u << first_index) | (1u << second_index); + const JoyConGesture& a = g_joycon_gestures[first_index]; + const JoyConGesture& b = g_joycon_gestures[second_index]; + if (a.device != first || b.device != second || + a.started_ms != b.started_ms) return false; + const int indices[] = {first_index, second_index}; + for (int index : indices) { + const JoyConGesture& gesture = g_joycon_gestures[index]; + if (!joycon_gesture_live(index) || + gesture.participants != participants || + gesture.joining != joining || !gesture.held || + now_ms - gesture.last_report_ms > kJoyConGestureFreshMs || + static_cast(gesture.last_report_ms - gesture.started_ms) < + static_cast(kJoyConGestureHoldMs)) return false; + } + const int a_slot = slot_for_device(first); + const int b_slot = slot_for_device(second); + return joining + ? a_slot != b_slot && g_slots[a_slot].companion == nullptr && + g_slots[b_slot].companion == nullptr + : a_slot == b_slot && g_slots[a_slot].companion != nullptr; +} + +// Caller holds the state lock. Prefer the last live pair's exact members, +// otherwise preserve the existing first-ready / lowest-slot admission order. +int joycon_partner_slot(uni_hid_device_t* device, int slot_index) { + const int side = joycon_side(device); + if (side == 0 || !joycon_default_pairing_allowed(device)) return -1; + const auto& hint = g_joycon_pair_hints[physical_index_for_device(device)]; + int first = -1; + for (uint8_t index = 0; index < kSlotCount; ++index) { + const BackendSlot& candidate = g_slots[index]; + if (index == slot_index || !candidate.active || + candidate.companion != nullptr || + !joycon_default_pairing_allowed(candidate.device) || + joycon_side(candidate.device) != -side) continue; + if (candidate.device == hint.mate) return index; + const auto& candidate_hint = + g_joycon_pair_hints[physical_index_for_device(candidate.device)]; + if (candidate_hint.mate != nullptr && candidate_hint.mate != device) continue; + if (first < 0) first = index; + } + return first; +} + +void stop_joycon_output(uni_hid_device_t* device) { + if (device->report_parser.play_dual_rumble != nullptr) { + dispatch_rumble(device, 0, 0, 0); + } +} + +// Core 1 only; shared by ready admission, saved defaults and explicit gestures. +// Pair enrollment is atomic and idempotent, and always precedes topology +// changes with no cross-core input lock held during storage I/O. +bool merge_joycon_slots(int owner_index, int joining_index, + uni_hid_device_t* joining_device, + bool gesture = false) { + critical_section_enter_blocking(&g_state_lock); + BackendSlot& owner = g_slots[owner_index]; + BackendSlot& joining = g_slots[joining_index]; + uni_hid_device_t* const owner_device = owner.device; + const uint32_t owner_generation = owner.connection_generation; + const uint32_t joining_generation = joining.connection_generation; + const bool joining_active = joining.active; + const int side = joycon_side(joining_device); + const bool admission = gesture + ? joining.active && joycon_gesture_mature( + owner_device, joining_device, true, btstack_run_loop_get_time_ms()) + : joycon_default_pairing_allowed(owner_device) && + joycon_default_pairing_allowed(joining_device); + const bool eligible = admission && + owner.active && owner.companion == nullptr && + joining.companion == nullptr && side != 0 && + joycon_side(owner_device) == -side && + reserve_device_slot(joining_device) == joining_index; + const ControllerIdentity owner_identity = identity_for_device(owner_device); + const ControllerIdentity joining_identity = identity_for_device(joining_device); + critical_section_exit(&g_state_lock); + ControllerIdentity pair_identity{}; + if (!eligible || + !controller_identity_make_joycon_pair( + side < 0 ? joining_identity : owner_identity, + side < 0 ? owner_identity : joining_identity, &pair_identity) || + !profile_service_observe_joycon_pair_on_storage_core(pair_identity)) { + return false; + } + + critical_section_enter_blocking(&g_state_lock); + const bool still_admitted = gesture + ? joycon_gesture_mature( + owner_device, joining_device, true, btstack_run_loop_get_time_ms()) + : joycon_default_pairing_allowed(owner_device) && + joycon_default_pairing_allowed(joining_device); + if (!still_admitted || + !owner.active || owner.device != owner_device || + owner.companion != nullptr || + owner.connection_generation != owner_generation || + joining.active != joining_active || joining.companion != nullptr || + joining.connection_generation != joining_generation || + reserve_device_slot(joining_device) != joining_index) { + critical_section_exit(&g_state_lock); + return false; + } + invalidate_slot(owner); + invalidate_slot(joining); + // Clear both parser epochs and local motor feedback before publishing + // either the new pair or its neutral retired output. + stop_joycon_output(owner_device); + stop_joycon_output(joining_device); + if (side < 0) { + owner.companion = owner.device; + owner.companion_gamepad = owner.gamepad; + owner.companion_extra_buttons = owner.extra_buttons; + owner.device = joining_device; + owner.gamepad = joining.gamepad; + owner.extra_buttons = joining.extra_buttons; + } else { + owner.companion = joining_device; + owner.companion_gamepad = joining.gamepad; + owner.companion_extra_buttons = joining.extra_buttons; + } + owner.identity = pair_identity; + refresh_topology_input(owner); + joining.identity = controller_identity_global(); + joining.device = nullptr; + joining.gamepad = {}; + joining.extra_buttons = 0; + joining.active = false; + forget_joycon_pair_hint(owner.device); + forget_joycon_pair_hint(owner.companion); + if (gesture) { + set_joycon_override(owner.device, owner.companion, + JoyConGroupingOverride::Paired); + queue_local_feedback(owner, kJoyConGestureFeedbackMs, + kProfileFeedbackWeakMagnitude, + kProfileFeedbackStrongMagnitude); + } + g_joycon_pair_hints[physical_index_for_device(owner.device)] = + {owner.companion, static_cast(owner_index)}; + g_joycon_pair_hints[physical_index_for_device(owner.companion)] = + {owner.device, static_cast(owner_index)}; + critical_section_exit(&g_state_lock); + apply_slot_lighting(static_cast(owner_index), owner.device); + apply_slot_lighting(static_cast(owner_index), owner.companion); + return true; +} + +bool split_joycon_slot(uint8_t owner_index, bool gesture = false) { + critical_section_enter_blocking(&g_state_lock); + BackendSlot& owner = g_slots[owner_index]; + const int physical_index = physical_index_for_device(owner.device); + if (!owner.active || owner.companion == nullptr || physical_index < 0 || + (!gesture && g_joycon_overrides[physical_index].mode == + JoyConGroupingOverride::Paired) || + (gesture && !joycon_gesture_mature( + owner.device, owner.companion, false, + btstack_run_loop_get_time_ms()))) { + critical_section_exit(&g_state_lock); + return false; + } + // Keep the pair's left member at its existing player index. Prefer the + // right member's physical index, falling back to the lowest free output. + int right_index = physical_index_for_device(owner.companion); + if (right_index < 0 || g_slots[right_index].device != nullptr) { + right_index = -1; + for (uint8_t index = 0; index < kSlotCount; ++index) { + if (g_slots[index].device == nullptr) { + right_index = index; + break; + } + } + } + if (right_index < 0) { + critical_section_exit(&g_state_lock); + return false; + } + BackendSlot& right = g_slots[right_index]; + ControllerIdentity left_identity{}; + ControllerIdentity right_identity{}; + // The enrolled owner is authoritative even if a later identity-resolution + // event has temporarily cleared a member's transport mapping. + if (!controller_identity_joycon_pair_members( + owner.identity, &left_identity, &right_identity)) { + critical_section_exit(&g_state_lock); + return false; + } + invalidate_slot(owner); + invalidate_slot(right); + stop_joycon_output(owner.device); + stop_joycon_output(owner.companion); + right.device = owner.companion; + right.identity = right_identity; + right.gamepad = owner.companion_gamepad; + right.extra_buttons = owner.companion_extra_buttons; + right.active = true; + owner.identity = left_identity; + owner.companion = nullptr; + owner.companion_gamepad = {}; + owner.companion_extra_buttons = 0; + refresh_topology_input(owner); + refresh_topology_input(right); + if (gesture) { + set_joycon_override(owner.device, right.device, + JoyConGroupingOverride::Individual); + queue_local_feedback(owner, kJoyConGestureFeedbackMs, + kProfileFeedbackWeakMagnitude, + kProfileFeedbackStrongMagnitude); + queue_local_feedback(right, kJoyConGestureFeedbackMs, + kProfileFeedbackWeakMagnitude, + kProfileFeedbackStrongMagnitude); + } + critical_section_exit(&g_state_lock); + apply_slot_lighting(owner_index, owner.device); + apply_slot_lighting(static_cast(right_index), right.device); + return true; +} + +void process_joycon_gestures(uint32_t now_ms) { + for (uint8_t index = 0; index < kSlotCount; ++index) { + now_ms = btstack_run_loop_get_time_ms(); + critical_section_enter_blocking(&g_state_lock); + refresh_joycon_gestures(now_ms); + const JoyConGesture& gesture = g_joycon_gestures[index]; + if (gesture.blocked || joycon_side(gesture.device) >= 0) { + critical_section_exit(&g_state_lock); + continue; + } + uni_hid_device_t* right = nullptr; + for (uint8_t mate = 0; mate < kSlotCount; ++mate) { + if (mate != index && (gesture.participants & (1u << mate))) { + right = g_joycon_gestures[mate].device; + } + } + const bool joining = gesture.joining; + const int owner_slot = slot_for_device(gesture.device); + const int right_slot = slot_for_device(right); + const bool mature = joycon_gesture_mature( + gesture.device, right, joining, now_ms); + if (mature) block_joycon_gesture(gesture.participants); + critical_section_exit(&g_state_lock); + if (!mature) continue; + // Latch success AND failure before any enrollment I/O. A failed seed + // must not retry at the timer cadence or undo either participant. + // Player-slot ownership is independent of physical handedness. + // Keep the lower occupied slot, even when the left half owns the higher one. + const bool keep_left_slot = owner_slot < right_slot; + const bool changed = joining + ? merge_joycon_slots( + keep_left_slot ? owner_slot : right_slot, + keep_left_slot ? right_slot : owner_slot, + keep_left_slot ? right : gesture.device, true) + : split_joycon_slot(static_cast(owner_slot), true); + if (changed) recompute_connection_status(); + } +} + +void apply_joycon_configuration(const ConfigurationServiceSnapshot& configuration) { + if (configuration.state != ConfigurationServiceState::kReady) return; + const JoyConMode requested = configuration.configuration.joycon_mode; + if (requested != g_joycon_mode) { + critical_section_enter_blocking(&g_state_lock); + for (uint8_t index = 0; index < kSlotCount; ++index) { + g_joycon_overrides[index] = {}; + block_joycon_gesture(g_joycon_gestures[index].participants); + } + g_joycon_mode = requested; + g_joycon_reconcile_requested = true; + critical_section_exit(&g_state_lock); + } + if (!g_joycon_reconcile_requested) return; + g_joycon_reconcile_requested = false; + if (g_joycon_mode == JoyConMode::kIndividual) { + for (uint8_t index = 0; index < kSlotCount; ++index) { + split_joycon_slot(index); + } + } else { + uint8_t attempted = 0; + for (uint8_t index = 0; index < kSlotCount; ++index) { + critical_section_enter_blocking(&g_state_lock); + const BackendSlot& slot = g_slots[index]; + int partner = slot.active && slot.companion == nullptr && + !(attempted & (1u << index)) + ? joycon_partner_slot(slot.device, index) : -1; + if (partner < 0 || (attempted & (1u << partner))) { + critical_section_exit(&g_state_lock); + continue; + } + int owner = index; + int joining = partner; + const auto& hint = + g_joycon_pair_hints[physical_index_for_device(slot.device)]; + if (hint.mate == g_slots[partner].device && hint.owner_slot == partner) { + owner = partner; + joining = index; + } + uni_hid_device_t* joining_device = g_slots[joining].device; + attempted |= (1u << index) | (1u << partner); + critical_section_exit(&g_state_lock); + // Failed seeds/invalid identities leave both live solos intact. + // Retry only on a mode change or fresh identity/ready event, never + // at the 50 ms poll cadence or for unrelated configuration edits. + merge_joycon_slots(owner, joining, joining_device); + } + } + recompute_connection_status(); +} + void platform_init(int argc, const char** argv) { (void)argc; (void)argv; @@ -1650,7 +2743,6 @@ void platform_on_init_complete() { hci_add_event_handler(&g_pairing_event_callback); switch2_wake_initialize(); refresh_pairing_snapshot(); - // Keep Bluepad32 autoconnect active whenever at least one slot is free. btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer); btstack_run_loop_set_timer(&g_rumble_timer, kRumblePollIntervalMs); btstack_run_loop_add_timer(&g_rumble_timer); @@ -1660,6 +2752,9 @@ void platform_on_init_complete() { &g_configuration_timer, kConfigurationPollIntervalMs); btstack_run_loop_add_timer(&g_configuration_timer); __atomic_store_n(&g_initialization_stage, 6, __ATOMIC_RELEASE); + ConfigurationServiceSnapshot configuration{}; + configuration_service_snapshot(&configuration); + apply_joycon_configuration(configuration); if (switch2_wake_ready_for_connections()) { recompute_connection_status(); } @@ -1667,12 +2762,26 @@ void platform_on_init_complete() { uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, uint16_t cod, uint8_t rssi) { - (void)addr; (void)name; (void)cod; (void)rssi; - return has_free_slot() && - g_connection_policy_state == ConnectionPolicyState::Open + if (!has_free_slot()) { + return UNI_ERROR_IGNORE_DEVICE; + } + if (g_connection_policy_state == ConnectionPolicyState::Open) { + return UNI_ERROR_SUCCESS; + } + if (g_connection_policy_state != ConnectionPolicyState::Passive) { + return UNI_ERROR_IGNORE_DEVICE; + } + const uni_hid_device_t* candidate = + uni_hid_device_get_instance_for_address(addr); + const int side = joycon_side(candidate); + uint8_t address_type = BD_ADDR_TYPE_UNKNOWN; + return side != 0 && waiting_for_joycon_mate(side) && + uni_hid_parser_switch2_identity_address_type( + candidate, &address_type) && + uni_switch2_pairing_known(address_type, addr) ? UNI_ERROR_SUCCESS : UNI_ERROR_IGNORE_DEVICE; } @@ -1686,117 +2795,145 @@ void platform_on_device_connected(uni_hid_device_t* device) { uni_hid_device_disconnect(device); return; } - - const int slot_index = slot_for_device(device); - if (slot_index < 0) { - return; - } - const ControllerIdentity connection_identity = - identity_for_device(device); - - bool tracked_connection = false; + const ControllerIdentity connection_identity = identity_for_device(device); critical_section_enter_blocking(&g_state_lock); - BackendSlot& slot = g_slots[slot_index]; - if (!slot.active && slot.device == nullptr) { - slot.device = device; - slot.rumble_pending = false; - reset_slot_hotkeys(slot); - tracked_connection = true; - } else { - tracked_connection = slot.device == device; + const int physical_index = physical_index_for_device(device); + if (physical_index >= 0) { + g_retired_devices[physical_index] = nullptr; + g_switch2_interval_requests[physical_index] = {}; + if (slot_for_device(device) < 0) reset_joycon_connection(device); } - if (tracked_connection) { - slot.identity = connection_identity; + const int slot_index = reserve_device_slot(device); + if (slot_index >= 0) { + BackendSlot& slot = g_slots[slot_index]; + if (slot.device == nullptr) { + slot.device = device; + slot.identity = connection_identity; + slot.rumble_pending = false; + reset_slot_hotkeys(slot); + } } critical_section_exit(&g_state_lock); - - if (tracked_connection) { + if (slot_index >= 0) { recompute_connection_status(); + } else { + uni_hid_device_disconnect(device); } } void platform_on_device_disconnected(uni_hid_device_t* device) { + const int slot_index = slot_for_device(device); + if (slot_index < 0) { + return; + } #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE switch_native_output_detach(device); #endif #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT haptics_experiment_detach(device); #endif - const int slot_index = slot_for_device(device); - if (slot_index < 0) { - return; - } - - bool disconnected_tracked_device = false; + uni_hid_device_t* survivor = nullptr; + ControllerIdentity survivor_identity{}; critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; - if (slot.device == device) { - slot.state = make_neutral_state(); - slot.identity = controller_identity_global(); - slot.device = nullptr; - slot.active = false; - slot.rumble_pending = false; - reset_slot_hotkeys(slot); - ++slot.state_generation; - ++slot.connection_generation; - disconnected_tracked_device = true; + g_retired_devices[physical_index_for_device(device)] = device; + reset_joycon_connection(device); + if (slot.companion != nullptr) { + invalidate_slot(slot); + if (slot.device == device) { + slot.device = slot.companion; + slot.gamepad = slot.companion_gamepad; + slot.extra_buttons = slot.companion_extra_buttons; + } + slot.companion = nullptr; + slot.companion_gamepad = {}; + slot.companion_extra_buttons = 0; + survivor = slot.device; + slot.identity = identity_for_device(survivor); + survivor_identity = slot.identity; + refresh_topology_input(slot); + } else { + release_slot(slot); } critical_section_exit(&g_state_lock); clear_ble_identity_for_device(device); - - if (disconnected_tracked_device) { - // Re-evaluate from scratch: resume discovery only after the final - // active controller disconnects; otherwise keep passive incoming - // reconnect support without inquiry-induced latency. - g_connection_policy_state = ConnectionPolicyState::Uninitialized; - recompute_connection_status(); + if (survivor != nullptr) { + if (survivor->report_parser.play_dual_rumble != nullptr) { + dispatch_rumble(survivor, 0, 0, 0); + } + apply_slot_lighting(static_cast(slot_index), survivor); + if (survivor_identity.stable) { + profile_service_observe_identity_on_storage_core(survivor_identity); + } } + // Losing a half frees transport capacity, not another logical player. + if (g_connection_policy_state != ConnectionPolicyState::FailedClosed) { + g_connection_policy_state = ConnectionPolicyState::Uninitialized; + } + recompute_connection_status(); } uni_error_t platform_on_device_ready(uni_hid_device_t* device) { if (device == nullptr || !uni_hid_device_is_gamepad(device)) { return UNI_ERROR_INVALID_CONTROLLER; } - - const int slot_index = slot_for_device(device); - if (slot_index < 0) { + if (g_connection_policy_state == ConnectionPolicyState::FailedClosed) { return UNI_ERROR_NO_SLOTS; } - const ControllerIdentity connection_identity = - identity_for_device(device); - bool occupied_mismatch = false; bool became_active = false; + bool paired = false; uint32_t lighting_generation = 0; + uni_hid_device_t* owner = device; + uni_hid_device_t* companion = nullptr; + ControllerIdentity connection_identity = identity_for_device(device); critical_section_enter_blocking(&g_state_lock); - BackendSlot& slot = g_slots[slot_index]; - occupied_mismatch = slot.device != nullptr && slot.device != device; - if (!occupied_mismatch) { - slot.identity = connection_identity; - slot.device = device; - if (!slot.active) { - slot.state = make_neutral_state(); - slot.active = true; - slot.rumble_pending = false; - reset_slot_hotkeys(slot); - ++slot.state_generation; - became_active = true; - lighting_generation = slot.connection_generation; - } - } - critical_section_exit(&g_state_lock); - - if (occupied_mismatch) { + int slot_index = reserve_device_slot(device); + if (slot_index < 0) { + critical_section_exit(&g_state_lock); return UNI_ERROR_NO_SLOTS; } + BackendSlot& pending = g_slots[slot_index]; + if (!pending.active) { + const int partner_index = joycon_partner_slot(device, slot_index); + if (partner_index >= 0) { + critical_section_exit(&g_state_lock); + if (!merge_joycon_slots(partner_index, slot_index, device)) { + return UNI_ERROR_INIT_FAILED; + } + critical_section_enter_blocking(&g_state_lock); + slot_index = partner_index; + paired = true; + } else { + pending.identity = connection_identity; + pending.device = device; + pending.state = make_neutral_state(); + pending.active = true; + pending.rumble_pending = false; + reset_slot_hotkeys(pending); + ++pending.state_generation; + } + became_active = true; + } + const BackendSlot& current = g_slots[slot_index]; + owner = current.device; + companion = current.companion; + lighting_generation = current.connection_generation; + connection_identity = current.identity; + critical_section_exit(&g_state_lock); if (became_active) { + apply_radio_connection_policy(); #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE - switch_native_output_attach(static_cast(slot_index), - lighting_generation, device, connection_identity); + if (!paired) { + switch_native_output_attach(static_cast(slot_index), + lighting_generation, device, connection_identity); + } #endif #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT - haptics_experiment_attach( - static_cast(slot_index), lighting_generation, device); + if (!paired && !uni_hid_parser_switch2_is_ble_device(device)) { + haptics_experiment_attach( + static_cast(slot_index), lighting_generation, device); + } #ifdef SWITCH_PICO_HD_RUMBLE if (connection_identity.vendor_id == 0x054c && (connection_identity.product_id == 0x0ce6 || @@ -1805,16 +2942,21 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { } #endif #endif - if (lighting_target_is_current( - static_cast(slot_index), - lighting_generation, device)) { - apply_slot_lighting( - static_cast(slot_index), device); + if (!paired && lighting_target_is_current( + static_cast(slot_index), lighting_generation, owner)) { + apply_slot_lighting(static_cast(slot_index), owner); + if (companion != nullptr) { + apply_slot_lighting(static_cast(slot_index), companion); + } } - if (connection_identity.stable) { + if (!paired && connection_identity.stable) { profile_service_observe_identity_on_storage_core( connection_identity); } + if (!paired && joycon_side(device) != 0 && + g_joycon_mode == JoyConMode::kPaired) { + g_joycon_reconcile_requested = true; + } } @@ -1831,22 +2973,41 @@ void platform_on_controller_data(uni_hid_device_t* device, } __atomic_add_fetch(&g_controller_reports, 1, __ATOMIC_RELAXED); - uni_gamepad_t gamepad = controller->gamepad; - const uint16_t pre_hotkey_button_mask = - logical_button_mask(gamepad); + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (!slot.active) { + critical_section_exit(&g_state_lock); + return; + } + const uint8_t extras = uni_hid_parser_switch2_extra_buttons(device); + if (slot.companion == device) { + slot.companion_gamepad = controller->gamepad; + slot.companion_extra_buttons = extras; + } else { + slot.gamepad = controller->gamepad; + slot.extra_buttons = extras; + } + observe_joycon_gesture( + device, controller->gamepad, btstack_run_loop_get_time_ms()); + const uni_gamepad_t gamepad = logical_gamepad(slot); + uni_hid_device_t* owner = slot.device; + const bool fresh_motion = + slot.companion == nullptr || slot.companion == device; + const uint8_t merged_extras = + slot.extra_buttons | slot.companion_extra_buttons; + critical_section_exit(&g_state_lock); + const uint16_t pre_hotkey_button_mask = logical_button_mask(gamepad); if (wake_chord_rising_edge( - static_cast(slot_index), device, - pre_hotkey_button_mask)) { + static_cast(slot_index), owner, pre_hotkey_button_mask)) { switch2_wake_request(); } const HotkeyDecision hotkeys = update_controller_hotkeys( - static_cast(slot_index), device); - const uint16_t output_button_mask = pre_hotkey_button_mask; + static_cast(slot_index), owner); + ControllerState state = map_gamepad( + gamepad, hotkeys.motion_enabled && fresh_motion, pre_hotkey_button_mask); + state.extra_buttons = merged_extras; publish_device_state( - static_cast(slot_index), device, - pre_hotkey_button_mask, - map_gamepad( - gamepad, hotkeys.motion_enabled, output_button_mask)); + static_cast(slot_index), owner, pre_hotkey_button_mask, state); } const uni_property_t* platform_get_property(uni_property_idx_t index) { @@ -1892,6 +3053,12 @@ uni_platform* get_platform() { __atomic_store_n(&g_initialization_stage, 2, __ATOMIC_RELEASE); configuration_service_initialize_on_storage_core(); profile_service_initialize_on_storage_core(); + ConfigurationServiceSnapshot configuration{}; + configuration_service_snapshot(&configuration); + if (configuration.state == ConfigurationServiceState::kReady) { + // Load before uni_init can deliver even the first ready callback. + g_joycon_mode = configuration.configuration.joycon_mode; + } __atomic_store_n(&g_initialization_stage, 3, __ATOMIC_RELEASE); if (cyw43_arch_init() != 0) { halt_wireless_backend(); @@ -1914,6 +3081,24 @@ uni_platform* get_platform() { } // namespace +extern "C" bool switch_pico_switch2_pairing_allowed(void) { + return g_initialized && + pairing_window_active_at(btstack_run_loop_get_time_ms()); +} + +extern "C" void __real_sm_request_pairing(hci_con_handle_t handle); +extern "C" void __wrap_sm_request_pairing(hci_con_handle_t handle) { + uni_hid_device_t* device = + uni_hid_device_get_instance_for_connection_handle(handle); + if (uni_hid_parser_switch2_is_ble_device(device)) { + // GATT's implicit authentication retry must not enter standard SMP for + // this proprietary protocol. Retain storage until HCI teardown. + uni_hid_device_disconnect(device); + return; + } + __real_sm_request_pairing(handle); +} + #if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE) extern "C" bool uni_platform_on_l2cap_can_send_now( uni_hid_device_t* device, uint16_t cid) { @@ -2004,7 +3189,12 @@ void bluepad32_input_backend_init() { g_consumed_generation[slot_index] = 0; g_last_snapshot_generation[slot_index] = 0; g_ble_identity_mappings[slot_index] = {}; + g_joycon_pair_hints[slot_index] = {}; + g_joycon_gestures[slot_index] = {}; + g_joycon_overrides[slot_index] = {}; } + g_joycon_mode = JoyConMode::kPaired; + g_joycon_reconcile_requested = false; g_pairing_window_requested = false; g_pairing_snapshot_requested = false; g_pairing_snapshot = {}; @@ -2015,6 +3205,7 @@ void bluepad32_input_backend_init() { g_next_clear_pairings_request_token = 1; g_connection_status = ConnectionStatus::Initializing; g_connection_policy_state = ConnectionPolicyState::Uninitialized; + g_background_scan_active = false; g_pairing_window_deadline_ms = 0; g_pairing_window_duration_ms = kDefaultPairingWindowDurationMs; @@ -2088,8 +3279,9 @@ void bluepad32_input_backend_request_pairing_snapshot() { critical_section_enter_blocking(&g_state_lock); g_pairing_snapshot_requested = true; - g_pairing_snapshot.status = - Bluepad32PairingSnapshotStatus::kPending; + if (g_pairing_snapshot.status != Bluepad32PairingSnapshotStatus::kFailed) { + g_pairing_snapshot.status = Bluepad32PairingSnapshotStatus::kPending; + } critical_section_exit(&g_state_lock); } @@ -2128,6 +3320,9 @@ void bluepad32_input_backend_diagnostics( __atomic_load_n(&g_local_feedback_requests, __ATOMIC_RELAXED); out->rumble_dispatches = __atomic_load_n(&g_rumble_dispatches, __ATOMIC_RELAXED); + out->switch2_ingress_drops = + __atomic_load_n(&g_switch2_ingress_drops, __ATOMIC_RELAXED); + out->switch2_output_drops = uni_hid_parser_switch2_haptics_dropped(); critical_section_enter_blocking(&g_state_lock); for (const BackendSlot& slot : g_slots) { @@ -2142,7 +3337,7 @@ void bluepad32_input_backend_diagnostics( slot.pending_profile_feedback_count != 0) { ++out->feedback_pending_slots; } - if (slot.rumble_pending) { + if (slot.rumble_pending || slot.switch2_ingress.count != 0) { ++out->rumble_pending_slots; } } @@ -2201,6 +3396,32 @@ void bluepad32_input_backend_playtest_snapshot( (slot.device->report_parser.set_lightbar_color != nullptr ? 2u : 0u) | (slot.device->report_parser.set_player_leds != nullptr ? 4u : 0u) | (slot.state.motion_sample_count != 0 ? 8u : 0u); + if (slot.active) { + const int side = joycon_side(slot.device); + if (side != 0) { + out->controller_layout = slot.companion != nullptr + ? Bluepad32ControllerLayout::kJoyCon2MergedPair + : side < 0 + ? Bluepad32ControllerLayout::kJoyCon2LeftSolo + : Bluepad32ControllerLayout::kJoyCon2RightSolo; + } 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; + break; + case CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK: + case CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL: + out->controller_layout = + Bluepad32ControllerLayout::kWiiNunchuk; + break; + default: + break; + } + } + } } critical_section_exit(&g_state_lock); } @@ -2252,16 +3473,47 @@ void bluepad32_input_backend_queue_rumble( bool native_candidate = false; #endif const uint16_t duration_ms = host_rumble_duration_ms(); + const uint32_t received_ms = btstack_run_loop_get_time_ms(); critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; if (slot.active && slot.device != nullptr) { + if (uni_hid_parser_switch2_is_ble_device(slot.device)) { + Switch2Ingress& ingress = slot.switch2_ingress; + const uint8_t host_mode = switch2_host_mode(); + if (ingress.host_mode != host_mode) { + clear_switch2_ingress(slot); + ingress.host_mode = host_mode; + } + if (switch2_host_stop(rumble)) { + // Host stop is a barrier, not a local-feedback cancellation. + ingress.head = 0; + ingress.count = 0; + } else if (ingress.count == kSwitch2IngressCapacity) { + ingress.head = (ingress.head + 1u) % kSwitch2IngressCapacity; + --ingress.count; + __atomic_add_fetch(&g_switch2_ingress_drops, 1, __ATOMIC_RELAXED); + } + Switch2HostCommand& command = + ingress.commands[(ingress.head + ingress.count) % kSwitch2IngressCapacity]; + command = {}; + command.envelope.slot = slot_index; + command.envelope.connection_generation = slot.connection_generation; + command.envelope.rumble = rumble; + command.envelope.duration_ms = duration_ms; + command.envelope.received_ms = received_ms; + command.generation = ingress.generation; + ++ingress.count; + __atomic_add_fetch(&g_host_rumble_requests, 1, __ATOMIC_RELAXED); + critical_section_exit(&g_state_lock); + return; + } #if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE) native_generation = slot.connection_generation; native_candidate = true; #endif const RumbleEnvelope envelope{ slot_index, slot.connection_generation, rumble, - duration_ms + duration_ms, received_ms #if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE) , received_us #endif diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index dc37634..b56cbd8 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -20,6 +20,7 @@ enum class Bluepad32PairingTransport : uint8_t { enum class Bluepad32PairingSnapshotStatus : uint8_t { kReady = 0, kPending = 1, + kFailed = 2, }; struct Bluepad32PairingRecord { @@ -63,6 +64,15 @@ struct Bluepad32SlotSnapshot { ControllerState state; }; +enum class Bluepad32ControllerLayout : uint8_t { + kUnspecified = 0, + kJoyCon2LeftSolo = 1, + kJoyCon2RightSolo = 2, + kJoyCon2MergedPair = 3, + kWiiRemote = 4, + kWiiNunchuk = 5, +}; + // Side-effect-free raw input snapshot for management telemetry. Unlike the // report-path snapshot, reading this does not consume motion samples. struct Bluepad32PlaytestSnapshot { @@ -73,6 +83,8 @@ struct Bluepad32PlaytestSnapshot { uint16_t physical_button_mask = 0; uint8_t battery = 0; uint8_t capabilities = 0; + Bluepad32ControllerLayout controller_layout = + Bluepad32ControllerLayout::kUnspecified; ControllerState state{}; }; @@ -88,6 +100,8 @@ struct Bluepad32BackendDiagnostics { uint8_t rumble_capable_slots; uint8_t feedback_pending_slots; uint8_t rumble_pending_slots; + uint32_t switch2_ingress_drops; + uint32_t switch2_output_drops; }; @@ -95,6 +109,9 @@ struct Bluepad32BackendDiagnostics { void bluepad32_input_backend_init(); void bluepad32_input_backend_start(); void bluepad32_input_backend_open_pairing_window(); +// Core 1 parser admission gate for fresh proprietary Switch 2 pairing; this +// never opens a pairing window or changes the bounded connection policy. +extern "C" bool switch_pico_switch2_pairing_allowed(void); // Repeated calls coalesce until Core 1 completes the operation and return the // same nonzero token. uint32_t bluepad32_input_backend_clear_pairings(); diff --git a/src/firmware/profile/controller_profile.cpp b/src/firmware/profile/controller_profile.cpp index df14959..150edfc 100644 --- a/src/firmware/profile/controller_profile.cpp +++ b/src/firmware/profile/controller_profile.cpp @@ -53,10 +53,15 @@ bool valid_button(uint8_t button) { } bool valid_control_output(uint8_t output) { - return output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT || + return output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL || output == CONTROLLER_PROFILE_NO_BUTTON; } +bool valid_source_control(uint8_t control) { + return control < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT || + control == CONTROLLER_PROFILE_NO_BUTTON; +} + bool valid_turbo_settings(const ControllerProfileTurboSettings& settings) { return settings.rate_hz >= CONTROLLER_PROFILE_TURBO_RATE_MIN && settings.rate_hz <= CONTROLLER_PROFILE_TURBO_RATE_MAX && @@ -285,8 +290,13 @@ bool controller_profile_validate(const ControllerProfile& profile) { return false; } } - if (!valid_control_output(profile.shortcuts.modifier) || - !valid_control_output(profile.shift.modifier) || + for (uint8_t output : profile.extra_button_map) { + if (!valid_control_output(output)) { + return false; + } + } + if (!valid_source_control(profile.shortcuts.modifier) || + !valid_source_control(profile.shift.modifier) || static_cast(profile.shift.mode) > static_cast(ControllerProfileShiftMode::kToggle) || (profile.shift.mode != ControllerProfileShiftMode::kOff && @@ -312,6 +322,11 @@ bool controller_profile_validate(const ControllerProfile& profile) { return false; } } + for (uint8_t output : profile.shift.extra_button_map) { + if (!valid_button(output)) { + return false; + } + } for (uint8_t button = 0; button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++button) { if ((profile.turbo_override_mask & (1u << button)) != 0 && @@ -359,7 +374,7 @@ bool controller_profile_validate(const ControllerProfile& profile) { const ControllerProfileMacro& macro = profile.macros[macro_index]; if ((macro.trigger_mask & ~kLogicalControlMask) != 0 || - !valid_control_output(macro.cancel_control) || + !valid_source_control(macro.cancel_control) || static_cast(macro.mode) > static_cast(ControllerProfileMacroMode::kRepeat) || macro.repeat_count == 0 || @@ -503,6 +518,8 @@ bool controller_profile_encode(const ControllerProfile& profile, static_cast(stream_offset - macro_start); output[336 + macro_index * 2] = static_cast(macro.mode); output[337 + macro_index * 2] = macro.repeat_count; + output[358 + macro_index] = static_cast( + macro.trigger_mask >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL); } output[256] = profile.shortcuts.modifier; memcpy(&output[257], profile.shortcuts.selectors, @@ -526,6 +543,14 @@ bool controller_profile_encode(const ControllerProfile& profile, output[settings_offset++] = settings.burst_count; } } + memcpy(&output[344], profile.extra_button_map, + CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT); + memcpy(&output[351], profile.shift.extra_button_map, + CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT); + output[362] = static_cast( + profile.switching_chord >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL); + output[363] = static_cast( + profile.motion_toggle_chord >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL); return stream_offset <= CONTROLLER_PROFILE_MACRO_STREAM_SIZE; } @@ -538,7 +563,7 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, } const uint16_t schema_version = profile_read_u16(&input[0]); const size_t expected_size = - schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION + schema_version >= CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION ? CONTROLLER_PROFILE_ENCODED_SIZE : CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE; if (schema_version < CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION || @@ -555,6 +580,10 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, schema_version >= CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION; const bool sparse_macros = schema_version >= CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION; + const bool has_expanded_settings = + schema_version >= CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION; + const bool has_extra_controls = + schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION; if ((has_control_mapping ? input[61] != 0 || input[71] != 0 : !profile_bytes_are_zero(&input[60], 2) || @@ -649,7 +678,9 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, (static_cast(descriptor[2] & 0x03u) << 16); const uint8_t cancel = static_cast((descriptor[2] >> 2) & 0x1fu); - if (cancel > CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL && + if (cancel >= (has_extra_controls + ? CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT + : CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) && cancel != 0x1fu) { return false; } @@ -657,7 +688,7 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, cancel == 0x1fu ? CONTROLLER_PROFILE_NO_BUTTON : cancel; macro.first_step = decoded_step_count; macro.step_count = descriptor[4]; - if (schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION) { + if (has_expanded_settings) { macro.mode = static_cast( input[336 + macro_index * 2]); macro.repeat_count = input[337 + macro_index * 2]; @@ -762,7 +793,7 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, static_cast(input[82 + index]); } } - if (schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION) { + if (has_expanded_settings) { profile.shortcuts.modifier = input[256]; memcpy(profile.shortcuts.selectors, &input[257], CONTROLLER_PROFILE_COUNT); @@ -783,8 +814,13 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, } } if (!profile_bytes_are_zero( - &input[settings_offset], 336 - settings_offset) || - !profile_bytes_are_zero(&input[344], 40)) { + &input[settings_offset], 336 - settings_offset)) { + return false; + } + if (!has_extra_controls && + (!profile_bytes_are_zero(&input[344], 40) || + !valid_control_output(profile.shortcuts.modifier) || + !valid_control_output(profile.shift.modifier))) { return false; } } else { @@ -795,6 +831,35 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, } } } + if (has_extra_controls) { + memcpy(profile.extra_button_map, &input[344], + CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT); + memcpy(profile.shift.extra_button_map, &input[351], + CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT); + for (size_t index = 358; index < 364; ++index) { + if ((input[index] & 0x80u) != 0) { + return false; + } + } + if (!profile_bytes_are_zero(&input[364], 20)) { + return false; + } + for (uint8_t index = 0; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) { + profile.macros[index].trigger_mask |= + static_cast(input[358 + index]) + << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; + } + profile.switching_chord |= static_cast(input[362]) + << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; + profile.motion_toggle_chord |= static_cast(input[363]) + << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; + } else { + for (const ControllerProfileMacro& macro : profile.macros) { + if (!valid_control_output(macro.cancel_control)) { + return false; + } + } + } if (!controller_profile_validate(profile)) { return false; } diff --git a/src/firmware/profile/controller_profile.h b/src/firmware/profile/controller_profile.h index c745842..6e65f17 100644 --- a/src/firmware/profile/controller_profile.h +++ b/src/firmware/profile/controller_profile.h @@ -10,12 +10,15 @@ constexpr uint16_t CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2; constexpr uint16_t CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3; constexpr uint16_t CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4; constexpr uint16_t CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5; -constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 6; +constexpr uint16_t CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION = 6; +constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 7; constexpr size_t CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE = 256; constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 384; constexpr uint8_t CONTROLLER_PROFILE_COUNT = 8; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16; -constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 18; +constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 25; +constexpr uint8_t CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT = 7; +constexpr uint8_t CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL = 18; constexpr uint8_t CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL = 16; constexpr uint8_t CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL = 17; constexpr uint8_t CONTROLLER_PROFILE_MACRO_COUNT = 4; @@ -123,6 +126,8 @@ struct ControllerProfileShiftConfiguration { uint8_t modifier = CONTROLLER_PROFILE_NO_BUTTON; uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; + uint8_t extra_button_map[CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT]{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; }; struct ControllerProfileTurboSettings { @@ -184,6 +189,8 @@ struct ControllerProfileMacro { struct ControllerProfile { uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; + uint8_t extra_button_map[CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT]{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; ControllerProfileStickConfiguration sticks[2]{}; ControllerProfileTriggerConfiguration triggers[2]{}; uint8_t weak_rumble_scale = UINT8_MAX; diff --git a/src/firmware/profile/controller_profile_transform.cpp b/src/firmware/profile/controller_profile_transform.cpp index 942880e..839d978 100644 --- a/src/firmware/profile/controller_profile_transform.cpp +++ b/src/firmware/profile/controller_profile_transform.cpp @@ -187,7 +187,7 @@ void route_trigger_output( } return; } - if (output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) { + if (output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) { const uint8_t trigger_index = static_cast( output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); if (value > output_triggers[trigger_index]) { @@ -255,6 +255,10 @@ uint16_t controller_profile_extract_button_mask(const ControllerState& state) { uint32_t controller_profile_extract_control_mask( const ControllerState& state, const ControllerProfile& profile) { uint32_t mask = controller_profile_extract_button_mask(state); + mask |= static_cast( + state.extra_buttons & + ((1u << CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT) - 1u)) + << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; const uint16_t trigger_values[2] = { transform_trigger(state.left_trigger, profile.triggers[0]), transform_trigger(state.right_trigger, profile.triggers[1]), @@ -288,6 +292,8 @@ void controller_profile_remove_control_mask( (1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL)) != 0) { state->right_trigger = 0; } + state->extra_buttons &= static_cast( + ~(control_mask >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL)); } void controller_profile_apply_button_mask(uint16_t button_mask, @@ -354,11 +360,14 @@ uint16_t controller_profile_map_button_mask( ControllerProfileTransformResult controller_profile_transform( const ControllerState& input, const ControllerProfile& profile, - const uint8_t* button_map) { + const uint8_t* button_map, const uint8_t* extra_button_map) { const uint8_t* selected_map = button_map == nullptr ? profile.button_map : button_map; + const uint8_t* selected_extra_map = + extra_button_map == nullptr ? profile.extra_button_map : extra_button_map; ControllerProfileTransformResult result{}; result.state = input; + result.state.extra_buttons = 0; const uint16_t input_button_mask = controller_profile_extract_button_mask(input); uint16_t output_button_mask = @@ -379,12 +388,25 @@ ControllerProfileTransformResult controller_profile_transform( } const uint8_t output = selected_map[input_button]; if (output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && - output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) { + output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) { output_triggers[ output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL] = UINT16_MAX; } } + for (uint8_t extra = 0; + extra < CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT; ++extra) { + if ((input.extra_buttons & (1u << extra)) == 0) { + continue; + } + const uint8_t output = selected_extra_map[extra]; + if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { + output_button_mask |= static_cast(1u << output); + } else if (output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) { + output_triggers[ + output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL] = UINT16_MAX; + } + } const uint16_t transformed_triggers[2] = { transform_trigger(input.left_trigger, profile.triggers[0]), diff --git a/src/firmware/profile/controller_profile_transform.h b/src/firmware/profile/controller_profile_transform.h index 3cea832..3a9909f 100644 --- a/src/firmware/profile/controller_profile_transform.h +++ b/src/firmware/profile/controller_profile_transform.h @@ -27,7 +27,8 @@ uint16_t controller_profile_map_button_mask( ControllerProfileTransformResult controller_profile_transform( const ControllerState& input, const ControllerProfile& profile, - const uint8_t* button_map = nullptr); + const uint8_t* button_map = nullptr, + const uint8_t* extra_button_map = nullptr); uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude, uint8_t scale); diff --git a/src/firmware/profile/controller_synthetic_input.cpp b/src/firmware/profile/controller_synthetic_input.cpp index f95ecf5..4e7bef1 100644 --- a/src/firmware/profile/controller_synthetic_input.cpp +++ b/src/firmware/profile/controller_synthetic_input.cpp @@ -332,7 +332,8 @@ ControllerProfileTransformResult controller_synthetic_input_apply( ControllerProfileTransformResult result = controller_profile_transform( gated_input, profile, - context->shift_active ? profile.shift.button_map : nullptr); + context->shift_active ? profile.shift.button_map : nullptr, + context->shift_active ? profile.shift.extra_button_map : nullptr); if (context->macro_active && context->macro_index < CONTROLLER_PROFILE_MACRO_COUNT) { const ControllerProfileMacro& macro = diff --git a/src/firmware/profile/profile_service.cpp b/src/firmware/profile/profile_service.cpp index 078c732..f5b8a98 100644 --- a/src/firmware/profile/profile_service.cpp +++ b/src/firmware/profile/profile_service.cpp @@ -63,6 +63,24 @@ bool valid_identity(const ControllerIdentity &identity) { controller_identity_encode(identity, encoded, sizeof(encoded)); } +bool valid_owner_locked(const ControllerIdentity &identity) { + if (!valid_identity(identity)) { + return false; + } + if (!controller_identity_is_joycon_pair(identity)) { + return true; + } + if (g_metadata.state != ProfileServiceState::kReady) { + return false; + } + for (uint8_t index = 0; index < g_list.count; ++index) { + if (controller_identity_equal(g_list.rows[index].identity, identity)) { + return true; + } + } + return false; +} + void refresh_list_locked() { g_list = ProfileServiceListSnapshot{}; g_list.metadata = g_metadata; @@ -119,6 +137,17 @@ void refresh_selected_locked() { void refresh_metadata_locked(ProfileServiceState state) { const ProfileStorageSnapshot &stored = g_storage.snapshot(); + if (state == ProfileServiceState::kStorageError) { + // Keep the last committed publication usable by already-connected slots. + // In particular, a rejected pair setup must not replace either solo's + // profile with defaults or expose newly appended member rows. + g_metadata.state = state; + g_list.metadata = g_metadata; + g_selected.metadata = g_metadata; + g_selected.valid = false; + g_selected.status = ConfigurationTransactionStatus::kStorageError; + return; + } g_metadata.state = state; g_metadata.generation = stored.valid ? stored.generation : 0; g_metadata.payload_crc = stored.valid ? stored.payload_crc : 0; @@ -216,7 +245,8 @@ void profile_service_initialize_on_storage_core() { bool profile_service_observe_identity_on_storage_core( const ControllerIdentity &identity) { if (!g_prepared || !identity.stable || - controller_identity_is_global(identity) || !valid_identity(identity)) { + controller_identity_is_global(identity) || !valid_identity(identity) || + controller_identity_is_joycon_pair(identity)) { return false; } critical_section_enter_blocking(&g_lock); @@ -228,6 +258,39 @@ bool profile_service_observe_identity_on_storage_core( const ProfileStorageResult result = g_storage.ensure_identity(identity); if (result != ProfileStorageResult::kOk && result != ProfileStorageResult::kUnchanged) { + if (result == ProfileStorageResult::kIoError) { + critical_section_enter_blocking(&g_lock); + refresh_metadata_locked(ProfileServiceState::kStorageError); + critical_section_exit(&g_lock); + } + return false; + } + critical_section_enter_blocking(&g_lock); + refresh_metadata_locked(ProfileServiceState::kReady); + critical_section_exit(&g_lock); + return true; +} + +bool profile_service_observe_joycon_pair_on_storage_core( + const ControllerIdentity &pair) { + if (!g_prepared || !controller_identity_is_joycon_pair(pair)) { + return false; + } + critical_section_enter_blocking(&g_lock); + const bool ready = g_metadata.state == ProfileServiceState::kReady; + critical_section_exit(&g_lock); + if (!ready) { + return false; + } + // Flash writes and the seed snapshot must not hold either core's state lock. + const ProfileStorageResult result = g_storage.ensure_joycon_pair(pair); + if (result != ProfileStorageResult::kOk && + result != ProfileStorageResult::kUnchanged) { + if (result == ProfileStorageResult::kIoError) { + critical_section_enter_blocking(&g_lock); + refresh_metadata_locked(ProfileServiceState::kStorageError); + critical_section_exit(&g_lock); + } return false; } critical_section_enter_blocking(&g_lock); @@ -309,7 +372,7 @@ profile_service_select(const ControllerIdentity &identity, critical_section_enter_blocking(&g_lock); ConfigurationTransactionStatus status = ConfigurationTransactionStatus::kCommitted; - if (!valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { + if (!valid_owner_locked(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { status = ConfigurationTransactionStatus::kMalformed; g_selected.metadata = g_metadata; g_selected.identity = identity; @@ -359,7 +422,7 @@ profile_service_begin(uint32_t transaction_id, g_transaction.profile_index = profile_index; if (transaction_id == 0 || (transaction_id & kInternalTransactionIdMask) != 0 || - !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT || + !valid_owner_locked(identity) || profile_index >= CONTROLLER_PROFILE_COUNT || payload_size == 0) { g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed; } else if (schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) { @@ -459,7 +522,7 @@ profile_service_reset(uint32_t transaction_id, g_transaction.profile_index = profile_index; if (transaction_id == 0 || (transaction_id & kInternalTransactionIdMask) != 0 || - !valid_identity(identity) || + !valid_owner_locked(identity) || (profile_index != CONTROLLER_PROFILE_ALL && profile_index >= CONTROLLER_PROFILE_COUNT)) { g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed; @@ -498,7 +561,7 @@ profile_service_activate(uint32_t transaction_id, g_transaction.profile_index = profile_index; if (transaction_id == 0 || (transaction_id & kInternalTransactionIdMask) != 0 || - !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { + !valid_owner_locked(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed; critical_section_exit(&g_lock); return ConfigurationTransactionStatus::kMalformed; @@ -535,7 +598,7 @@ ConfigurationTransactionStatus profile_service_set_metadata( bool malformed = transaction_id == 0 || (transaction_id & kInternalTransactionIdMask) != 0 || - !valid_identity(identity) || + !valid_owner_locked(identity) || (profile_index != CONTROLLER_PROFILE_ALL && profile_index >= CONTROLLER_PROFILE_COUNT) || value_size > PROFILE_SERVICE_METADATA_MAX_BYTES || @@ -582,7 +645,7 @@ profile_service_activate_internal(uint32_t transaction_id, return ConfigurationTransactionStatus::kBusy; } if ((transaction_id & kInternalTransactionIdMask) == 0 || - !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { + !valid_owner_locked(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { critical_section_exit(&g_lock); return ConfigurationTransactionStatus::kMalformed; } @@ -673,9 +736,12 @@ void profile_service_active_profile_snapshot( critical_section_enter_blocking(&g_lock); output->metadata = g_metadata; - if (g_metadata.state == ProfileServiceState::kReady && + if ((g_metadata.state == ProfileServiceState::kReady || + g_metadata.state == ProfileServiceState::kStorageError) && g_active_profile_count != 0) { - const PublishedActiveProfile *active = &g_active_profiles[0]; + const PublishedActiveProfile *active = + controller_identity_is_joycon_pair(identity) ? nullptr + : &g_active_profiles[0]; for (uint8_t index = 1; index < g_active_profile_count; ++index) { if (controller_identity_equal(g_active_profiles[index].identity, identity)) { @@ -683,9 +749,11 @@ void profile_service_active_profile_snapshot( break; } } - output->profile_index = active->profile_index; - output->profile = active->profile; - output->valid = true; + if (active != nullptr) { + output->profile_index = active->profile_index; + output->profile = active->profile; + output->valid = true; + } } critical_section_exit(&g_lock); } diff --git a/src/firmware/profile/profile_service.h b/src/firmware/profile/profile_service.h index d14aba7..90aeba6 100644 --- a/src/firmware/profile/profile_service.h +++ b/src/firmware/profile/profile_service.h @@ -74,6 +74,8 @@ void profile_service_initialize_on_storage_core(); void profile_service_task_on_storage_core(uint32_t now_ms); bool profile_service_observe_identity_on_storage_core( const ControllerIdentity& identity); +bool profile_service_observe_joycon_pair_on_storage_core( + const ControllerIdentity& pair); ConfigurationTransactionStatus profile_service_select( const ControllerIdentity& identity, uint8_t profile_index); diff --git a/src/firmware/profile/profile_storage.cpp b/src/firmware/profile/profile_storage.cpp index 45180eb..7fcba44 100644 --- a/src/firmware/profile/profile_storage.cpp +++ b/src/firmware/profile/profile_storage.cpp @@ -9,7 +9,8 @@ constexpr uint8_t kRecordMagic[4] = {'S', 'P', 'C', 'R'}; constexpr uint8_t kLegacyStorageMagic[4] = {'S', 'P', 'P', 'F'}; constexpr uint8_t kLegacyDatabaseMagic[4] = {'S', 'P', 'D', 'B'}; constexpr uint16_t kLegacyCatalogVersion = 1; -constexpr uint16_t kCatalogVersion = 2; +constexpr uint16_t kExpandedCatalogVersion = 2; +constexpr uint16_t kCatalogVersion = 3; constexpr size_t kRecordPayloadOffset = 128; constexpr size_t kLegacyRecordPayloadOffset = 256; static_assert(kRecordPayloadOffset + CONTROLLER_PROFILE_ENCODED_SIZE == @@ -188,10 +189,17 @@ bool ProfileStorage::initialize(const ProfileStorageIo &io) { return initialized_; } +bool ProfileStorage::valid_owner(const ControllerIdentity &identity) const { + return valid_identity(identity) && + (!controller_identity_is_joycon_pair(identity) || + find(identity) != nullptr); +} + ProfileStorageResult ProfileStorage::ensure_identity(const ControllerIdentity &identity) { - if (!initialized_ || !valid_identity(identity)) { - return ProfileStorageResult::kInvalidArgument; + if (!initialized_ || !valid_owner(identity)) { + return initialized_ ? ProfileStorageResult::kInvalidArgument + : ProfileStorageResult::kIoError; } if (find(identity) != nullptr) { return ProfileStorageResult::kUnchanged; @@ -202,12 +210,46 @@ ProfileStorage::ensure_identity(const ControllerIdentity &identity) { return append(RecordType::kActivate, identity, 0, nullptr, 0); } +ProfileStorageResult +ProfileStorage::ensure_joycon_pair(const ControllerIdentity &pair) { + if (!initialized_) { + return ProfileStorageResult::kIoError; + } + ControllerIdentity left{}; + ControllerIdentity right{}; + if (!controller_identity_joycon_pair_members(pair, &left, &right)) { + return ProfileStorageResult::kInvalidArgument; + } + if (find(pair) != nullptr) { + return ProfileStorageResult::kUnchanged; + } + const uint8_t needed = 1 + (find(left) == nullptr) + (find(right) == nullptr); + if (identity_count_ + needed > CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return ProfileStorageResult::kFull; + } + ProfileStorageResult result = ensure_identity(left); + if (result != ProfileStorageResult::kOk && + result != ProfileStorageResult::kUnchanged) { + return result; + } + result = ensure_identity(right); + if (result != ProfileStorageResult::kOk && + result != ProfileStorageResult::kUnchanged) { + return result; + } + // One committed record snapshots the immutable left record references. + // Defaults are identity-independent; later edits use normal copy-on-write. + return append(RecordType::kSeedJoyConPair, pair, + CONTROLLER_PROFILE_ALL, nullptr, 0); +} + ProfileStorageResult ProfileStorage::get(const ControllerIdentity &identity, uint8_t profile_index, ControllerProfile *output) const { - if (!initialized_ || output == nullptr || !valid_identity(identity) || + if (!initialized_ || output == nullptr || !valid_owner(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { - return ProfileStorageResult::kInvalidArgument; + return initialized_ ? ProfileStorageResult::kInvalidArgument + : ProfileStorageResult::kIoError; } const ProfileStorageIdentityIndex *entry = find(identity); if (entry == nullptr || @@ -223,10 +265,11 @@ ProfileStorageResult ProfileStorage::get(const ControllerIdentity &identity, ProfileStorageResult ProfileStorage::set(const ControllerIdentity &identity, uint8_t profile_index, const ControllerProfile &profile) { - if (!initialized_ || !valid_identity(identity) || + if (!initialized_ || !valid_owner(identity) || profile_index >= CONTROLLER_PROFILE_COUNT || !controller_profile_validate(profile)) { - return ProfileStorageResult::kInvalidArgument; + return initialized_ ? ProfileStorageResult::kInvalidArgument + : ProfileStorageResult::kIoError; } uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; if (!controller_profile_encode(profile, encoded, sizeof(encoded))) { @@ -250,10 +293,11 @@ ProfileStorageResult ProfileStorage::set(const ControllerIdentity &identity, ProfileStorageResult ProfileStorage::reset(const ControllerIdentity &identity, uint8_t profile_index) { - if (!initialized_ || !valid_identity(identity) || + if (!initialized_ || !valid_owner(identity) || (profile_index != CONTROLLER_PROFILE_ALL && profile_index >= CONTROLLER_PROFILE_COUNT)) { - return ProfileStorageResult::kInvalidArgument; + return initialized_ ? ProfileStorageResult::kInvalidArgument + : ProfileStorageResult::kIoError; } const ProfileStorageIdentityIndex *entry = find(identity); if (entry == nullptr) { @@ -278,9 +322,10 @@ ProfileStorageResult ProfileStorage::reset(const ControllerIdentity &identity, ProfileStorageResult ProfileStorage::activate(const ControllerIdentity &identity, uint8_t profile_index) { - if (!initialized_ || !valid_identity(identity) || + if (!initialized_ || !valid_owner(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { - return ProfileStorageResult::kInvalidArgument; + return initialized_ ? ProfileStorageResult::kInvalidArgument + : ProfileStorageResult::kIoError; } const ProfileStorageIdentityIndex *entry = find(identity); if (entry != nullptr && entry->active_profile == profile_index) { @@ -297,8 +342,9 @@ ProfileStorageResult ProfileStorage::get_alias( const ControllerIdentity &identity, char *output, size_t output_size) const { if (!initialized_ || output == nullptr || output_size == 0 || - !valid_identity(identity)) { - return ProfileStorageResult::kInvalidArgument; + !valid_owner(identity)) { + return initialized_ ? ProfileStorageResult::kInvalidArgument + : ProfileStorageResult::kIoError; } const ProfileStorageIdentityIndex *entry = find(identity); if (entry == nullptr || @@ -314,10 +360,11 @@ ProfileStorageResult ProfileStorage::get_alias( ProfileStorageResult ProfileStorage::set_alias( const ControllerIdentity &identity, const char *value, size_t value_size) { - if (!initialized_ || !valid_identity(identity) || + if (!initialized_ || !valid_owner(identity) || value_size > PROFILE_STORAGE_METADATA_MAX_BYTES || (value_size != 0 && value == nullptr)) { - return ProfileStorageResult::kInvalidArgument; + return initialized_ ? ProfileStorageResult::kInvalidArgument + : ProfileStorageResult::kIoError; } char current[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; if (find(identity) == nullptr && @@ -343,9 +390,10 @@ ProfileStorageResult ProfileStorage::get_profile_name( const ControllerIdentity &identity, uint8_t profile_index, char *output, size_t output_size) const { if (!initialized_ || output == nullptr || output_size == 0 || - !valid_identity(identity) || + !valid_owner(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { - return ProfileStorageResult::kInvalidArgument; + return initialized_ ? ProfileStorageResult::kInvalidArgument + : ProfileStorageResult::kIoError; } const ProfileStorageIdentityIndex *entry = find(identity); if (entry == nullptr || @@ -374,11 +422,12 @@ ProfileStorageResult ProfileStorage::get_profile_name( ProfileStorageResult ProfileStorage::set_profile_name( const ControllerIdentity &identity, uint8_t profile_index, const char *value, size_t value_size) { - if (!initialized_ || !valid_identity(identity) || + if (!initialized_ || !valid_owner(identity) || profile_index >= CONTROLLER_PROFILE_COUNT || value_size > PROFILE_STORAGE_METADATA_MAX_BYTES || (value_size != 0 && value == nullptr)) { - return ProfileStorageResult::kInvalidArgument; + return initialized_ ? ProfileStorageResult::kInvalidArgument + : ProfileStorageResult::kIoError; } const ProfileStorageIdentityIndex *entry = find(identity); if (entry == nullptr && @@ -443,7 +492,8 @@ ProfileStorageResult ProfileStorage::scan_arena( } *version = read_u16(&superblock[4]); if (memcmp(superblock, kSuperblockMagic, sizeof(kSuperblockMagic)) != 0 || - (*version != kCatalogVersion && *version != kLegacyCatalogVersion) || + (*version != kCatalogVersion && *version != kExpandedCatalogVersion && + *version != kLegacyCatalogVersion) || profile_storage_crc32(superblock, 12) != read_u32(&superblock[12]) || !bytes_are(0, &superblock[16], sizeof(superblock) - 16)) { return ProfileStorageResult::kUnchanged; @@ -479,9 +529,11 @@ ProfileStorageResult ProfileStorage::scan_arena( controller_identity_decode(&record[20], CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value); const uint32_t record_generation = read_u32(&record[8]); - apply_record(index, identity_count, static_cast(record[6]), - identity_value, record[7], record_generation, - pack_record(arena, offset)); + if (!apply_record(index, identity_count, static_cast(record[6]), + identity_value, record[7], record_generation, + pack_record(arena, offset))) { + return ProfileStorageResult::kIoError; + } if (!have_generation || generation_is_newer(record_generation, *generation)) { have_generation = true; *generation = record_generation; @@ -512,7 +564,8 @@ bool ProfileStorage::validate_record(const uint8_t *record, const bool known_type = type == RecordType::kProfile || type == RecordType::kReset || type == RecordType::kResetAll || type == RecordType::kActivate || - type == RecordType::kAlias || type == RecordType::kProfileNames; + type == RecordType::kAlias || type == RecordType::kProfileNames || + (version == kCatalogVersion && type == RecordType::kSeedJoyConPair); const bool indexed_profile = type == RecordType::kProfile || type == RecordType::kReset || type == RecordType::kActivate; @@ -529,6 +582,11 @@ bool ProfileStorage::validate_record(const uint8_t *record, if (!controller_identity_decode( &record[20], CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) || !valid_identity(identity_value) || !known_type || + (version != kCatalogVersion && + controller_identity_is_joycon_pair(identity_value)) || + (type == RecordType::kSeedJoyConPair && + (!controller_identity_is_joycon_pair(identity_value) || + profile_index != CONTROLLER_PROFILE_ALL)) || (indexed_profile && profile_index >= CONTROLLER_PROFILE_COUNT) || payload_size != expected_payload_size || profile_storage_crc32(payload, payload_size) != read_u32(&record[16])) { @@ -575,7 +633,8 @@ bool ProfileStorage::read_record_payload( ? kLegacyRecordPayloadOffset : kRecordPayloadOffset; const size_t payload_size = read_u16(&header[12]); - if ((version != kCatalogVersion && version != kLegacyCatalogVersion) || + if ((version != kCatalogVersion && version != kExpandedCatalogVersion && + version != kLegacyCatalogVersion) || payload_size > capacity || payload_size > PROFILE_STORAGE_RECORD_SIZE - payload_offset || !io_.read(io_.context, record_arena(record), @@ -620,6 +679,7 @@ ProfileStorageResult ProfileStorage::append(RecordType type, if (next_offset_ + PROFILE_STORAGE_RECORD_SIZE > PROFILE_STORAGE_ARENA_SIZE) { const ProfileStorageResult result = compact(); if (result != ProfileStorageResult::kOk) { + initialized_ = false; return result; } } @@ -628,10 +688,17 @@ ProfileStorageResult ProfileStorage::append(RecordType type, next_offset_ += PROFILE_STORAGE_RECORD_SIZE; if (!write_record(snapshot_.active_bank, offset, type, identity, profile_index, generation, payload, payload_size)) { + // The header may have committed even when program/readback reports an + // error. Freeze all mutations until initialize() replays durable state; + // otherwise a retry could seed from a different left snapshot. + initialized_ = false; + return ProfileStorageResult::kIoError; + } + if (!apply_record(index_, &identity_count_, type, identity, profile_index, + generation, pack_record(snapshot_.active_bank, offset))) { + initialized_ = false; return ProfileStorageResult::kIoError; } - apply_record(index_, &identity_count_, type, identity, profile_index, - generation, pack_record(snapshot_.active_bank, offset)); snapshot_.generation = generation; snapshot_.payload_crc = profile_storage_crc32(payload, payload_size); return ProfileStorageResult::kOk; @@ -851,7 +918,8 @@ ProfileStorageResult ProfileStorage::migrate_legacy() { !controller_identity_decode( entry_header, CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) || !identity_value.stable || - controller_identity_is_global(identity_value)) { + controller_identity_is_global(identity_value) || + controller_identity_is_joycon_pair(identity_value)) { return ProfileStorageResult::kIoError; } for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT; @@ -1004,7 +1072,7 @@ bool ProfileStorage::write_record(uint8_t arena, size_t offset, RecordType type, memcmp(record, verified, sizeof(record)) == 0; } -void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index, +bool ProfileStorage::apply_record(ProfileStorageIdentityIndex *index, uint8_t *identity_count, RecordType type, const ControllerIdentity &identity, uint8_t profile_index, uint32_t generation, @@ -1017,9 +1085,46 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index, break; } } + if (type == RecordType::kSeedJoyConPair) { + if (entry != nullptr) { + return true; // A duplicate seed can never overwrite pair-owned edits. + } + ControllerIdentity left{}; + ControllerIdentity right{}; + if (!controller_identity_joycon_pair_members(identity, &left, &right) || + *identity_count >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return false; + } + const ProfileStorageIdentityIndex *source = nullptr; + bool have_right = false; + for (uint8_t current = 0; current < *identity_count; ++current) { + if (index[current].used && + controller_identity_equal(index[current].identity, left)) { + source = &index[current]; + } + if (index[current].used && + controller_identity_equal(index[current].identity, right)) { + have_right = true; + } + } + if (source == nullptr || !have_right) { + return false; + } + entry = &index[(*identity_count)++]; + *entry = *source; + entry->identity = identity; + entry->alias_record = PROFILE_STORAGE_NO_RECORD; + entry->alias_generation = 0; + entry->active_generation = generation; + entry->profile_names_generation = generation; + for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { + entry->profile_generation[profile] = generation; + } + return true; + } if (entry == nullptr) { if (*identity_count >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { - return; + return false; } entry = &index[(*identity_count)++]; *entry = {}; @@ -1035,7 +1140,7 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index, entry->active_profile = profile_index; entry->active_generation = generation; } - return; + return true; } if (type == RecordType::kAlias) { if (entry->alias_generation == 0 || @@ -1043,7 +1148,7 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index, entry->alias_record = record; entry->alias_generation = generation; } - return; + return true; } if (type == RecordType::kProfileNames) { if (entry->profile_names_generation == 0 || @@ -1052,7 +1157,7 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index, entry->profile_names_record = record; entry->profile_names_generation = generation; } - return; + return true; } if (type == RecordType::kResetAll) { for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { @@ -1067,7 +1172,7 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index, entry->active_profile = 0; entry->active_generation = generation; } - return; + return true; } if (entry->profile_generation[profile_index] == 0 || generation_is_newer(generation, @@ -1076,4 +1181,5 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index, type == RecordType::kProfile ? record : PROFILE_STORAGE_NO_RECORD; entry->profile_generation[profile_index] = generation; } + return true; } diff --git a/src/firmware/profile/profile_storage.h b/src/firmware/profile/profile_storage.h index 1342c9c..8025560 100644 --- a/src/firmware/profile/profile_storage.h +++ b/src/firmware/profile/profile_storage.h @@ -93,6 +93,7 @@ class ProfileStorage { public: bool initialize(const ProfileStorageIo &io); ProfileStorageResult ensure_identity(const ControllerIdentity &identity); + ProfileStorageResult ensure_joycon_pair(const ControllerIdentity &pair); ProfileStorageResult get(const ControllerIdentity &identity, uint8_t profile_index, ControllerProfile *output) const; @@ -130,8 +131,10 @@ private: kActivate = 4, kAlias = 5, kProfileNames = 6, + kSeedJoyConPair = 7, }; + bool valid_owner(const ControllerIdentity &identity) const; ProfileStorageResult scan_arena( uint8_t arena, uint16_t *version, uint32_t *epoch, uint32_t *generation, uint32_t *payload_crc, size_t *next_offset, @@ -154,7 +157,7 @@ private: const ControllerIdentity &identity, uint8_t profile_index, uint32_t generation, const uint8_t *payload, size_t payload_size) const; - void apply_record(ProfileStorageIdentityIndex *index, uint8_t *identity_count, + bool apply_record(ProfileStorageIdentityIndex *index, uint8_t *identity_count, RecordType type, const ControllerIdentity &identity, uint8_t profile_index, uint32_t generation, uint32_t record) const; diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index 3981b70..0c4f163 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -193,7 +193,7 @@ size_t encode_info(uint8_t* output, size_t output_size) { size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) { Bluepad32BackendDiagnostics diagnostics{}; bluepad32_input_backend_diagnostics(&diagnostics); - uint8_t payload[32]{}; + uint8_t payload[40]{}; write_u32(&payload[0], diagnostics.initialization_stage); write_u32(&payload[4], diagnostics.rumble_timer_ticks); write_u32(&payload[8], diagnostics.configuration_timer_ticks); @@ -205,6 +205,8 @@ size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) { payload[29] = diagnostics.rumble_capable_slots; payload[30] = diagnostics.feedback_pending_slots; payload[31] = diagnostics.rumble_pending_slots; + write_u32(&payload[32], diagnostics.switch2_ingress_drops); + write_u32(&payload[36], diagnostics.switch2_output_drops); return encode_response(Operation::kRuntimeDiagnostics, Status::kOk, 0, 0, 0, payload, sizeof(payload), output, output_size); @@ -465,7 +467,9 @@ size_t encode_pairing_snapshot(const Bluepad32PairingSnapshot& snapshot, Operation::kPairingRead, snapshot.status == Bluepad32PairingSnapshotStatus::kReady ? Status::kOk - : Status::kPending, + : snapshot.status == Bluepad32PairingSnapshotStatus::kFailed + ? Status::kStorageError + : Status::kPending, snapshot.overflow ? 1 : 0, 0, snapshot.generation, payload, offset, output, output_size); } @@ -511,7 +515,10 @@ size_t encode_profile_playtest( snapshot.identity, &payload[12], CONTROLLER_IDENTITY_ENCODED_SIZE) || snapshot.state.motion_sample_count > - CONTROLLER_MOTION_SAMPLE_CAPACITY) { + CONTROLLER_MOTION_SAMPLE_CAPACITY || + (snapshot.state.extra_buttons & 0x80u) != 0 || + static_cast(snapshot.controller_layout) > + static_cast(Bluepad32ControllerLayout::kWiiNunchuk)) { return 0; } payload[0] = 1; @@ -532,6 +539,8 @@ size_t encode_profile_playtest( payload[38] = snapshot.state.motion_sample_count; payload[39] = snapshot.battery; payload[40] = snapshot.capabilities; + payload[54] = snapshot.state.extra_buttons; + payload[55] = static_cast(snapshot.controller_layout); if (snapshot.state.motion_sample_count != 0) { payload[0] |= 2; const ControllerMotionSample& motion = diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index 5203d47..c277696 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -23,8 +23,8 @@ 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 = 2; -constexpr size_t kProfilePlaytestPayloadSize = 54; +constexpr uint16_t kProfilePlaytestSchemaVersion = 4; +constexpr size_t kProfilePlaytestPayloadSize = 56; constexpr size_t kProfileMetadataPayloadSize = (CONTROLLER_PROFILE_COUNT + 1) * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1); diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index d75d54f..c776ac9 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -99,7 +99,10 @@ STATUS_NAMES = { 8: "storage failure", } -CONFIGURATION_SCHEMA_VERSION = 3 +CONFIGURATION_SCHEMA_VERSION = 4 +JOYCON_MODE_PAIRED = 0 +JOYCON_MODE_INDIVIDUAL = 1 +JOYCON_MODE_NAMES = ("paired", "individual") CONFIGURATION_SIZE = 232 NATIVE_SWITCH_CONTROLLER_CAPACITY = 16 NATIVE_SWITCH_RUMBLE_APPROVAL_NOTE = ( @@ -132,12 +135,14 @@ PAIRING_RECORD_CAPACITY = 16 TRANSPORT_UNKNOWN = 0 TRANSPORT_CLASSIC = 1 TRANSPORT_BLE = 2 +TRANSPORT_JOYCON_PAIR = 3 PROFILE_LEGACY_SCHEMA_VERSION = 1 PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2 PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3 PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4 PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5 -PROFILE_SCHEMA_VERSION = 6 +PROFILE_EXPANDED_SCHEMA_VERSION = 6 +PROFILE_SCHEMA_VERSION = 7 PROFILE_LEGACY_SIZE = 256 PROFILE_SIZE = 384 PROFILE_CAPACITY = 8 @@ -163,8 +168,20 @@ PROFILE_TURBO_BURST_MIN = 1 PROFILE_TURBO_BURST_MAX = 255 PROFILE_MACRO_REPEAT_MIN = 1 PROFILE_MACRO_REPEAT_MAX = 255 -PROFILE_PLAYTEST_SCHEMA_VERSION = 2 -PROFILE_PLAYTEST_SIZE = 54 +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_SIZE = 56 +PROFILE_PLAYTEST_LAYOUTS = ( + None, + "joycon2-left", + "joycon2-right", + "joycon2-pair", + "wii-remote", + "wii-nunchuk", +) PROFILE_PLAYTEST_SLOT_COUNT = 4 PROFILE_METADATA_SCHEMA_VERSION = 1 PROFILE_METADATA_MAX_BYTES = 31 @@ -276,7 +293,9 @@ LOGICAL_BUTTONS = ( "dpad_left", "dpad_right", ) -LOGICAL_CONTROLS = LOGICAL_BUTTONS + ("left_trigger", "right_trigger") +OUTPUT_CONTROLS = LOGICAL_BUTTONS + ("left_trigger", "right_trigger") +EXTRA_BUTTONS = ("c", "gl", "gr", "left_sl", "left_sr", "right_sl", "right_sr") +LOGICAL_CONTROLS = OUTPUT_CONTROLS + EXTRA_BUTTONS PROFILE_LOGICAL_CONTROL_MASK = (1 << len(LOGICAL_CONTROLS)) - 1 RUMBLE_POLICIES = ("none", "rumble", "led", "rumble_and_led") TURBO_MODES = ("off", "turbo", "auto_burst", "burst") @@ -369,6 +388,8 @@ class RuntimeDiagnostics: rumble_capable_slots: int feedback_pending_slots: int rumble_pending_slots: int + switch2_ingress_drops: int | None = None + switch2_output_drops: int | None = None @dataclass(frozen=True) @@ -586,6 +607,7 @@ class AdapterConfiguration: requested_mode: int = REQUESTED_MODE_AUTO native_switch_controllers: tuple[ControllerIdentity, ...] = () schema_version: int = CONFIGURATION_SCHEMA_VERSION + joycon_mode: int = JOYCON_MODE_PAIRED @dataclass(frozen=True) @@ -605,6 +627,10 @@ class PairingRecord: address_type: int address: bytes + def __post_init__(self) -> None: + if self.transport not in (TRANSPORT_CLASSIC, TRANSPORT_BLE): + raise ConfigManagerError("pairing record must identify a physical Bluetooth peer") + @property def address_text(self) -> str: return ":".join(f"{octet:02X}" for octet in self.address) @@ -684,13 +710,16 @@ def _button_name(value: int) -> str | None: return LOGICAL_BUTTONS[value] -def _control_index(value: Any, name: str) -> int: +def _control_index( + value: Any, name: str, *, schema_version: int = PROFILE_SCHEMA_VERSION +) -> int: if value is None: return PROFILE_NONE_BUTTON - if type(value) is not str or value not in LOGICAL_CONTROLS: - choices = ", ".join(LOGICAL_CONTROLS) + controls = LOGICAL_CONTROLS if schema_version >= PROFILE_SCHEMA_VERSION else OUTPUT_CONTROLS + if type(value) is not str or value not in controls: + choices = ", ".join(controls) raise ConfigManagerError(f"{name} must be null or one of: {choices}") - return LOGICAL_CONTROLS.index(value) + return controls.index(value) def _control_name(value: int) -> str | None: @@ -714,12 +743,14 @@ def _button_mask_from_json(value: Any, name: str) -> int: return mask -def _control_mask_from_json(value: Any, name: str) -> int: +def _control_mask_from_json( + value: Any, name: str, *, schema_version: int = PROFILE_SCHEMA_VERSION +) -> int: if type(value) is not list: raise ConfigManagerError(f"{name} must be a JSON array") mask = 0 for entry in value: - index = _control_index(entry, name) + index = _control_index(entry, name, schema_version=schema_version) if index == PROFILE_NONE_BUTTON: raise ConfigManagerError(f"{name} cannot contain null") bit = 1 << index @@ -745,15 +776,39 @@ class ControllerIdentity: address: bytes vendor_id: int product_id: int + partner_address_type: int = 0 + partner_address: bytes = bytes(6) def __post_init__(self) -> None: _require_bool(self.stable, "identity stable") - _require_int(self.transport, "identity transport", 0, 2) + _require_int(self.transport, "identity transport", 0, TRANSPORT_JOYCON_PAIR) _require_int(self.address_type, "identity address_type", 0, 0xFF) if type(self.address) is not bytes or len(self.address) != 6: raise ConfigManagerError("identity address must contain six bytes") _require_int(self.vendor_id, "identity vendor_id", 0, 0xFFFF) _require_int(self.product_id, "identity product_id", 0, 0xFFFF) + _require_int(self.partner_address_type, "identity partner_address_type", 0, 0xFF) + if type(self.partner_address) is not bytes or len(self.partner_address) != 6: + raise ConfigManagerError("identity partner_address must contain six bytes") + if self.is_joycon_pair: + if ( + not self.stable + or self.vendor_id != 0x057E + or self.product_id != 0x2067 + or self.address_type not in (0, 1) + or self.partner_address_type not in (0, 1) + or (self.address_type == 1 and self.address[0] & 0xC0 != 0xC0) + or ( + self.partner_address_type == 1 + and self.partner_address[0] & 0xC0 != 0xC0 + ) + or (self.address_type, self.address) + == (self.partner_address_type, self.partner_address) + ): + raise ConfigManagerError("invalid Joy-Con 2 pair identity") + return + if self.partner_address_type != 0 or self.partner_address != bytes(6): + raise ConfigManagerError("physical identity cannot contain a partner address") if self.stable: if self.transport not in (TRANSPORT_CLASSIC, TRANSPORT_BLE): raise ConfigManagerError( @@ -774,11 +829,53 @@ class ControllerIdentity: def global_fallback(cls) -> ControllerIdentity: return cls(False, TRANSPORT_UNKNOWN, 0, bytes(6), 0, 0) + @classmethod + def make_joycon_pair( + cls, left: ControllerIdentity, right: ControllerIdentity + ) -> ControllerIdentity: + for member, product_id in ((left, 0x2067), (right, 0x2066)): + if ( + not isinstance(member, ControllerIdentity) + or not member.stable + or member.transport != TRANSPORT_BLE + or member.vendor_id != 0x057E + or member.product_id != product_id + or member.address_type not in (0, 1) + ): + raise ConfigManagerError( + "pair members must be stable BLE Joy-Con 2 (L) and (R) identities" + ) + return cls( + True, TRANSPORT_JOYCON_PAIR, left.address_type, left.address, + 0x057E, 0x2067, right.address_type, right.address, + ) + + def joycon_pair_members(self) -> tuple[ControllerIdentity, ControllerIdentity]: + if not self.is_joycon_pair: + raise ConfigManagerError("identity is not a Joy-Con 2 pair") + return ( + ControllerIdentity( + True, TRANSPORT_BLE, self.address_type, self.address, 0x057E, 0x2067, + ), + ControllerIdentity( + True, TRANSPORT_BLE, self.partner_address_type, self.partner_address, + 0x057E, 0x2066, + ), + ) + @classmethod def from_bytes(cls, payload: bytes) -> ControllerIdentity: payload = bytes(payload) if len(payload) != CONTROLLER_IDENTITY_SIZE: raise ConfigManagerError("invalid controller identity size") + if payload[1] == TRANSPORT_JOYCON_PAIR: + flags = payload[0] + if flags & ~0x07 or not flags & 1: + raise ConfigManagerError("invalid Joy-Con 2 pair flags") + return cls( + True, TRANSPORT_JOYCON_PAIR, (flags >> 1) & 1, payload[2:8], + 0x057E, 0x2067, (flags >> 2) & 1, payload[8:14], + ) stable, transport, address_type, reserved = payload[:4] if stable not in (0, 1) or reserved != 0: raise ConfigManagerError("invalid controller identity encoding") @@ -793,6 +890,15 @@ class ControllerIdentity: ) def to_bytes(self) -> bytes: + if self.is_joycon_pair: + return ( + bytes(( + 1 | (self.address_type << 1) | (self.partner_address_type << 2), + TRANSPORT_JOYCON_PAIR, + )) + + self.address + + self.partner_address + ) return ( bytes( [ @@ -810,18 +916,47 @@ class ControllerIdentity: def is_global_fallback(self) -> bool: return not self.stable + @property + def is_joycon_pair(self) -> bool: + return self.transport == TRANSPORT_JOYCON_PAIR + @property def address_text(self) -> str: return ":".join(f"{octet:02X}" for octet in self.address) + @property + def partner_address_text(self) -> str: + return ":".join(f"{octet:02X}" for octet in self.partner_address) + @property def transport_text(self) -> str: if self.transport == TRANSPORT_CLASSIC: return "Classic" if self.transport == TRANSPORT_BLE: return "BLE" + if self.is_joycon_pair: + return "BLE pair" return "Unknown" + def to_json_object(self) -> dict[str, Any]: + result: dict[str, Any] = { + "stable": self.stable, + "address": self.address_text, + "address_type": self.address_type, + "transport": self.transport_text, + "vendor_id": self.vendor_id, + "product_id": self.product_id, + "is_joycon_pair": self.is_joycon_pair, + } + if self.is_joycon_pair: + left, right = self.joycon_pair_members() + result.update( + partner_address=self.partner_address_text, + partner_address_type=self.partner_address_type, + members={"left": left.to_json_object(), "right": right.to_json_object()}, + ) + return result + @dataclass(frozen=True) class ProfileListEntry: @@ -887,6 +1022,8 @@ class ProfilePlaytest: battery: int capabilities: int motion: tuple[int, int, int, int, int, int] | None + extra_buttons: int = 0 + layout: str | None = None def to_json_object(self) -> dict[str, Any]: return { @@ -895,16 +1032,16 @@ class ProfilePlaytest: "connection_generation": self.connection_generation, "state_generation": self.state_generation, "identity": ( - { - "address": self.identity.address_text, - "transport": self.identity.transport_text, - "vendor_id": self.identity.vendor_id, - "product_id": self.identity.product_id, - } + self.identity.to_json_object() if self.identity is not None else None ), + "layout": self.layout, "buttons": _button_mask_to_json(self.button_mask), + "extra_buttons": [ + name for index, name in enumerate(EXTRA_BUTTONS) + if self.extra_buttons & (1 << index) + ], "left_stick": { "x": self.left_stick[0], "y": self.left_stick[1], @@ -1056,7 +1193,7 @@ class TriggerConfig: if ( type(self.output) is not int or self.output != PROFILE_NONE_BUTTON - and not 0 <= self.output < len(LOGICAL_CONTROLS) + and not 0 <= self.output < len(OUTPUT_CONTROLS) ): raise ConfigManagerError("invalid trigger output mapping") @@ -1430,12 +1567,14 @@ class ProfileShortcuts: } @classmethod - def from_json_object(cls, value: Any) -> ProfileShortcuts: + def from_json_object( + cls, value: Any, *, schema_version: int = PROFILE_SCHEMA_VERSION + ) -> ProfileShortcuts: obj = _require_object(value, ("modifier", "profiles"), "profile.shortcuts") if type(obj["profiles"]) is not list: raise ConfigManagerError("profile.shortcuts.profiles must be an array") return cls( - _control_index(obj["modifier"], "profile.shortcuts.modifier"), + _control_index(obj["modifier"], "profile.shortcuts.modifier", schema_version=schema_version), tuple( _button_index(selector, f"profile.shortcuts.profiles[{index}]") for index, selector in enumerate(obj["profiles"]) @@ -1448,6 +1587,7 @@ class ProfileShift: mode: int = 0 modifier: int = PROFILE_NONE_BUTTON button_map: tuple[int, ...] = tuple(range(len(LOGICAL_BUTTONS))) + extra_button_map: tuple[int, ...] = (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS) def __post_init__(self) -> None: _require_int(self.mode, "Shift mode", 0, len(SHIFT_MODES) - 1) @@ -1462,7 +1602,9 @@ class ProfileShift: LOGICAL_BUTTONS ): raise ConfigManagerError("Shift button map must contain 16 mappings") - for output in self.button_map: + if type(self.extra_button_map) is not tuple or len(self.extra_button_map) != len(EXTRA_BUTTONS): + raise ConfigManagerError("Shift extra button map must contain seven mappings") + for output in (*self.button_map, *self.extra_button_map): if type(output) is not int or ( output != PROFILE_NONE_BUTTON and not 0 <= output < len(LOGICAL_BUTTONS) ): @@ -1476,23 +1618,38 @@ class ProfileShift: name: _button_name(self.button_map[index]) for index, name in enumerate(LOGICAL_BUTTONS) }, + "extra_button_map": { + name: _button_name(self.extra_button_map[index]) + for index, name in enumerate(EXTRA_BUTTONS) + }, } @classmethod - def from_json_object(cls, value: Any) -> ProfileShift: - obj = _require_object( - value, ("mode", "modifier", "button_map"), "profile.shift" - ) + def from_json_object( + cls, value: Any, *, schema_version: int = PROFILE_SCHEMA_VERSION + ) -> ProfileShift: + fields = ["mode", "modifier", "button_map"] + if schema_version >= PROFILE_SCHEMA_VERSION: + fields.append("extra_button_map") + obj = _require_object(value, fields, "profile.shift") mappings = _require_object( obj["button_map"], LOGICAL_BUTTONS, "profile.shift.button_map" ) + extras = ( + _require_object(obj["extra_button_map"], EXTRA_BUTTONS, "profile.shift.extra_button_map") + if schema_version >= PROFILE_SCHEMA_VERSION else dict.fromkeys(EXTRA_BUTTONS) + ) return cls( _require_enum(obj["mode"], SHIFT_MODES, "profile.shift.mode"), - _control_index(obj["modifier"], "profile.shift.modifier"), + _control_index(obj["modifier"], "profile.shift.modifier", schema_version=schema_version), tuple( _button_index(mappings[name], f"profile.shift.button_map.{name}") for name in LOGICAL_BUTTONS ), + tuple( + _button_index(extras[name], f"profile.shift.extra_button_map.{name}") + for name in EXTRA_BUTTONS + ), ) @@ -1594,25 +1751,25 @@ class ControllerMacro: cls, value: Any, name: str, *, schema_version: int = PROFILE_SCHEMA_VERSION ) -> ControllerMacro: fields = ["trigger", "cancel", "steps"] - if schema_version >= PROFILE_SCHEMA_VERSION: + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION: fields.extend(("playback", "repeat_count")) obj = _require_object(value, fields, name) steps = obj["steps"] if type(steps) is not list or len(steps) > PROFILE_MACRO_STEPS_PER_MACRO: raise ConfigManagerError(f"{name}.steps must contain zero to eight steps") return cls( - _control_mask_from_json(obj["trigger"], f"{name}.trigger"), - _control_index(obj["cancel"], f"{name}.cancel"), + _control_mask_from_json(obj["trigger"], f"{name}.trigger", schema_version=schema_version), + _control_index(obj["cancel"], f"{name}.cancel", schema_version=schema_version), tuple( MacroStep.from_json_object(step, f"{name}.steps[{index}]") for index, step in enumerate(steps) ), ( _require_enum(obj["playback"], MACRO_PLAYBACK_MODES, f"{name}.playback") - if schema_version >= PROFILE_SCHEMA_VERSION + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION else 0 ), - obj["repeat_count"] if schema_version >= PROFILE_SCHEMA_VERSION else 1, + obj["repeat_count"] if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION else 1, ) @@ -1634,16 +1791,19 @@ class ControllerProfile: shift: ProfileShift = ProfileShift() turbo_defaults: TurboSettings = TurboSettings() turbo_overrides: tuple[TurboSettings | None, ...] = (None,) * len(LOGICAL_BUTTONS) + extra_button_map: tuple[int, ...] = (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS) def __post_init__(self) -> None: if type(self.button_map) is not tuple or len(self.button_map) != len( LOGICAL_BUTTONS ): raise ConfigManagerError("button map must contain 16 logical mappings") - for mapping in self.button_map: + if type(self.extra_button_map) is not tuple or len(self.extra_button_map) != len(EXTRA_BUTTONS): + raise ConfigManagerError("extra button map must contain seven mappings") + for mapping in (*self.button_map, *self.extra_button_map): if type(mapping) is not int or ( mapping != PROFILE_NONE_BUTTON - and not 0 <= mapping < len(LOGICAL_CONTROLS) + and not 0 <= mapping < len(OUTPUT_CONTROLS) ): raise ConfigManagerError("invalid logical control mapping") if not isinstance(self.left_stick, StickConfig) or not isinstance( @@ -1657,7 +1817,7 @@ class ControllerProfile: routed_triggers = [ trigger.output for trigger in (self.left_trigger, self.right_trigger) - if len(LOGICAL_BUTTONS) <= trigger.output < len(LOGICAL_CONTROLS) + if len(LOGICAL_BUTTONS) <= trigger.output < len(OUTPUT_CONTROLS) ] if len(routed_triggers) != len(set(routed_triggers)): raise ConfigManagerError( @@ -1767,7 +1927,7 @@ class ControllerProfile: raise ConfigManagerError("invalid profile size") version, size = struct.unpack_from("= PROFILE_SCHEMA_VERSION else PROFILE_LEGACY_SIZE + PROFILE_SIZE if version >= PROFILE_EXPANDED_SCHEMA_VERSION else PROFILE_LEGACY_SIZE ) if ( version < PROFILE_LEGACY_SCHEMA_VERSION @@ -1779,6 +1939,8 @@ class ControllerProfile: has_control_mapping = version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION has_action_controls = version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION sparse_macros = version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION + has_extra_buttons = version >= PROFILE_SCHEMA_VERSION + control_count = len(LOGICAL_CONTROLS) if has_extra_buttons else len(OUTPUT_CONTROLS) if sparse_macros: if payload[75] & 0xCC: raise ConfigManagerError("profile action flags are invalid") @@ -1811,6 +1973,11 @@ class ControllerProfile: motion_toggle_chord = struct.unpack_from("> 4) & 0x03) << 16 ) + if has_extra_buttons: + if any(value & 0x80 for value in payload[358:364]): + raise ConfigManagerError("invalid extra control mask") + switching_chord |= payload[362] << 18 + motion_toggle_chord |= payload[363] << 18 turbo_modes = tuple(payload[80:96]) macros: list[ControllerMacro] = [] stream_offset = 0 @@ -1823,8 +1990,10 @@ class ControllerProfile: trigger_mask = struct.unpack_from("> 2) & 0x1F - if cancel > len(LOGICAL_CONTROLS) - 1 and cancel != 0x1F: + if cancel >= control_count and cancel != 0x1F: raise ConfigManagerError("invalid sparse macro cancel control") step_count = descriptor[4] encoded_size = descriptor[5] @@ -1855,10 +2024,10 @@ class ControllerProfile: PROFILE_NONE_BUTTON if cancel == 0x1F else cancel, tuple(steps), payload[336 + macro_index * 2] - if version >= PROFILE_SCHEMA_VERSION + if version >= PROFILE_EXPANDED_SCHEMA_VERSION else 0, payload[337 + macro_index * 2] - if version >= PROFILE_SCHEMA_VERSION + if version >= PROFILE_EXPANDED_SCHEMA_VERSION else 1, ) ) @@ -1886,6 +2055,8 @@ class ControllerProfile: 0 if legacy_trigger == PROFILE_NONE_BUTTON else 1 << legacy_trigger ) cancel_control = payload[79] + if cancel_control != PROFILE_NONE_BUTTON and cancel_control >= control_count: + raise ConfigManagerError("invalid legacy macro cancel control") if has_action_controls: switching_chord |= (payload[75] & 0x03) << 16 trigger_mask |= ((payload[75] >> 2) & 0x03) << 16 @@ -1918,9 +2089,14 @@ class ControllerProfile: shift = ProfileShift() turbo_defaults = TurboSettings() turbo_overrides: list[TurboSettings | None] = [None] * len(LOGICAL_BUTTONS) - if version >= PROFILE_SCHEMA_VERSION: + if version >= PROFILE_EXPANDED_SCHEMA_VERSION: + if any(value != PROFILE_NONE_BUTTON and value >= control_count for value in (payload[256], payload[266])): + raise ConfigManagerError("invalid profile modifier") shortcuts = ProfileShortcuts(payload[256], tuple(payload[257:265])) - shift = ProfileShift(payload[265], payload[266], tuple(payload[267:283])) + shift = ProfileShift( + payload[265], payload[266], tuple(payload[267:283]), + tuple(payload[351:358]) if has_extra_buttons else (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS), + ) turbo_defaults = TurboSettings(*payload[283:286]) override_mask = struct.unpack_from(" 2 for mode in turbo_modes): raise ConfigManagerError("invalid legacy Turbo mode") @@ -1954,6 +2131,7 @@ class ControllerProfile: shift=shift, turbo_defaults=turbo_defaults, turbo_overrides=tuple(turbo_overrides), + extra_button_map=tuple(payload[344:351]) if has_extra_buttons else (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS), ) def to_bytes(self) -> bytes: @@ -1999,6 +2177,7 @@ class ControllerProfile: stream.extend(encoded_steps) payload[336 + macro_index * 2] = macro.playback payload[337 + macro_index * 2] = macro.repeat_count + payload[358 + macro_index] = (macro.trigger_mask >> 18) & 0x7F if len(stream) > PROFILE_MACRO_STREAM_SIZE: raise ConfigManagerError("profile macros exceed the 136-byte sparse stream") payload[120 : 120 + len(stream)] = stream @@ -2028,6 +2207,10 @@ class ControllerProfile: ) settings_offset += 3 struct.pack_into("> 18) & 0x7F + payload[363] = (self.motion_toggle_chord >> 18) & 0x7F return bytes(payload) def to_json_object(self) -> dict[str, Any]: @@ -2038,6 +2221,10 @@ class ControllerProfile: name: _control_name(self.button_map[index]) for index, name in enumerate(LOGICAL_BUTTONS) }, + "extra_button_map": { + name: _control_name(self.extra_button_map[index]) + for index, name in enumerate(EXTRA_BUTTONS) + }, "sticks": { "left": self.left_stick.to_json_object(), "right": self.right_stick.to_json_object(), @@ -2105,12 +2292,14 @@ class ControllerProfile: if schema_version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION else "macro" ) - if schema_version >= PROFILE_SCHEMA_VERSION: + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION: fields.extend(("shortcuts", "shift", "turbo_settings")) + if schema_version >= PROFILE_SCHEMA_VERSION: + fields.append("extra_button_map") obj = _require_object(value, fields, "profile") expected_size = ( PROFILE_SIZE - if schema_version >= PROFILE_SCHEMA_VERSION + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION else PROFILE_LEGACY_SIZE ) if _require_int(obj["size"], "profile.size", 0, 0xFFFF) != expected_size: @@ -2118,6 +2307,10 @@ class ControllerProfile: button_map = _require_object( obj["button_map"], LOGICAL_BUTTONS, "profile.button_map" ) + extras = ( + _require_object(obj["extra_button_map"], EXTRA_BUTTONS, "profile.extra_button_map") + if schema_version >= PROFILE_SCHEMA_VERSION else dict.fromkeys(EXTRA_BUTTONS) + ) sticks = _require_object(obj["sticks"], ("left", "right"), "profile.sticks") triggers = _require_object( obj["triggers"], ("left", "right"), "profile.triggers" @@ -2145,7 +2338,7 @@ class ControllerProfile: right_trigger = _migrate_legacy_trigger_threshold(right_trigger) mask_parser = ( - _control_mask_from_json + (lambda value, name: _control_mask_from_json(value, name, schema_version=schema_version)) if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION else _button_mask_from_json ) @@ -2195,7 +2388,7 @@ class ControllerProfile: if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: trigger_mask = mask_parser(macro["trigger"], "profile.macro.trigger") cancel_control = ( - _control_index(macro["cancel"], "profile.macro.cancel") + _control_index(macro["cancel"], "profile.macro.cancel", schema_version=schema_version) if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION else _button_index(macro["cancel"], "profile.macro.cancel") ) @@ -2212,9 +2405,9 @@ class ControllerProfile: shift = ProfileShift() turbo_defaults = TurboSettings() turbo_overrides: list[TurboSettings | None] = [None] * len(LOGICAL_BUTTONS) - if schema_version >= PROFILE_SCHEMA_VERSION: - shortcuts = ProfileShortcuts.from_json_object(obj["shortcuts"]) - shift = ProfileShift.from_json_object(obj["shift"]) + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION: + shortcuts = ProfileShortcuts.from_json_object(obj["shortcuts"], schema_version=schema_version) + shift = ProfileShift.from_json_object(obj["shift"], schema_version=schema_version) settings = _require_object( obj["turbo_settings"], ("defaults", "overrides"), @@ -2272,7 +2465,7 @@ class ControllerProfile: _require_enum( turbo[name], TURBO_MODES - if schema_version >= PROFILE_SCHEMA_VERSION + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION else TURBO_MODES[:3], f"profile.turbo.{name}", ) @@ -2282,6 +2475,10 @@ class ControllerProfile: shift=shift, turbo_defaults=turbo_defaults, turbo_overrides=tuple(turbo_overrides), + extra_button_map=tuple( + _control_index(extras[name], f"profile.extra_button_map.{name}") + for name in EXTRA_BUTTONS + ), ) @classmethod @@ -2626,15 +2823,22 @@ def capture_macro_steps(page: MacroCapturePage) -> tuple[MacroStep, ...]: def read_runtime_diagnostics(device: UsbDevice) -> RuntimeDiagnostics: envelope = _control_in(device, OP_RUNTIME_DIAGNOSTICS) _raise_status(envelope) - if len(envelope.payload) != 32: + if len(envelope.payload) not in (32, 40): raise ConfigManagerError("invalid runtime-diagnostics payload") counters = struct.unpack_from("<7I", envelope.payload) + ingress_drops, output_drops = ( + struct.unpack_from("<2I", envelope.payload, 32) + if len(envelope.payload) == 40 + else (None, None) + ) return RuntimeDiagnostics( *counters, active_slots=envelope.payload[28], rumble_capable_slots=envelope.payload[29], feedback_pending_slots=envelope.payload[30], rumble_pending_slots=envelope.payload[31], + switch2_ingress_drops=ingress_drops, + switch2_output_drops=output_drops, ) @@ -3144,6 +3348,7 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration: _raise_status(envelope) payload = envelope.payload identities: tuple[ControllerIdentity, ...] = () + joycon_mode = JOYCON_MODE_PAIRED if envelope.schema_version == 1: if len(payload) != 4 or payload[2:] != bytes(2): raise ConfigManagerError("unsupported configuration object") @@ -3152,14 +3357,15 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration: if len(payload) != 8 or payload[3:] != bytes(5): raise ConfigManagerError("unsupported configuration object") requested_mode = payload[2] - elif envelope.schema_version == CONFIGURATION_SCHEMA_VERSION: + elif envelope.schema_version in (3, CONFIGURATION_SCHEMA_VERSION): if len(payload) != CONFIGURATION_SIZE: raise ConfigManagerError("unsupported configuration object") count = payload[3] end = 8 + count * CONTROLLER_IDENTITY_SIZE if ( count > NATIVE_SWITCH_CONTROLLER_CAPACITY - or payload[4:8] != bytes(4) + or payload[5:8] != bytes(3) + or (envelope.schema_version == 3 and payload[4] != 0) or payload[end:] != bytes(CONFIGURATION_SIZE - end) ): raise ConfigManagerError("invalid native rumble approval encoding") @@ -3172,6 +3378,8 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration: if identities != _canonical_native_switch_controllers(identities): raise ConfigManagerError("noncanonical native rumble approval order") requested_mode = payload[2] + if envelope.schema_version == CONFIGURATION_SCHEMA_VERSION: + joycon_mode = payload[4] else: raise ConfigManagerError("unsupported configuration object") pairing_window_seconds = struct.unpack_from(" AdapterConfiguration: raise ConfigManagerError("invalid stored pairing-window duration") if requested_mode >= len(REQUESTED_MODE_NAMES): raise ConfigManagerError(f"invalid stored requested USB mode {requested_mode}") + if joycon_mode >= len(JOYCON_MODE_NAMES): + raise ConfigManagerError(f"invalid stored Joy-Con2 mode {joycon_mode}") return AdapterConfiguration( pairing_window_seconds=pairing_window_seconds, generation=envelope.generation, @@ -3190,6 +3400,7 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration: requested_mode=requested_mode, native_switch_controllers=identities, schema_version=envelope.schema_version, + joycon_mode=joycon_mode, ) @@ -3229,16 +3440,23 @@ def write_configuration( configuration.requested_mode ) is not int or not 0 <= configuration.requested_mode < len(REQUESTED_MODE_NAMES): raise ConfigManagerError("invalid requested USB mode") + _require_int(configuration.joycon_mode, "Joy-Con2 mode", 0, 1) + if ( + configuration.schema_version < CONFIGURATION_SCHEMA_VERSION + and configuration.joycon_mode != JOYCON_MODE_PAIRED + ): + raise ConfigManagerError("Joy-Con2 player mode requires schema 4 firmware") identities = _canonical_native_switch_controllers( configuration.native_switch_controllers ) - if configuration.schema_version == CONFIGURATION_SCHEMA_VERSION: + if configuration.schema_version in (3, CONFIGURATION_SCHEMA_VERSION): payload = ( struct.pack( - " TransactionStatus: + """Persist the adapter-wide player mode without rebooting or changing profiles.""" + _require_int(mode, "Joy-Con2 mode", 0, 1) + before = read_configuration(device) + if before.schema_version < CONFIGURATION_SCHEMA_VERSION: + raise ConfigManagerError("Joy-Con2 player mode requires schema 4 firmware") + return write_configuration(device, replace(before, joycon_mode=mode), timeout) + + def set_native_switch_rumble_approval( device: UsbDevice, identity: ControllerIdentity, @@ -3298,7 +3527,7 @@ def set_native_switch_rumble_approval( _require_bool(approved, "native rumble approval") _canonical_native_switch_controllers((identity,)) before = read_configuration(device) - if before.schema_version != CONFIGURATION_SCHEMA_VERSION: + if before.schema_version < 3: raise ConfigManagerError("native rumble approval requires schema 3 firmware") identities = tuple( item for item in before.native_switch_controllers if item != identity @@ -3367,6 +3596,7 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, PROFILE_ACTION_CONTROL_SCHEMA_VERSION, PROFILE_SPARSE_MACRO_SCHEMA_VERSION, + PROFILE_EXPANDED_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile-list schema") @@ -3444,6 +3674,7 @@ def read_selected_profile(device: UsbDevice) -> ControllerProfile: PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, PROFILE_ACTION_CONTROL_SCHEMA_VERSION, PROFILE_SPARSE_MACRO_SCHEMA_VERSION, + PROFILE_EXPANDED_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile schema") @@ -3541,12 +3772,20 @@ def identify_controller(device: UsbDevice, identity: ControllerIdentity) -> None def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest: _raise_status(envelope) - if ( - envelope.schema_version != PROFILE_PLAYTEST_SCHEMA_VERSION - or len(envelope.payload) != PROFILE_PLAYTEST_SIZE - ): + expected_size = { + PROFILE_PLAYTEST_LEGACY_SCHEMA_VERSION: PROFILE_PLAYTEST_LEGACY_SIZE, + PROFILE_PLAYTEST_EXTRA_BUTTON_SCHEMA_VERSION: PROFILE_PLAYTEST_EXTRA_BUTTON_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): + raise ConfigManagerError("invalid playtest controller layout") + if extra_buttons & ~0x7F: + raise ConfigManagerError("invalid playtest extra buttons") flags = payload[0] if flags & ~0x03 or flags != envelope.flags or payload[41] != 0: raise ConfigManagerError("invalid profile playtest flags") @@ -3596,6 +3835,8 @@ def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest: battery=payload[39], capabilities=payload[40], motion=motion_values if has_motion else None, + extra_buttons=extra_buttons, + layout=PROFILE_PLAYTEST_LAYOUTS[layout_code], ) @@ -4034,11 +4275,22 @@ def _print_pairings(snapshot: PairingSnapshot) -> None: print("Warning: additional pairings did not fit in the response.") -def _print_profiles(entries: Sequence[ProfileListEntry]) -> None: +def _print_profiles( + entries: Sequence[ProfileListEntry], *, physical_only: bool = False +) -> None: for index, entry in enumerate(entries): identity = entry.identity + if physical_only and (identity.is_global_fallback or identity.is_joycon_pair): + continue if identity.is_global_fallback: description = "global fallback" + elif identity.is_joycon_pair: + description = ( + f"Nintendo Joy-Con 2 (L+R) · {identity.transport_text} " + f"L {identity.address_text} address-type {identity.address_type}; " + f"R {identity.partner_address_text} " + f"address-type {identity.partner_address_type}" + ) else: description = ( f"{identity.transport_text} {identity.address_text} " @@ -4191,6 +4443,14 @@ def build_parser() -> argparse.ArgumentParser: reboot.add_argument("target", choices=("bootsel",)) mode = commands.add_parser("mode", help="select the persistent USB mode") mode.add_argument("mode", choices=SELECTABLE_MODE_NAMES) + joycon_mode = commands.add_parser( + "joycon-mode", + help="read or apply the persistent Joy-Con2 player mode without reconnecting", + ) + joycon_mode.add_argument("mode", nargs="?", choices=JOYCON_MODE_NAMES) + joycon_mode.add_argument( + "--json", action="store_true", help="emit the committed mode as JSON" + ) config = commands.add_parser("config", help="read or change configuration") config_commands = config.add_subparsers(dest="config_command", required=True) @@ -4371,6 +4631,7 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"Configuration generation: {configuration.generation}") print(f"Configuration CRC: {configuration.crc:08x}") print(f"Pairing window: {configuration.pairing_window_seconds} seconds") + print(f"Joy-Con2 player mode: {JOYCON_MODE_NAMES[configuration.joycon_mode]}") elif args.command == "diagnostics": diagnostics = read_runtime_diagnostics(device) print(f"Initialization stage: {diagnostics.initialization_stage}") @@ -4384,6 +4645,9 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"Rumble-capable slots: {diagnostics.rumble_capable_slots}") print(f"Feedback-pending slots: {diagnostics.feedback_pending_slots}") print(f"Rumble-pending slots: {diagnostics.rumble_pending_slots}") + if diagnostics.switch2_ingress_drops is not None: + print(f"Switch 2 ingress drops: {diagnostics.switch2_ingress_drops}") + print(f"Switch 2 output drops: {diagnostics.switch2_output_drops}") elif args.command == "haptics-experiment": _run_haptics_experiment_command(device, args) elif args.command == "reboot": @@ -4396,6 +4660,27 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"USB mode changed to {args.mode}.") else: print(f"USB mode is already {args.mode}.") + elif args.command == "joycon-mode": + if args.mode is not None: + set_joycon_mode(device, JOYCON_MODE_NAMES.index(args.mode), args.timeout) + configuration = read_configuration(device) + mode_name = JOYCON_MODE_NAMES[configuration.joycon_mode] + supported = configuration.schema_version >= CONFIGURATION_SCHEMA_VERSION + if args.json: + print( + json.dumps( + { + "mode": mode_name, + "generation": configuration.generation, + "supported": supported, + } + ) + ) + else: + print(f"Joy-Con2 player mode: {mode_name}") + print(f"Configuration generation: {configuration.generation}") + if not supported: + print("Changing Joy-Con2 player mode requires schema 4 firmware.") elif args.command == "config": if args.config_command == "show": configuration = read_configuration(device) @@ -4404,6 +4689,7 @@ def main(argv: Sequence[str] | None = None) -> int: "requested_mode=" f"{REQUESTED_MODE_NAMES[configuration.requested_mode]}" ) + print(f"joycon_mode={JOYCON_MODE_NAMES[configuration.joycon_mode]}") print(f"generation={configuration.generation}") print(f"crc={configuration.crc:08x}") for identity in configuration.native_switch_controllers: @@ -4437,9 +4723,9 @@ def main(argv: Sequence[str] | None = None) -> int: elif args.native_rumble_command == "list": configuration = read_configuration(device) print(NATIVE_SWITCH_RUMBLE_APPROVAL_NOTE) - if configuration.schema_version != CONFIGURATION_SCHEMA_VERSION: + if configuration.schema_version < 3: print("Native rumble approval requires schema 3 firmware.") - _print_profiles(entries) + _print_profiles(entries, physical_only=True) for index, entry in enumerate(entries): if entry.identity in configuration.native_switch_controllers: print( diff --git a/src/switch_pico_bridge/profile_web.py b/src/switch_pico_bridge/profile_web.py index 57e3ffa..7af8a63 100644 --- a/src/switch_pico_bridge/profile_web.py +++ b/src/switch_pico_bridge/profile_web.py @@ -24,17 +24,35 @@ _ASSET_TYPES = { "text/javascript; charset=utf-8", ), "/style.css": ("profile_editor.css", "text/css; charset=utf-8"), - "/assets/controller-switch-pro.svg": ( - "assets/controller-switch-pro.svg", - "image/svg+xml", + "/assets/controller_layouts.js": ( + "controller_layouts.js", "text/javascript; charset=utf-8", ), - "/assets/controller-dualsense.svg": ( - "assets/controller-dualsense.svg", - "image/svg+xml", + "/assets/switch-pro-controller-simple.svg": ( + "assets/switch-pro-controller-simple.svg", "image/svg+xml", ), - "/assets/controller-xbox.svg": ( - "assets/controller-xbox.svg", - "image/svg+xml", + "/assets/switch-2-pro-controller-simple.svg": ( + "assets/switch-2-pro-controller-simple.svg", "image/svg+xml", + ), + "/assets/switch-2-joycon-left.svg": ( + "assets/switch-2-joycon-left.svg", "image/svg+xml", + ), + "/assets/switch-2-joycon-single.svg": ( + "assets/switch-2-joycon-single.svg", "image/svg+xml", + ), + "/assets/switch-2-joycons-connected.svg": ( + "assets/switch-2-joycons-connected.svg", "image/svg+xml", + ), + "/assets/ps5-dualsense-simple.svg": ( + "assets/ps5-dualsense-simple.svg", "image/svg+xml", + ), + "/assets/xbox-controller-simple.svg": ( + "assets/xbox-controller-simple.svg", "image/svg+xml", + ), + "/assets/wii-remote-simple.svg": ( + "assets/wii-remote-simple.svg", "image/svg+xml", + ), + "/assets/wii-remote-nunchuk-simple.svg": ( + "assets/wii-remote-nunchuk-simple.svg", "image/svg+xml", ), } @@ -86,6 +104,13 @@ _CONTROL_LABELS = { "system": "Home", "left_stick": "Left Stick", "right_stick": "Right Stick", + "c": "C", + "gl": "GL", + "gr": "GR", + "left_sl": "Left SL", + "left_sr": "Left SR", + "right_sl": "Right SL", + "right_sr": "Right SR", }, "playstation": { "north": "Triangle", @@ -103,31 +128,98 @@ _CONTROL_LABELS = { "left_stick": "L3", "right_stick": "R3", }, + "wii": { + "north": "B", + "east": "2", + "south": "1", + "west": "A", + "select": "Minus", + "start": "Plus", + "system": "Home", + }, } def _controller_presentation( identity: config_manager.ControllerIdentity, + layout: str | None = None, ) -> dict[str, str]: + if identity.is_joycon_pair: + return { + "model": "Nintendo Joy-Con 2 (L+R)", + "style": "switch", + "layout": "joycon2-pair", + } + live_layouts = { + "joycon2-left": ("Nintendo Joy-Con 2 (L)", "switch", {0x2067}), + "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-nunchuk": ("Nintendo Wii Remote + Nunchuk", "wii", {0x0306, 0x0330}), + } + live = live_layouts.get(layout) + if live is not None and ( + identity.is_global_fallback + or identity.vendor_id == 0x057E and identity.product_id in live[2] + ): + return {"model": live[0], "style": live[1], "layout": cast(str, layout)} if identity.is_global_fallback: - return {"model": "Generic controller", "style": "generic"} + return {"model": "Generic controller", "style": "generic", "layout": "generic"} known = { - (0x057E, 0x2009): ("Nintendo Switch Pro Controller", "switch"), - (0x054C, 0x0CE6): ("Sony DualSense", "playstation"), - (0x054C, 0x09CC): ("Sony DualShock 4", "playstation"), + (0x057E, 0x2009): ("Nintendo Switch Pro Controller", "switch", "switch-pro"), + (0x057E, 0x2069): ("Nintendo Switch 2 Pro Controller", "switch", "switch2-pro"), + (0x057E, 0x2067): ("Nintendo Joy-Con 2 (L)", "switch", "joycon2-left"), + (0x057E, 0x2066): ("Nintendo Joy-Con 2 (R)", "switch", "joycon2-right"), + (0x054C, 0x0CE6): ("Sony DualSense", "playstation", "dualsense"), + (0x054C, 0x0DF2): ("Sony DualSense Edge", "playstation", "dualsense"), + (0x054C, 0x09CC): ("Sony DualShock 4", "playstation", "generic"), + # VID/PID cannot distinguish Wii extensions or Wii U Pro from Remote Plus. + (0x057E, 0x0306): ("Nintendo Wii controller", "generic", "generic"), + (0x057E, 0x0330): ("Nintendo Wii / Wii U controller", "generic", "generic"), } exact = known.get((identity.vendor_id, identity.product_id)) if exact is not None: - model, style = exact - return {"model": model, "style": style} + model, style, model_layout = exact + return {"model": model, "style": style, "layout": model_layout} if identity.vendor_id == 0x054C: - return {"model": "Sony controller", "style": "playstation"} + return {"model": "Sony controller", "style": "playstation", "layout": "generic"} if identity.vendor_id == 0x045E: - return {"model": "Xbox controller", "style": "xbox"} + return {"model": "Xbox controller", "style": "xbox", "layout": "xbox"} if identity.vendor_id == 0x2DC8: - return {"model": "8BitDo controller", "style": "switch"} - return {"model": "Connected controller", "style": "generic"} + return {"model": "8BitDo controller", "style": "switch", "layout": "generic"} + return {"model": "Connected controller", "style": "generic", "layout": "generic"} + + +def _source_controls(controller: dict[str, str]) -> list[str]: + layout = controller["layout"] + if layout == "switch2-pro": + controls = config_manager.OUTPUT_CONTROLS + ("c", "gl", "gr") + elif layout == "joycon2-pair": + controls = config_manager.OUTPUT_CONTROLS + ( + "c", "left_sl", "left_sr", "right_sl", "right_sr", + ) + elif layout in {"joycon2-left", "joycon2-right"}: + controls = ( + "south", "east", "west", "north", "left_shoulder", + "right_shoulder", "left_stick", + ) + ( + ("select", "capture", "left_trigger", "left_sl", "left_sr") + if layout == "joycon2-left" + else ("start", "system", "right_trigger", "c", "right_sl", "right_sr") + ) + elif layout in {"wii-remote", "wii-nunchuk"}: + controls = ( + "south", "east", "west", "north", "select", "start", "system", + "dpad_up", "dpad_down", "dpad_left", "dpad_right", + ) + ( + ("left_shoulder", "right_shoulder") if layout == "wii-nunchuk" else () + ) + elif layout in {"switch-pro", "dualsense", "xbox"} or controller["style"] == "playstation": + controls = config_manager.OUTPUT_CONTROLS + else: + controls = config_manager.LOGICAL_CONTROLS + return list(controls) def _controller_label( @@ -149,6 +241,9 @@ def _controller_label( } model = friendly_models.get(presentation["model"], presentation["model"]) address_suffix = ":".join(f"{octet:02X}" for octet in identity.address[-2:]) + if identity.is_joycon_pair: + partner_suffix = ":".join(f"{octet:02X}" for octet in identity.partner_address[-2:]) + return f"{model} · L {address_suffix} + R {partner_suffix}" return f"{model} · {address_suffix}" @@ -201,6 +296,8 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): { "buttons": list(config_manager.LOGICAL_BUTTONS), "controls": list(config_manager.LOGICAL_CONTROLS), + "output_controls": list(config_manager.OUTPUT_CONTROLS), + "extra_buttons": list(config_manager.EXTRA_BUTTONS), "default_switching_chord": [ "left_shoulder", "right_shoulder", @@ -260,6 +357,9 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): if path == "/api/profiles": self._api_call(self._list_profiles) return + if path == "/api/joycon-mode": + self._api_call(self._read_joycon_mode) + return capture = self._parse_capture_path(path) if capture is not None: identity_index, profile_index, action = capture @@ -288,6 +388,9 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): if not self._allow_mutation(): return path = urlsplit(self.path).path + if path == "/api/joycon-mode": + self._api_call(self._set_joycon_mode) + return identity_selection = self._parse_identity_path(path) if identity_selection is not None: identity_index, action = identity_selection @@ -434,25 +537,22 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): def _list_profiles(self) -> dict[str, Any]: device = self.profile_server.find_device() entries = config_manager.list_profiles(device) - return { - "identities": [ - { - "index": index, - "label": _controller_label(entry.identity, entry.alias), - "key": entry.identity.to_bytes().hex(), - "active_profile": entry.active_profile_index + 1, - "controller": _controller_presentation(entry.identity), - "alias": entry.alias, - "modifier_controls": list( - config_manager.LOGICAL_CONTROLS - if _controller_presentation(entry.identity)["style"] - in {"xbox", "playstation"} - else config_manager.LOGICAL_BUTTONS - ), - } - for index, entry in enumerate(entries) - ] - } + identities = [] + for index, entry in enumerate(entries): + controller = _controller_presentation(entry.identity) + source_controls = _source_controls(controller) + identities.append({ + "index": index, + "label": _controller_label(entry.identity, entry.alias), + "key": entry.identity.to_bytes().hex(), + "identity": entry.identity.to_json_object(), + "active_profile": entry.active_profile_index + 1, + "controller": controller, + "alias": entry.alias, + "source_controls": source_controls, + "modifier_controls": source_controls, + }) + return {"identities": identities} def _read_profile(self, identity_index: int, profile_index: int) -> dict[str, Any]: device = self.profile_server.find_device() @@ -469,7 +569,7 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): def _read_playtest(self, identity_index: int, profile_index: int) -> dict[str, Any]: device = self.profile_server.find_device() - _, identity = self._entries_and_identity(device, identity_index) + entries, identity = self._entries_and_identity(device, identity_index) config_manager.select_profile(device, identity, profile_index) playtest = config_manager.read_profile_playtest(device) result = playtest.to_json_object() @@ -480,12 +580,16 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): else None ) result["controller"] = ( - _controller_presentation(playtest.identity) - if playtest.identity is not None - else None + _controller_presentation(playtest.identity, playtest.layout) + if playtest.connected and playtest.identity is not None + else _controller_presentation(identity) ) + result["source_controls"] = _source_controls(result["controller"]) result["label"] = ( - _controller_label(playtest.identity) + _controller_label( + playtest.identity, + entries[identity_index].alias if playtest.identity == identity else "", + ) if playtest.identity is not None else None ) @@ -771,6 +875,40 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): ) return value + def _read_joycon_mode(self) -> dict[str, Any]: + configuration = config_manager.read_configuration( + self.profile_server.find_device() + ) + supported = configuration.schema_version >= 4 + mode = ( + configuration.joycon_mode + if supported + else config_manager.JOYCON_MODE_PAIRED + ) + return { + "mode": config_manager.JOYCON_MODE_NAMES[mode], + "generation": configuration.generation, + "supported": supported, + } + + def _set_joycon_mode(self) -> dict[str, Any]: + body = self._read_json_object() + mode = body.get("mode") + if ( + set(body) != {"mode"} + or not isinstance(mode, str) + or mode not in config_manager.JOYCON_MODE_NAMES + ): + raise config_manager.ConfigManagerError( + "request must contain only mode: paired or individual" + ) + config_manager.set_joycon_mode( + self.profile_server.find_device(), + config_manager.JOYCON_MODE_NAMES.index(mode), + self.profile_server.operation_timeout, + ) + return self._read_joycon_mode() + def _write_profile(self, identity_index: int, profile_index: int) -> dict[str, Any]: profile = config_manager.ControllerProfile.from_json(self._read_json_body()) device = self.profile_server.find_device() diff --git a/src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt b/src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt deleted file mode 100644 index 64f58ef..0000000 --- a/src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt +++ /dev/null @@ -1,35 +0,0 @@ -Controller artwork source -========================= - -The following files are redistributed from AL2009man/Gamepad-Asset-Pack -revision a6d1113b0b3b2438bbbcb08a33f9a25eb2391d7c: - -- controller-dualsense.svg -- controller-switch-pro.svg -- controller-xbox.svg - -Source: https://github.com/AL2009man/Gamepad-Asset-Pack -Copyright (c) 2024 Al. Lopez -License: MIT - -MIT License - -Copyright (c) 2024 Al. Lopez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/switch_pico_bridge/web/assets/controller-dualsense.svg b/src/switch_pico_bridge/web/assets/controller-dualsense.svg deleted file mode 100644 index 92a893e..0000000 --- a/src/switch_pico_bridge/web/assets/controller-dualsense.svg +++ /dev/null @@ -1,2013 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/switch_pico_bridge/web/assets/controller-switch-pro.svg b/src/switch_pico_bridge/web/assets/controller-switch-pro.svg deleted file mode 100644 index 5b68d3e..0000000 --- a/src/switch_pico_bridge/web/assets/controller-switch-pro.svg +++ /dev/null @@ -1,463 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/switch_pico_bridge/web/assets/controller-xbox.svg b/src/switch_pico_bridge/web/assets/controller-xbox.svg deleted file mode 100644 index a8c9e97..0000000 --- a/src/switch_pico_bridge/web/assets/controller-xbox.svg +++ /dev/null @@ -1,1383 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/switch_pico_bridge/web/assets/ps5-dualsense-simple.svg b/src/switch_pico_bridge/web/assets/ps5-dualsense-simple.svg new file mode 100644 index 0000000..893ebe0 --- /dev/null +++ b/src/switch_pico_bridge/web/assets/ps5-dualsense-simple.svg @@ -0,0 +1,117 @@ + + PS5 DualSense wireless controller — white + Front-view white-and-black PlayStation 5 DualSense controller, with symmetrical analog sticks, a touchpad with blue edge lights, directional buttons, triangle circle cross square controls, speaker, PlayStation and microphone buttons. Transparent background. Lightweight vector shapes and simple gradients; no filters, textures, fonts or external images. + Visual reference: https://www.playstation.com/en-us/accessories/dualsense-wireless-controller/ . Independently drawn vector artwork. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/switch-2-joycon-left.svg b/src/switch_pico_bridge/web/assets/switch-2-joycon-left.svg new file mode 100644 index 0000000..2acac85 --- /dev/null +++ b/src/switch_pico_bridge/web/assets/switch-2-joycon-left.svg @@ -0,0 +1,69 @@ + + Nintendo Switch 2 — left Joy-Con 2 + Front-view left Joy-Con 2 with a graphite shell, light-blue accent ring and attachment edge, analog stick, four directional buttons, minus and capture buttons. Transparent background; four gradients, no filters, textures or external assets. + Visual reference: https://www.nintendo.com/us/store/products/nintendo-switch-2-joy-con-2-l-r-light-blue-light-red-123677/ and https://www.nintendo.com/us/store/products/nintendo-switch-2-joy-con-2-charging-grip-123690/ . Independently drawn vector artwork, without embedded reference images. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/switch-2-joycon-single.svg b/src/switch_pico_bridge/web/assets/switch-2-joycon-single.svg new file mode 100644 index 0000000..b3cb7e1 --- /dev/null +++ b/src/switch_pico_bridge/web/assets/switch-2-joycon-single.svg @@ -0,0 +1,76 @@ + + Nintendo Switch 2 — right Joy-Con 2 + Front-view right Joy-Con 2 with a graphite shell, coral accent ring, ABXY buttons, analog stick, plus, home and C buttons. Transparent background; four gradients, no filters, textures or external assets. + Visual reference: https://www.nintendo.com/us/store/products/nintendo-switch-2-joy-con-2-l-r-light-blue-light-red-123677/ and https://www.nintendo.com/us/store/products/nintendo-switch-2-joy-con-2-charging-grip-123690/ . Independently drawn vector artwork, without embedded reference images. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/switch-2-joycons-connected.svg b/src/switch_pico_bridge/web/assets/switch-2-joycons-connected.svg new file mode 100644 index 0000000..5270231 --- /dev/null +++ b/src/switch_pico_bridge/web/assets/switch-2-joycons-connected.svg @@ -0,0 +1,143 @@ + + Nintendo Switch 2 — connected Joy-Con 2 pair + Front-view left and right Joy-Con 2 attached to a central controller grip. Graphite shells, light-blue and coral stick rings, directional and ABXY buttons, capture, home and C buttons. Transparent background; four gradients, no filters, textures or external assets. + Visual reference: https://www.nintendo.com/us/store/products/nintendo-switch-2-joy-con-2-l-r-light-blue-light-red-123677/ and https://www.nintendo.com/us/store/products/nintendo-switch-2-joy-con-2-charging-grip-123690/ . Independently drawn vector artwork, without embedded reference images. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/switch-2-pro-controller-simple.svg b/src/switch_pico_bridge/web/assets/switch-2-pro-controller-simple.svg new file mode 100644 index 0000000..018ce86 --- /dev/null +++ b/src/switch_pico_bridge/web/assets/switch-2-pro-controller-simple.svg @@ -0,0 +1,132 @@ + + Nintendo Switch 2 Pro Controller — lightweight transparent illustration + A simplified graphite and ivory Nintendo Switch 2 Pro Controller on a transparent background. Five simple gradients and solid vector shapes; no filters, textures, masks, shadows, fonts, or external assets. + Reference: https://www.nintendo.com/us/store/products/nintendo-switch-2-pro-controller-123674/ . Original vector illustration; no embedded photographs or external assets. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/switch-pro-controller-simple.svg b/src/switch_pico_bridge/web/assets/switch-pro-controller-simple.svg new file mode 100644 index 0000000..9388351 --- /dev/null +++ b/src/switch_pico_bridge/web/assets/switch-pro-controller-simple.svg @@ -0,0 +1,113 @@ + + Nintendo Switch Pro Controller — original model + Front-view original Nintendo Switch Pro Controller, with a smoky charcoal face, separate dark grip panels, black shoulder buttons, offset analog sticks, cross directional pad, Nintendo ABXY layout, capture and home buttons, and lower player indicators. Transparent background. Lightweight vector shapes and simple gradients; no filters, textures, fonts or external images. + Visual reference: https://www.nintendo.com/us/store/products/pro-controller/ . Independently drawn vector artwork. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/wii-remote-nunchuk-simple.svg b/src/switch_pico_bridge/web/assets/wii-remote-nunchuk-simple.svg new file mode 100644 index 0000000..c1203b4 --- /dev/null +++ b/src/switch_pico_bridge/web/assets/wii-remote-nunchuk-simple.svg @@ -0,0 +1,149 @@ + + Wii Remote and Nunchuk — connected white controllers + Original white Nintendo Wii Remote beside a white Nunchuk, joined by a gently looping cable at the remote expansion connector. The Nunchuk shows its raised analog stick and rear C and Z buttons in a three-quarter view. Transparent background. Simple vector gradients, with no filters, textures, fonts or external assets. + Visual references: https://commons.wikimedia.org/wiki/File:Nintendo-Wii-Remote-wNunchuk.jpg and https://en.wikipedia.org/wiki/Wii_Remote . Independently drawn artwork; no embedded photographs. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/wii-remote-simple.svg b/src/switch_pico_bridge/web/assets/wii-remote-simple.svg new file mode 100644 index 0000000..8d8cc27 --- /dev/null +++ b/src/switch_pico_bridge/web/assets/wii-remote-simple.svg @@ -0,0 +1,95 @@ + + Wii Remote — original white controller + Front-view original white Nintendo Wii Remote, with power, cross directional pad, A, minus, home, plus, speaker, 1 and 2 buttons and blue player indicator. Transparent background. Simple vector gradients, with no filters, textures, fonts or external assets. + Visual references: https://commons.wikimedia.org/wiki/File:Nintendo-Wii-Remote-wNunchuk.jpg and https://en.wikipedia.org/wiki/Wii_Remote . Independently drawn artwork; no embedded photographs. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/xbox-controller-simple.svg b/src/switch_pico_bridge/web/assets/xbox-controller-simple.svg new file mode 100644 index 0000000..386fef3 --- /dev/null +++ b/src/switch_pico_bridge/web/assets/xbox-controller-simple.svg @@ -0,0 +1,97 @@ + + Xbox Wireless Controller — Carbon Black + Front-view Xbox Series X and Series S wireless controller, with offset analog sticks, a faceted hybrid directional pad, colored ABXY letters, Xbox, view, menu and share buttons. Transparent background. Lightweight vector shapes and simple gradients; no filters, textures, fonts or external images. + Visual reference: https://www.xbox.com/en-US/accessories/controllers/xbox-wireless-controller . Independently drawn vector artwork. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/controller_layouts.js b/src/switch_pico_bridge/web/controller_layouts.js new file mode 100644 index 0000000..5c901dd --- /dev/null +++ b/src/switch_pico_bridge/web/controller_layouts.js @@ -0,0 +1,192 @@ +"use strict"; + +// Coordinates are in the supplied SVGs' original viewBoxes, including their +// offsets and nested group translations. A single stage rotates art + targets. +const ControllerLayouts = (() => { + const point = (x, y, label, glyph) => ({ x, y, label, glyph }); + const back = (label, glyph) => ({ label, glyph, offArt: true }); + const dpad = (x, y, distance) => ({ + dpad_up: point(x, y - distance, "D-pad Up", "▲"), + dpad_right: point(x + distance, y, "D-pad Right", "▶"), + dpad_down: point(x, y + distance, "D-pad Down", "▼"), + dpad_left: point(x - distance, y, "D-pad Left", "◀"), + }); + const triggers = { + left_trigger: back("Left trigger · rear", "LT"), + right_trigger: back("Right trigger · rear", "RT"), + }; + const xbox = { + name: "Xbox Wireless Controller", style: "xbox", asset: "xbox-controller-simple.svg", + viewBox: [0, 0, 800, 552], minWidth: 640, + controls: { + ...triggers, + left_shoulder: point(204, 39, "LB", "LB"), right_shoulder: point(596, 39, "RB", "RB"), + left_stick: point(204, 177, "Left stick click", "L3"), right_stick: point(501, 298, "Right stick click", "R3"), + ...dpad(295, 306, 37), + north: point(600, 131, "Y", "Y"), east: point(652, 182, "B", "B"), + south: point(600, 233, "A", "A"), west: point(548, 182, "X", "X"), + select: point(340, 180, "View", "View"), start: point(460, 180, "Menu", "Menu"), + capture: point(400, 225, "Share", "Share"), system: point(400, 93, "Xbox", "Xbox"), + }, + }; + const leftFace = { + left_stick: point(84, 139, "Left stick click", "L3"), + select: point(133, 62.5, "Minus", "−"), capture: point(119, 374, "Capture", "Cap"), + ...dpad(84, 278, 39), + }; + // The SVG's left/right directional centers are 40 units from its center. + leftFace.dpad_left.x = 44; + leftFace.dpad_right.x = 124; + const rightFace = { + north: point(80, 100, "X", "X"), west: point(40, 139, "Y", "Y"), + east: point(120, 139, "A", "A"), south: point(80, 178, "B", "B"), + right_stick: point(80, 276, "Right stick click", "R3"), + start: point(31, 63, "Plus", "+"), system: point(45, 374, "Home", "Home"), + c: point(45, 430, "C", "C"), + }; + const translate = (controls, x, y = 0) => Object.fromEntries( + Object.entries(controls).map(([id, value]) => [id, { ...value, x: value.x + x, y: value.y + y }]) + ); + const railNote = "Rail SL/SR are source-only extras and also emit the left/right shoulder source in solo mode. The physical L or R button shares that shoulder source. Each source ID is editable once; changing an extra does not disable its shoulder alias."; + const wiiCommon = { + select: point(60, 294, "Minus", "−"), system: point(100, 294, "Home", "Home"), + start: point(140, 294, "Plus", "+"), + }; + const wiiVertical = { + ...wiiCommon, ...dpad(100, 128, 25), + south: back("B · underside", "B"), east: point(100, 216, "A", "A"), + west: point(100, 445, "1", "1"), north: point(100, 495, "2", "2"), + }; + const layouts = { + generic: { ...xbox, name: "Generic controller reference", style: "generic", generic: true }, + xbox, + dualsense: { + name: "Sony DualSense", style: "playstation", asset: "ps5-dualsense-simple.svg", + viewBox: [0, 0, 800, 548], minWidth: 640, + controls: { + left_trigger: back("L2 · rear", "L2"), right_trigger: back("R2 · rear", "R2"), + left_shoulder: point(165, 32, "L1", "L1"), right_shoulder: point(635, 32, "R1", "R1"), + left_stick: point(282, 280.3, "Left stick click", "L3"), right_stick: point(518, 280.3, "Right stick click", "R3"), + ...dpad(166, 174, 39), + north: point(634, 119, "Triangle", "△"), east: point(688, 173, "Circle", "○"), + south: point(634, 227, "Cross", "×"), west: point(580, 173, "Square", "□"), + select: point(231, 92, "Create", "Create"), start: point(569, 92, "Options", "Opt"), + capture: point(400, 120, "Touchpad click", "TP"), system: point(400, 277, "PS", "PS"), + }, + note: "The microphone mute button is not exposed as a remappable source. Rear triggers are listed below the front view.", + }, + "switch-pro": { + name: "Nintendo Switch Pro Controller", style: "switch", asset: "switch-pro-controller-simple.svg", + viewBox: [0, 0, 800, 570], minWidth: 640, + controls: { + left_trigger: back("ZL · rear", "ZL"), right_trigger: back("ZR · rear", "ZR"), + left_shoulder: point(205, 32, "L", "L"), right_shoulder: point(595, 32, "R", "R"), + left_stick: point(190, 179, "Left stick click", "L3"), right_stick: point(500, 304, "Right stick click", "R3"), + ...dpad(294, 306, 40), + north: point(610, 137, "X", "X"), east: point(662, 189, "A", "A"), + south: point(610, 241, "B", "B"), west: point(558, 189, "Y", "Y"), + select: point(301, 118, "Minus", "−"), start: point(499, 118, "Plus", "+"), + capture: point(349, 180, "Capture", "Cap"), system: point(451, 180, "Home", "Home"), + }, + }, + "switch2-pro": { + name: "Nintendo Switch 2 Pro Controller", style: "switch", asset: "switch-2-pro-controller-simple.svg", + viewBox: [-16, 160, 1232, 884], minWidth: 720, + controls: { + left_trigger: back("ZL · rear", "ZL"), right_trigger: back("ZR · rear", "ZR"), + gl: back("GL · back button", "GL"), gr: back("GR · back button", "GR"), + left_shoulder: point(290, 193, "L", "L"), right_shoulder: point(910, 193, "R", "R"), + left_stick: point(272, 434, "Left stick click", "L3"), right_stick: point(746, 605, "Right stick click", "R3"), + ...dpad(424, 602, 60), + north: point(909, 350, "X", "X"), east: point(1005, 433, "A", "A"), + south: point(909, 517, "B", "B"), west: point(813, 433, "Y", "Y"), + select: point(454, 341, "Minus", "−"), start: point(743, 341, "Plus", "+"), + capture: point(526, 434, "Capture", "Cap"), system: point(672, 435, "Home", "Home"), + c: point(599, 693, "C", "C"), + }, + note: "C is on the front. GL and GR are rear controls, not front-face buttons. C, GL and GR are source-only inputs.", + }, + "joycon2-pair": { + name: "Joy-Con 2 pair", style: "switch", asset: "switch-2-joycons-connected.svg", + viewBox: [-8, -10, 688, 560], minWidth: 620, + controls: { + ...translate(leftFace, 64), ...translate(rightFace, 444), + left_shoulder: back("L · top edge", "L"), right_shoulder: back("R · top edge", "R"), + left_trigger: back("ZL · rear", "ZL"), right_trigger: back("ZR · rear", "ZR"), + left_sl: back("Left SL · rail", "L SL"), left_sr: back("Left SR · rail", "L SR"), + right_sl: back("Right SL · rail", "R SL"), right_sr: back("Right SR · rail", "R SR"), + }, + note: "Select the L+R profile owner for paired settings; its eight profiles are independent of both solo banks. If no L+R owner is listed, update firmware and connect both members. The grip is illustrative, not detected. Rear triggers and rails are not visible in this front view. C and rail inputs are source-only; GL/GR belong to Switch 2 Pro, not Joy-Con 2.", + }, + "joycon2-left": { + name: "Joy-Con 2 left · sideways solo", style: "switch", asset: "switch-2-joycon-left.svg", + viewBox: [-17, -10, 200, 560], rotation: -90, minWidth: 560, + controls: { + left_stick: leftFace.left_stick, select: leftFace.select, capture: leftFace.capture, + south: { ...leftFace.dpad_left, label: "Left arrow (solo south)", glyph: "▼" }, + east: { ...leftFace.dpad_down, label: "Down arrow (solo east)", glyph: "▶" }, + west: { ...leftFace.dpad_up, label: "Up arrow (solo west)", glyph: "◀" }, + north: { ...leftFace.dpad_right, label: "Right arrow (solo north)", glyph: "▲" }, + left_shoulder: back("L / SL · shoulder alias", "L / SL"), right_shoulder: back("SR · shoulder alias", "SR"), + left_trigger: back("ZL · rear", "ZL"), + left_sl: back("SL · rail extra", "SL extra"), left_sr: back("SR · rail extra", "SR extra"), + }, + note: "Solo firmware rotates the directional buttons into face-button sources and keeps the stick on LEFT axes. Arrow names refer to the original upright art. " + railNote, + }, + "joycon2-right": { + name: "Joy-Con 2 right · sideways solo", style: "switch", asset: "switch-2-joycon-single.svg", + viewBox: [-19, -10, 200, 560], rotation: 90, minWidth: 560, + controls: { + south: { ...rightFace.east, label: "A (solo south)" }, east: { ...rightFace.north, label: "X (solo east)" }, + west: { ...rightFace.south, label: "B (solo west)" }, north: { ...rightFace.west, label: "Y (solo north)" }, + left_stick: { ...rightFace.right_stick, label: "Right stick · solo LEFT axes/click", glyph: "L3" }, + start: rightFace.start, system: rightFace.system, c: rightFace.c, + left_shoulder: back("SL · shoulder alias", "SL"), right_shoulder: back("R / SR · shoulder alias", "R / SR"), + right_trigger: back("ZR · rear", "ZR"), + right_sl: back("SL · rail extra", "SL extra"), right_sr: back("SR · rail extra", "SR extra"), + }, + note: "Solo firmware rotates ABXY sources; the right stick becomes LEFT axes and left-stick click. Output labels do not rotate. Select the separate L+R profile owner for paired settings, not this solo bank. " + railNote, + }, + "wii-remote": { + name: "Wii Remote · orientation unknown", style: "switch", asset: "wii-remote-simple.svg", + viewBox: [0, 0, 200, 632], minWidth: 240, maxWidth: 260, + 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)])), + }, + 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.", + }, + "wii-horizontal": { + name: "Wii Remote · horizontal / accelerometer", style: "switch", asset: "wii-remote-simple.svg", + viewBox: [0, 0, 200, 632], rotation: -90, minWidth: 760, + controls: { + ...wiiCommon, + south: point(100, 445, "1", "1"), east: point(100, 495, "2", "2"), + west: point(100, 216, "A", "A"), north: back("B · underside", "B"), + dpad_left: point(100, 103, "D-pad physical Up → Left", "◀"), + dpad_up: point(125, 128, "D-pad physical Right → Up", "▲"), + 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.", + }, + "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.", + }, + "wii-nunchuk": { + name: "Wii Remote + Nunchuk", style: "switch", asset: "wii-remote-nunchuk-simple.svg", + viewBox: [0, 0, 540, 764], minWidth: 600, + controls: { + ...translate({ ...wiiCommon, ...dpad(100, 128, 25), east: wiiVertical.east }, 320, 10), + south: back("B · Remote underside", "B"), + 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.", + }, + }; + return layouts; +})(); diff --git a/src/switch_pico_bridge/web/profile_editor.css b/src/switch_pico_bridge/web/profile_editor.css index b1c9705..9e30b76 100644 --- a/src/switch_pico_bridge/web/profile_editor.css +++ b/src/switch_pico_bridge/web/profile_editor.css @@ -186,6 +186,19 @@ h4 { font-size: 1rem; } .select:focus, .number-input:focus { border-color: var(--sky); box-shadow: 0 0 0 4px rgba(89, 199, 255, 0.1); } .select:disabled, .number-input:disabled { cursor: not-allowed; opacity: 0.45; border-color: var(--line-soft); box-shadow: none; } .select option:disabled { color: var(--subtle); } +.joycon-mode { margin-top: 22px; padding: 16px 0; border-block: 1px solid var(--line); } +.joycon-mode h3 { font-size: 1rem; } +.joycon-mode .field-label { margin-top: 14px; } +.joycon-mode-help { margin: 12px 0; color: var(--muted); font-size: 0.73rem; line-height: 1.5; } +.joycon-mode-help summary { color: var(--blue); cursor: pointer; } +.joycon-mode-help summary:focus-visible { outline: 3px solid var(--sky); outline-offset: 3px; } +.joycon-mode-help > div { display: grid; gap: 8px; margin-top: 10px; } +.joycon-mode-help strong { color: var(--ink); } +.joycon-mode .button { width: 100%; } +.joycon-mode-status { margin-top: 10px; color: var(--mint); font-size: 0.74rem; line-height: 1.5; overflow-wrap: anywhere; } +.joycon-mode-status[data-state="loading"] { color: var(--muted); } +.joycon-mode-status[data-state="pending"], .joycon-mode-status[data-state="unsupported"] { color: var(--amber); } +.joycon-mode-status[data-state="error"] { color: var(--rose); } .profile-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 22px; } .profile-button { --slot-color: var(--blue); @@ -293,6 +306,7 @@ h4 { font-size: 1rem; } .panel-heading > p { max-width: 440px; color: var(--muted); font-size: 0.78rem; line-height: 1.55; text-align: right; } .controller-mapper { display: grid; grid-template-columns: minmax(0, 1fr) 250px; gap: 18px; align-items: stretch; } .controller-canvas { + min-width: 0; padding: 18px; overflow: hidden; border: 1px solid rgba(89, 199, 255, 0.24); @@ -300,14 +314,25 @@ h4 { font-size: 1rem; } background: #090f1d; box-shadow: inset 0 1px rgba(255, 255, 255, 0.04); } -.controller-caption { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 27px; } +.controller-caption { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; min-height: 27px; } .controller-caption strong { color: #dfe8ff; font-size: 0.8rem; } -.controller-live { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 0.65rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; } -.controller-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--mint); box-shadow: 0 0 0 4px rgba(74, 222, 165, 0.09), 0 0 12px var(--mint); } -.controller-photo-wrap { position: relative; width: 100%; margin: 10px auto 4px; aspect-ratio: 1.62; transition: aspect-ratio 180ms ease; } -.controller-canvas[data-style="switch"] .controller-photo-wrap { aspect-ratio: 1.38; } -.controller-canvas[data-style="playstation"] .controller-photo-wrap { aspect-ratio: 1.8; } -.controller-photo { display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; user-select: none; filter: drop-shadow(0 22px 28px rgba(0, 0, 0, 0.46)); } +.controller-live { display: inline-flex; align-items: center; gap: 7px; color: var(--amber); font-size: 0.65rem; font-weight: 800; letter-spacing: 0.07em; text-transform: uppercase; } +.controller-live[data-state="live"] { color: var(--mint); } +.layout-picker { margin-block: 16px 8px; } +.layout-picker .select { width: 100%; margin-top: 6px; } +.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); } +.controller-stage { position: absolute; top: 50%; left: 50%; width: var(--stage-width, 100%); height: var(--stage-height, 100%); transform: translate(-50%, -50%) rotate(var(--stage-angle, 0deg)); } +.controller-photo { display: block; width: 100%; height: 100%; pointer-events: none; user-select: none; filter: drop-shadow(0 16px 20px rgba(0, 0, 0, 0.46)); } +.controller-annotation { position: absolute; left: var(--x); top: var(--y); max-width: 170px; padding: 6px; transform: translate(-50%, -50%) rotate(var(--label-angle, 0deg)); border: 1px solid var(--muted); border-radius: 6px; color: var(--ink); background: var(--surface-2); font-size: 0.65rem; text-align: center; pointer-events: none; } +.extra-controls { display: flex; flex-wrap: wrap; gap: 9px; margin: 10px 0; } +.extra-controls button { min-height: 44px; min-width: 48px; padding: 9px 12px; color: var(--ink); border: 1px solid var(--sky); border-radius: 9px; background: var(--surface-2); font-size: 0.72rem; font-weight: 800; cursor: pointer; } +.extra-controls button.disabled-map { color: var(--muted); border-color: var(--control-line); } +.extra-controls button.selected, .extra-controls button:focus-visible { outline: 2px solid var(--sky); outline-offset: 3px; } +.extra-controls button.pressed { color: #090f1d; background: var(--amber); border-color: var(--amber); } +.mapping-playtest { margin-top: 14px; font-size: 0.76rem; } +.mapping-playtest > p { margin: 6px 0; } .controller-hotspots { position: absolute; inset: 0; } .controller-hotspots button { --x: 50%; @@ -317,90 +342,28 @@ h4 { font-size: 1rem; } top: var(--y); display: grid; place-items: center; - width: 34px; - height: 34px; + width: 30px; + height: 30px; padding: 0; color: #eff6ff; border: 2px solid var(--sky); border-radius: 50%; background: rgba(7, 12, 25, 0.9); box-shadow: 0 0 0 4px rgba(89, 199, 255, 0.12), 0 5px 16px rgba(0, 0, 0, 0.42); - transform: translate(-50%, -50%); - font-size: 0.7rem; + transform: translate(-50%, -50%) rotate(var(--label-angle, 0deg)); + font-size: 0.59rem; font-weight: 900; line-height: 1; transition: transform 120ms ease, border-color 120ms ease, background 120ms ease, box-shadow 120ms ease; } -.controller-hotspots button:hover, .controller-hotspots button:focus-visible { z-index: 2; border-color: var(--blue); transform: translate(-50%, -50%) scale(1.13); } -.controller-hotspots button.selected { z-index: 3; color: #090a16; border-color: var(--rose); background: var(--rose); box-shadow: 0 0 0 5px rgba(255, 124, 159, 0.18), 0 8px 20px rgba(0, 0, 0, 0.48); transform: translate(-50%, -50%) scale(1.16); } -.controller-hotspots button[data-controller-button$="_trigger"] { width: 60px; height: 30px; border-radius: 11px; } -.controller-hotspots button.disabled-map { color: var(--subtle); border-color: #56617a; background: rgba(8, 11, 20, 0.9); box-shadow: none; } -.controller-hotspots button[data-controller-button$="_shoulder"] { width: 60px; height: 30px; border-radius: 11px; } -.controller-hotspots button[data-controller-button^="dpad_"] { width: 27px; height: 27px; font-size: 0.58rem; } -.controller-hotspots button[data-controller-button="select"], -.controller-hotspots button[data-controller-button="start"], -.controller-hotspots button[data-controller-button="capture"], -.controller-hotspots button[data-controller-button="system"] { width: 29px; height: 29px; font-size: 0.63rem; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_shoulder"] { --x: 22%; --y: 12%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_trigger"] { --x: 22%; --y: 4%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_trigger"] { --x: 78%; --y: 4%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_shoulder"] { --x: 78%; --y: 12%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_stick"] { --x: 23%; --y: 43%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_stick"] { --x: 63%; --y: 68%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_up"] { --x: 36%; --y: 62%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_right"] { --x: 40%; --y: 68%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_down"] { --x: 36%; --y: 74%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_left"] { --x: 32%; --y: 68%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="north"] { --x: 77%; --y: 33%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="east"] { --x: 84%; --y: 43%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="south"] { --x: 77%; --y: 54%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="west"] { --x: 70%; --y: 43%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="select"] { --x: 42%; --y: 43%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="start"] { --x: 57%; --y: 43%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="capture"] { --x: 50%; --y: 52%; } -.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="system"] { --x: 50%; --y: 26%; } - -.controller-canvas[data-style="switch"] [data-controller-button="left_shoulder"] { --x: 27%; --y: 8%; } -.controller-canvas[data-style="switch"] [data-controller-button="right_shoulder"] { --x: 73%; --y: 8%; } -.controller-canvas[data-style="switch"] [data-controller-button="left_trigger"] { --x: 27%; --y: 2%; } -.controller-canvas[data-style="switch"] [data-controller-button="right_trigger"] { --x: 73%; --y: 2%; } -.controller-canvas[data-style="switch"] [data-controller-button="left_stick"] { --x: 24%; --y: 30%; } -.controller-canvas[data-style="switch"] [data-controller-button="right_stick"] { --x: 63%; --y: 51%; } -.controller-canvas[data-style="switch"] [data-controller-button="dpad_up"] { --x: 35%; --y: 46%; } -.controller-canvas[data-style="switch"] [data-controller-button="dpad_right"] { --x: 39%; --y: 51%; } -.controller-canvas[data-style="switch"] [data-controller-button="dpad_down"] { --x: 35%; --y: 56%; } -.controller-canvas[data-style="switch"] [data-controller-button="dpad_left"] { --x: 31%; --y: 51%; } -.controller-canvas[data-style="switch"] [data-controller-button="north"] { --x: 77%; --y: 22%; } -.controller-canvas[data-style="switch"] [data-controller-button="east"] { --x: 85%; --y: 32%; } -.controller-canvas[data-style="switch"] [data-controller-button="south"] { --x: 77%; --y: 43%; } -.controller-canvas[data-style="switch"] [data-controller-button="west"] { --x: 69%; --y: 32%; } -.controller-canvas[data-style="switch"] [data-controller-button="select"] { --x: 39%; --y: 21%; } -.controller-canvas[data-style="switch"] [data-controller-button="start"] { --x: 61%; --y: 21%; } -.controller-canvas[data-style="switch"] [data-controller-button="capture"] { --x: 44%; --y: 31%; } -.controller-canvas[data-style="switch"] [data-controller-button="system"] { --x: 57%; --y: 32%; } - -.controller-canvas[data-style="playstation"] [data-controller-button="left_shoulder"] { --x: 21%; --y: 12%; } -.controller-canvas[data-style="playstation"] [data-controller-button="right_shoulder"] { --x: 79%; --y: 12%; } -.controller-canvas[data-style="playstation"] [data-controller-button="left_stick"] { --x: 34%; --y: 69%; } -.controller-canvas[data-style="playstation"] [data-controller-button="left_trigger"] { --x: 21%; --y: 3%; } -.controller-canvas[data-style="playstation"] [data-controller-button="right_trigger"] { --x: 79%; --y: 3%; } -.controller-canvas[data-style="playstation"] [data-controller-button="right_stick"] { --x: 66%; --y: 69%; } -.controller-canvas[data-style="playstation"] [data-controller-button="dpad_up"] { --x: 18%; --y: 42%; } -.controller-canvas[data-style="playstation"] [data-controller-button="dpad_right"] { --x: 24%; --y: 50%; } -.controller-canvas[data-style="playstation"] [data-controller-button="dpad_down"] { --x: 18%; --y: 58%; } -.controller-canvas[data-style="playstation"] [data-controller-button="dpad_left"] { --x: 12%; --y: 50%; } -.controller-canvas[data-style="playstation"] [data-controller-button="north"] { --x: 83%; --y: 39%; } -.controller-canvas[data-style="playstation"] [data-controller-button="east"] { --x: 91%; --y: 50%; } -.controller-canvas[data-style="playstation"] [data-controller-button="south"] { --x: 83%; --y: 61%; } -.controller-canvas[data-style="playstation"] [data-controller-button="west"] { --x: 75%; --y: 50%; } -.controller-canvas[data-style="playstation"] [data-controller-button="select"] { --x: 25%; --y: 30%; } -.controller-canvas[data-style="playstation"] [data-controller-button="start"] { --x: 75%; --y: 30%; } -.controller-canvas[data-style="playstation"] [data-controller-button="capture"] { --x: 50%; --y: 36%; } -.controller-canvas[data-style="playstation"] [data-controller-button="system"] { --x: 50%; --y: 72%; } +.controller-hotspots button:hover, .controller-hotspots button:focus-visible { z-index: 2; outline: 2px solid var(--blue); outline-offset: 2px; } +.controller-hotspots button.selected { z-index: 3; color: #090a16; border-color: var(--rose); background: var(--rose); box-shadow: 0 0 0 4px rgba(255, 124, 159, 0.18); } +.controller-hotspots button.disabled-map:not(.selected):not(.pressed) { color: var(--subtle); border-color: #56617a; background: rgba(8, 11, 20, 0.9); box-shadow: none; } +.controller-hotspots button[data-controller-button^="dpad_"] { width: 26px; height: 26px; font-size: 0.58rem; } +.source-unavailable { color: var(--amber); } .controller-hint { margin-top: 4px; color: var(--muted); font-size: 0.72rem; text-align: center; } .controller-credit { margin-top: 8px; color: var(--subtle); font-size: 0.64rem; text-align: center; } -.controller-credit a { color: var(--muted); text-decoration-color: rgba(171, 181, 204, 0.45); text-underline-offset: 3px; } .mapping-inspector { display: flex; flex-direction: column; diff --git a/src/switch_pico_bridge/web/profile_editor.html b/src/switch_pico_bridge/web/profile_editor.html index 260ec7a..ca6aca5 100644 --- a/src/switch_pico_bridge/web/profile_editor.html +++ b/src/switch_pico_bridge/web/profile_editor.html @@ -35,10 +35,34 @@ -

The default applies when a controller has no dedicated layout.

+ +
+

Adapter-wide setting

+

Joy-Con 2 default mode

+ + +

Saved on the adapter; changing it applies live and clears temporary joins/splits. Not an artwork preview.

+
+ Controller shortcut & profile banks +
+

Paired: L + R share one player and a separate L+R profile bank. Unmatched halves stay separate.

+

Individual: each half is a sideways player using its own solo bank. Choose the L or R profile owner to edit it.

+

Join / split this pair: hold ZL + Minus on the left and ZR + Plus on the right for 2 seconds. A short pulse confirms success. Release all four buttons before using it again.

+

The shortcut affects only those halves and overrides this default until either disconnects. Other Joy-Cons are unchanged. Each half's complete two-button chord is reserved until both buttons release, even while waiting for the other half. Ambiguous solo selections do not join.

+

All saved banks and unsaved profile drafts are kept. Your chosen owner does not change automatically.

+

Saved preference is not a live pairing report. A pair waits for its L+R bank to be ready; the diagram and live playtest report the actual arrangement.

+
+
+ +

Reading saved preference…

+
+
@@ -110,46 +134,45 @@
- Connected layout + Reference layout Generic controller
-
- Connected controller -
- - - - - - - - - - - - - - - - - - +
+ + +

Preview changes only this editor's source labels and diagram, never firmware topology or saved mappings.

+
+
+
+
+ Generic controller reference +
+
-

Select a highlighted control on the controller to remap it.

-

- Image: - Controller artwork by Al. Lopez · MIT -

+

Select a control on the illustration or below it. On narrow screens, swipe sideways or use the Source control menu.

+

Off-art controls · rear, rails or unlocated sources

+
+

+

Original vector artwork supplied for Switch Pico Controller Studio.

+
+

Raw extra inputs: None

+

Draft button output: None

+

Base and Shift mapping preview only; shortcuts, macros and Turbo are not simulated.

+
@@ -431,6 +459,7 @@
+ diff --git a/src/switch_pico_bridge/web/profile_editor.js b/src/switch_pico_bridge/web/profile_editor.js index f79114d..dbbfa23 100644 --- a/src/switch_pico_bridge/web/profile_editor.js +++ b/src/switch_pico_bridge/web/profile_editor.js @@ -24,6 +24,19 @@ const macroCapture = { requestActive: false, timer: 0, }; +const joyconMode = { + saved: null, + selected: null, + pending: false, + supported: false, + generation: 0, + loading: true, + applying: false, + requestActive: false, + revision: 0, + readError: "", + applyError: "", +}; const state = { schema: null, identities: [], @@ -43,6 +56,11 @@ const state = { previewInputConnected: false, playtestRequestActive: false, liveSample: null, + layoutPreview: "auto", + presentation: null, + presentationKey: "", + diagramKey: "", + playtestShift: { key: "", held: false, active: false }, playtestTimer: 0, libraryRequestActive: false, libraryTimer: 0, @@ -52,6 +70,9 @@ const elements = { connection: document.querySelector("#connectionState"), connectionText: document.querySelector("#connectionText"), identity: document.querySelector("#identitySelect"), + joyconMode: document.querySelector("#joyconModeSelect"), + applyJoyconMode: document.querySelector("#applyJoyconModeButton"), + joyconModeStatus: document.querySelector("#joyconModeStatus"), profileList: document.querySelector("#profileList"), profileOwner: document.querySelector("#profileOwner"), controllerAlias: document.querySelector("#controllerAlias"), @@ -68,8 +89,15 @@ const elements = { controllerCanvas: document.querySelector("#controllerCanvas"), controllerImage: document.querySelector("#controllerImage"), controllerModel: document.querySelector("#controllerModel"), - controllerCredit: document.querySelector("#controllerCredit"), + controllerLayoutStatus: document.querySelector("#controllerLayoutStatus"), + controllerLayoutPreview: document.querySelector("#controllerLayoutPreview"), + controllerLayoutHelp: document.querySelector("#controllerLayoutHelp"), + controllerLayoutNote: document.querySelector("#controllerLayoutNote"), + controllerPhotoWrap: document.querySelector("#controllerPhotoWrap"), + controllerStage: document.querySelector("#controllerStage"), + selectedSource: document.querySelector("#selectedSource"), controllerHotspots: document.querySelector("#controllerHotspots"), + extraControls: document.querySelector("#extraControls"), selectedMapping: document.querySelector("#selectedMapping"), selectedControlGlyph: document.querySelector("#selectedControlGlyph"), selectedControlName: document.querySelector("#selectedControlName"), @@ -120,6 +148,9 @@ const elements = { playtestTitle: document.querySelector("#playtestTitle"), playtestStatus: document.querySelector("#playtestStatus"), playtestHelp: document.querySelector("#playtestHelp"), + playtestExtraInputs: document.querySelector("#playtestExtraInputs"), + playtestMappedButtons: document.querySelector("#playtestMappedButtons"), + playtestMappingHelp: document.querySelector("#playtestMappingHelp"), playtestLeftValues: document.querySelector("#playtestLeftValues"), playtestRightValues: document.querySelector("#playtestRightValues"), playtestLeftTriggerLabel: document.querySelector("#playtestLeftTriggerLabel"), @@ -162,13 +193,71 @@ const directionalLabels = { dpad_right: "D-pad Right", dpad_down: "D-pad Down", dpad_left: "D-pad Left", + c: "C", + gl: "GL", + gr: "GR", + left_sl: "Left SL", + left_sr: "Left SR", + right_sl: "Right SL", + right_sr: "Right SR", }; +function matchingLiveSample(sample = state.liveSample) { + const owner = currentOwner(); + return Boolean(sample?.connected && owner && sample.owner_key === owner.key && + (owner.index === 0 || sample.identity_key === owner.key)); +} + function currentControllerStyle() { - return state.identities[state.identityIndex]?.controller?.style || "generic"; + const style = state.presentation?.style || currentOwner()?.controller?.style || "generic"; + return style === "wii" ? "switch" : style; +} + +function sourceLabel(control) { + return (!state.presentation?.layout.generic && state.presentation?.layout.controls[control]?.label) || controlLabel(control); +} + +function sourceGlyph(control) { + return (!state.presentation?.layout.generic && state.presentation?.layout.controls[control]?.glyph) || controlGlyph(control, currentControllerStyle()); +} + +function sourceAvailable(control) { + return state.presentation?.sources.includes(control) ?? true; +} + +function sourceChoices(choices, stored = []) { + return choices.filter(control => sourceAvailable(control) || stored.includes(control)); +} + +function sourceOptions(selected, choices = state.schema.controls, excluded = []) { + const available = sourceChoices(choices); + if (selected && !available.includes(selected)) available.push(selected); + return `` + + available.map(control => ``).join(""); +} + +function syncControllerPresentation() { + const owner = currentOwner(); + const live = matchingLiveSample() ? state.liveSample : null; + const controller = live?.controller || owner?.controller || {}; + const preview = state.layoutPreview !== "auto"; + const layoutId = preview ? state.layoutPreview : controller.layout || "generic"; + const layout = ControllerLayouts[layoutId] || ControllerLayouts.generic; + 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 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; + state.presentationKey = key; + state.presentation = { layoutId, layout, sources, preview, liveDiagram, live, style, topologyKnown }; + return true; } function controlLabel(control, style = currentControllerStyle()) { + if (style === "wii") style = "switch"; return state.schema.control_labels?.[style]?.[control] || directionalLabels[control] || label(control); @@ -245,6 +334,9 @@ function updateCurveMarker(group, side, input) { function clearPlaytest(message, stateName = "waiting") { state.liveSample = null; + state.playtestShift = { key: "", held: false, active: false }; + elements.playtestExtraInputs.textContent = "None"; + elements.playtestMappedButtons.textContent = "None"; if (state.previewInputConnected) stopMacroPreview("Preview stopped: controller disconnected."); state.previewInputConnected = false; state.identifyAvailable = false; @@ -255,32 +347,60 @@ function clearPlaytest(message, stateName = "waiting") { elements.playtestTitle.textContent = stateName === "error" ? "Live input unavailable" : "Waiting for controller input"; elements.playtestHelp.textContent = message; - elements.controllerHotspots.querySelectorAll(".pressed") + elements.controllerCanvas.querySelectorAll(".pressed") .forEach((button) => button.classList.remove("pressed")); document.querySelectorAll(".curve-marker.visible") .forEach((marker) => marker.classList.remove("visible")); + if (state.schema && state.profile && syncControllerPresentation()) { + renderButtonMap(); + refreshSourceControls(); + } renderCapture(); } function renderPlaytest(sample) { state.liveSample = sample; - if (!sample.connected) { + if (!matchingLiveSample(sample)) { clearPlaytest( - "Connect or move a controller to compare its raw input with this draft." + sample.connected + ? sample.identity?.is_joycon_pair + ? "Paired input belongs to the L+R profile owner. Select that owner for paired settings; this draft and its layout are unchanged." + : "Live input belongs to another profile owner; this draft and its layout are unchanged." + : "Connect or move the selected controller to compare its raw input with this draft." ); return; } + if (syncControllerPresentation()) { + renderButtonMap(); + refreshSourceControls(); + } state.previewInputConnected = true; + const shift = state.profile.shift; + const shiftKey = `${sample.identity_key}:${sample.connection_generation}:${shift.mode}:${shift.modifier}`; + if (state.playtestShift.key !== shiftKey) { + state.playtestShift = { key: shiftKey, held: false, active: false }; + } + const controls = new Set([...sample.buttons, ...(sample.extra_buttons || [])]); + for (const side of ["left", "right"]) { + const value = transformTrigger(sample.triggers[side], state.profile.triggers[side]); + if (value >= state.profile.triggers[side].digital_threshold) { + controls.add(`${side}_trigger`); + } + } + const held = controls.has(shift.modifier); + if (shift.mode === "toggle" && held && !state.playtestShift.held) { + state.playtestShift.active = !state.playtestShift.active; + } else if (shift.mode !== "toggle") { + state.playtestShift.active = shift.mode === "hold" && held; + } + state.playtestShift.held = held; + const mapped = ProfilePlaytestMath.transformMappings(sample, state.profile, state.playtestShift.active); const rawLeft = sample.left_stick; const rawRight = sample.right_stick; const outputLeft = transformStick(rawLeft, state.profile.sticks.left); const outputRight = transformStick(rawRight, state.profile.sticks.right); - const outputLeftTrigger = transformTrigger( - sample.triggers.left, state.profile.triggers.left - ); - const outputRightTrigger = transformTrigger( - sample.triggers.right, state.profile.triggers.right - ); + const outputLeftTrigger = mapped.triggers.left; + const outputRightTrigger = mapped.triggers.right; updateStickPlaytest( "left", rawLeft, outputLeft, state.profile.sticks.left ); @@ -305,20 +425,26 @@ function renderPlaytest(sample) { ); updateCurveMarker("triggers", "left", sample.triggers.left / 65535); updateCurveMarker("triggers", "right", sample.triggers.right / 65535); - const style = sample.controller?.style || currentControllerStyle(); + const style = currentControllerStyle(); elements.playtestLeftTriggerLabel.textContent = controlLabel("left_trigger", style); elements.playtestRightTriggerLabel.textContent = controlLabel("right_trigger", style); - const pressed = new Set(sample.buttons); + const pressed = new Set([...sample.buttons, ...(sample.extra_buttons || [])]); if (sample.triggers.left > 512) pressed.add("left_trigger"); if (sample.triggers.right > 512) pressed.add("right_trigger"); - elements.controllerHotspots.querySelectorAll("[data-controller-button]") + elements.controllerCanvas.querySelectorAll("[data-controller-button]") .forEach((button) => { button.classList.toggle( - "pressed", pressed.has(button.dataset.controllerButton) + "pressed", state.presentation.liveDiagram && pressed.has(button.dataset.controllerButton) ); }); + elements.playtestExtraInputs.textContent = + (sample.extra_buttons || []).map(button => sourceLabel(button)).join(", ") || "None"; + elements.playtestMappedButtons.textContent = + mapped.buttons.map((button) => controlLabel(button, style)).join(", ") || "None"; + elements.playtestMappingHelp.textContent = + `${state.playtestShift.active ? "Shift" : "Base"} layer preview; shortcuts, macros and Turbo are not simulated.${state.presentation.liveDiagram ? "" : " Diagram is a reference/preview; physical highlighting is off."}`; elements.playtestPanel.dataset.state = "live"; elements.playtestStatus.textContent = "Live"; const owner = currentOwner(); @@ -431,6 +557,83 @@ async function api(path, options = {}) { } return payload; } +function joyconModeChanged() { + return joyconMode.selected !== null && joyconMode.selected !== joyconMode.saved; +} + +function renderJoyconMode() { + const locked = state.busy || captureBlocking() || macroCapture.requestActive || + joyconMode.loading || joyconMode.applying; + elements.joyconMode.disabled = locked || !joyconMode.supported || !state.adapterConnected; + elements.applyJoyconMode.disabled = elements.joyconMode.disabled || + Boolean(joyconMode.readError) || !joyconModeChanged(); + if (joyconMode.selected !== null && elements.joyconMode.value !== joyconMode.selected) { + elements.joyconMode.value = joyconMode.selected; + } + let status; + let mode; + const saved = joyconMode.saved === null ? "" : + `Saved default: ${label(joyconMode.saved)} · generation ${joyconMode.generation}.`; + const selection = joyconModeChanged() + ? ` Selection: ${label(joyconMode.selected)} — not applied.` : ""; + if (joyconMode.applying) { + mode = "loading"; + status = `Saving ${label(joyconMode.selected)} default… Your profile draft is unchanged.`; + } else if (joyconMode.loading) { + mode = "loading"; + status = `Reading saved preference… ${saved}${selection}`; + } else if (joyconMode.applyError || joyconMode.readError) { + mode = "error"; + status = `${joyconMode.applyError || joyconMode.readError} ${saved ? `Last confirmed ${saved.toLowerCase()}` : "Saved preference is unknown."}${selection}`; + } else if (joyconMode.saved === null) { + mode = "error"; + status = "Saved preference is unknown. Refresh to read it from the adapter."; + } else if (!joyconMode.supported) { + mode = "unsupported"; + status = `Paired is the legacy default. This firmware cannot save player mode; update the adapter firmware (configuration schema 4 required).${selection}`; + } else if (!state.adapterConnected) { + mode = "error"; + status = `Adapter unavailable. Last confirmed ${saved.toLowerCase()}${selection}`; + } else { + mode = joyconModeChanged() ? "pending" : "saved"; + status = `${saved}${selection} Shortcuts can override these connections. Use live playtest to check the current arrangement.`; + } + elements.joyconModeStatus.dataset.state = mode; + if (elements.joyconModeStatus.textContent !== status) { + elements.joyconModeStatus.textContent = status; + } +} + +function acceptJoyconMode(payload, committed = false) { + joyconMode.saved = payload.mode; + joyconMode.supported = payload.supported; + joyconMode.generation = payload.generation; + if (committed || !joyconMode.pending) joyconMode.selected = payload.mode; + joyconMode.pending = joyconModeChanged(); + joyconMode.readError = ""; + if (joyconMode.selected === payload.mode) joyconMode.applyError = ""; +} + +async function refreshJoyconMode(showLoading = false) { + if (joyconMode.requestActive || joyconMode.applying) return; + const revision = joyconMode.revision; + joyconMode.requestActive = true; + joyconMode.loading = showLoading || joyconMode.saved === null; + renderJoyconMode(); + try { + const payload = await api("/api/joycon-mode"); + if (revision === joyconMode.revision) acceptJoyconMode(payload); + } catch (error) { + if (revision === joyconMode.revision) { + joyconMode.readError = `Could not read player mode: ${error.message}`; + } + } finally { + joyconMode.requestActive = false; + joyconMode.loading = false; + renderJoyconMode(); + } +} + function setBusy(busy) { state.busy = busy; @@ -495,7 +698,7 @@ function persistOwnerKey(key) { function identitySignature(identities) { return identities.map((entry) => ( `${entry.index}:${entry.key}:${entry.label}:` + - `${entry.controller.model}:${entry.controller.style}` + `${entry.controller.model}:${entry.controller.style}:${entry.controller.layout}:${entry.source_controls?.join(",")}` )).join("|"); } @@ -534,6 +737,10 @@ function syncLibraryMetadata(identities, restoreStoredOwner = false) { elements.activeBadge.hidden = !state.active; elements.activate.disabled = !state.adapterConnected || state.busy || state.active; + if (state.schema && syncControllerPresentation()) { + renderButtonMap(); + refreshSourceControls(); + } } return ownerChanged; } @@ -550,6 +757,7 @@ async function pollLibraryMetadata() { } state.libraryRequestActive = true; try { + await refreshJoyconMode(); const payload = await api("/api/profiles"); syncLibraryMetadata(payload.identities); setConnection("ready", "Adapter connected"); @@ -597,7 +805,7 @@ function renderProfileList() { function buttonOptions( selected, includeNone = true, - choices = state.schema.controls, + choices = state.schema.output_controls, style = currentControllerStyle() ) { const none = includeNone @@ -615,11 +823,7 @@ function modeOptions(modes, selected) { } function modifierOptions(selected, excluded = []) { - const available = currentOwner()?.modifier_controls || state.schema.buttons; - return `` + - state.schema.controls.map((control) => - `` - ).join(""); + return sourceOptions(selected, state.schema.controls, excluded); } function renderShortcuts() { @@ -628,16 +832,14 @@ function renderShortcuts() {
-

None clears every shortcut. Analog trigger modifiers require a known Xbox or PlayStation owner; unsupported choices remain unavailable.

+

None clears every shortcut. Switch 2 extra inputs can act as modifiers. Trigger modifiers are available for known Xbox, PlayStation and Switch 2 owners.

`; elements.shortcuts.innerHTML = shortcuts.profiles.map((selector, index) => `
`).join(""); } @@ -654,11 +856,14 @@ function renderShift() {

Hold uses the alternate map while pressed. Toggle switches layers on each fresh press and resets when the profile or mode changes. Analog modifiers follow the same availability as shortcuts.

`; - elements.shiftMap.innerHTML = state.schema.buttons.map((button) => ` -
- - -
`).join(""); + elements.shiftMap.innerHTML = [...state.schema.buttons, ...state.schema.extra_buttons].map((button) => { + const map = state.schema.extra_buttons.includes(button) ? shift.extra_button_map : shift.button_map; + return ` +
+ + +
`; + }).join(""); updateShiftValidity(); } @@ -668,24 +873,6 @@ function updateShiftValidity() { ? "Choose a modifier for Hold or Toggle, or turn Shift off." : ""); } -const controllerArtwork = { - generic: { - source: "/assets/controller-xbox.svg", - alt: "Generic Xbox-layout controller artwork", - }, - xbox: { - source: "/assets/controller-xbox.svg", - alt: "Xbox controller artwork", - }, - switch: { - source: "/assets/controller-switch-pro.svg", - alt: "Nintendo Switch Pro Controller artwork", - }, - playstation: { - source: "/assets/controller-dualsense.svg", - alt: "Sony DualSense controller artwork", - }, -}; const controllerGlyphs = { generic: { @@ -725,6 +912,8 @@ const fixedControlGlyphs = { start: "+", capture: "▣", system: "⌂", + c: "C", gl: "GL", gr: "GR", + left_sl: "L SL", left_sr: "L SR", right_sl: "R SL", right_sr: "R SR", }; function controlGlyph(button, style) { @@ -734,6 +923,7 @@ function controlGlyph(button, style) { function getControlMapping(button) { if (button === "left_trigger") return state.profile.triggers.left.output; if (button === "right_trigger") return state.profile.triggers.right.output; + if (state.schema.extra_buttons.includes(button)) return state.profile.extra_button_map[button]; return state.profile.button_map[button]; } @@ -752,6 +942,8 @@ function setControlMapping(button, output) { state.profile.triggers[otherSide].output = button; } state.profile.triggers[side].output = output; + } else if (state.schema.extra_buttons.includes(button)) { + state.profile.extra_button_map[button] = output; } else { state.profile.button_map[button] = output; } @@ -759,47 +951,172 @@ function setControlMapping(button, output) { function renderButtonMap() { - const owner = state.identities[state.identityIndex]; - const style = owner?.controller?.style || "generic"; - const artwork = controllerArtwork[style] || controllerArtwork.generic; + syncControllerPresentation(); + const { layout, layoutId, sources, preview, liveDiagram, live, topologyKnown } = state.presentation; + const style = currentControllerStyle(); const selected = state.selectedButton; const mappedOutput = getControlMapping(selected); - elements.controllerCanvas.dataset.style = style; - elements.controllerImage.src = artwork.source; - elements.controllerImage.alt = artwork.alt; - elements.controllerModel.textContent = owner?.controller?.model || "Generic controller"; - elements.controllerCredit.href = "https://github.com/AL2009man/Gamepad-Asset-Pack"; - elements.controllerCredit.textContent = "Controller artwork by Al. Lopez · MIT"; - - elements.controllerHotspots.querySelectorAll("[data-controller-button]").forEach((hotspot) => { + elements.controllerCanvas.dataset.layout = layoutId; + elements.controllerLayoutStatus.dataset.state = liveDiagram ? "live" : "preview"; + elements.controllerLayoutStatus.textContent = preview ? "Manual layout preview" : + liveDiagram ? "Live · matching owner" : live ? "Live input · reference diagram" : "Offline · owner reference"; + elements.controllerModel.textContent = layout.name; + 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." + : "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 + ? "This older firmware reports paired input through a physical controller's profile bank. There is no independent L+R bank; edits still affect the selected existing owner. Update firmware for separate pair profiles. The grip is illustrative, not detected." + : layout.note || + (layout.generic ? "Generic reference art is not a model identification. Additional reported sources are unlocated, not buttons on this drawing." : + "Rear triggers are listed off-art below the front view. Unavailable stored mappings are retained."); + const diagramKey = `${layoutId}:${sources.join(",")}`; + if (diagramKey !== state.diagramKey) { + const focusedSource = elements.controllerCanvas.contains(document.activeElement) + ? document.activeElement.dataset.controllerButton : null; + state.diagramKey = diagramKey; + const [x, y, width, height] = layout.viewBox; + const angle = layout.rotation || 0; + const rotated = Math.abs(angle) === 90; + elements.controllerPhotoWrap.style.setProperty("--diagram-min", `${layout.minWidth}px`); + elements.controllerPhotoWrap.style.setProperty("--diagram-max", `${layout.maxWidth || Math.max(layout.minWidth, 900)}px`); + elements.controllerPhotoWrap.style.setProperty("--diagram-ratio", rotated ? `${height} / ${width}` : `${width} / ${height}`); + elements.controllerStage.style.setProperty("--stage-width", `${rotated ? width / height * 100 : 100}%`); + elements.controllerStage.style.setProperty("--stage-height", `${rotated ? height / width * 100 : 100}%`); + elements.controllerStage.style.setProperty("--stage-angle", `${angle}deg`); + elements.controllerStage.style.setProperty("--label-angle", `${-angle}deg`); + elements.controllerImage.src = `/assets/${layout.asset}`; + elements.controllerImage.alt = `${layout.name} · supplied front-view illustration`; + elements.controllerHotspots.replaceChildren(); + elements.extraControls.replaceChildren(); + for (const id of sources) { + const control = layout.controls[id]; + const hotspot = document.createElement("button"); + hotspot.type = "button"; + hotspot.dataset.controllerButton = id; + if (control && !control.offArt) { + hotspot.style.setProperty("--x", `${(control.x - x) / width * 100}%`); + hotspot.style.setProperty("--y", `${(control.y - y) / height * 100}%`); + elements.controllerHotspots.append(hotspot); + } else { + elements.extraControls.append(hotspot); + } + } + for (const note of layout.annotations || []) { + const annotation = document.createElement("span"); + annotation.className = "controller-annotation"; + annotation.textContent = note.label; + annotation.style.setProperty("--x", `${(note.x - x) / width * 100}%`); + annotation.style.setProperty("--y", `${(note.y - y) / height * 100}%`); + elements.controllerHotspots.append(annotation); + } + if (focusedSource) { + const replacement = [...elements.controllerCanvas.querySelectorAll("[data-controller-button]")] + .find(button => button.dataset.controllerButton === focusedSource); + (replacement || elements.selectedSource).focus({ preventScroll: true }); + } + } + elements.controllerCanvas.querySelectorAll("[data-controller-button]").forEach(hotspot => { const button = hotspot.dataset.controllerButton; const output = getControlMapping(button); - hotspot.textContent = controlGlyph(button, style); + hotspot.textContent = hotspot.parentElement === elements.extraControls ? sourceLabel(button) : sourceGlyph(button); hotspot.classList.toggle("selected", button === selected); - hotspot.classList.toggle("disabled-map", output === null); - hotspot.title = `${controlLabel(button, style)} → ${output === null ? "Disabled" : controlLabel(output, style)}`; + hotspot.classList.toggle("disabled-map", output == null); + if (!liveDiagram) hotspot.classList.remove("pressed"); + hotspot.setAttribute("aria-pressed", String(button === selected)); + hotspot.title = `${sourceLabel(button)} [${button}] → ${output == null ? "Disabled" : controlLabel(output)}`; hotspot.setAttribute("aria-label", hotspot.title); - hotspot.onclick = () => { - state.selectedButton = button; - stopMacroPreview("Preview stopped: control selection changed."); - renderButtonMap(); - }; }); - - elements.selectedControlGlyph.textContent = controlGlyph(selected, style); - elements.selectedControlName.textContent = controlLabel(selected, style); - elements.selectedControlDescription.textContent = ( - `Physical ${controlLabel(selected, style)} currently produces ${mappedOutput === null ? "no output" : controlLabel(mappedOutput, style)}.` - ); - elements.selectedMapping.innerHTML = buttonOptions(mappedOutput, true, state.schema.controls, style); - elements.selectedMapping.onchange = () => { - setControlMapping(selected, elements.selectedMapping.value || null); - renderButtonMap(); - updateDirtyState(); - }; + elements.selectedSource.innerHTML = sourceOptions(selected); + elements.selectedSource.querySelector('option[value=""]')?.remove(); + elements.selectedControlGlyph.textContent = sourceGlyph(selected); + elements.selectedControlName.textContent = sourceLabel(selected); + 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); } +elements.controllerCanvas.addEventListener("click", event => { + const hotspot = event.target.closest("[data-controller-button]"); + if (!hotspot || state.busy || captureBlocking()) return; + state.selectedButton = hotspot.dataset.controllerButton; + stopMacroPreview("Preview stopped: control selection changed."); + renderButtonMap(); +}); +elements.selectedSource.addEventListener("change", () => { + if (state.busy || captureBlocking()) return; + state.selectedButton = elements.selectedSource.value; + renderButtonMap(); +}); +elements.selectedMapping.addEventListener("change", () => { + if (state.busy || captureBlocking()) return; + setControlMapping(state.selectedButton, elements.selectedMapping.value || null); + renderButtonMap(); + updateDirtyState(); +}); +elements.controllerLayoutPreview.addEventListener("change", () => { + if (state.busy || captureBlocking()) return; + state.layoutPreview = elements.controllerLayoutPreview.value; + renderButtonMap(); + refreshSourceControls(); +}); + +// Patch source controls in place on topology changes. Never rerender the editor, +// stop macro playback, replace focused inputs, or mutate hidden draft values. +function refreshSourceControls() { + if (!state.profile) return; + elements.builtinActions.dataset.controllerStyle = currentControllerStyle(); + elements.macroControls.dataset.controllerStyle = currentControllerStyle(); + const shortcuts = state.profile.shortcuts; + const modifiers = [ + [elements.shortcutModifier.querySelector("select"), shortcuts.modifier, shortcuts.profiles], + [elements.shift.querySelector("#shift-modifier"), state.profile.shift.modifier, []], + [elements.macroControls.querySelector("#macro-cancel"), state.profile.macros[state.selectedMacro].cancel, []], + ]; + for (const [select, selected, excluded] of modifiers) { + if (select) select.innerHTML = sourceOptions(selected, state.schema.controls, excluded); + } + elements.shortcuts.querySelectorAll("select").forEach((select, index) => { + select.innerHTML = sourceOptions(shortcuts.profiles[index], state.schema.shortcut_selectors, + [shortcuts.modifier, ...shortcuts.profiles.filter((_, other) => other !== index)]); + }); + document.querySelectorAll("[data-source-card]").forEach(card => { + const available = sourceAvailable(card.dataset.sourceCard); + card.hidden = !available && !card.contains(document.activeElement); + card.classList.toggle("source-unavailable", !available); + }); + document.querySelectorAll("[data-source-label]").forEach(node => { + const id = node.dataset.sourceLabel; + node.textContent = sourceLabel(id) + (sourceAvailable(id) ? "" : " (stored / unavailable)"); + }); + document.querySelectorAll('[data-kind="action-chord"]').forEach(input => { + const id = input.dataset.name; + const card = input.closest("label"); + card.hidden = !sourceAvailable(id) && !input.checked && input !== document.activeElement; + card.classList.toggle("source-unavailable", !sourceAvailable(id)); + card.querySelector("b").textContent = sourceGlyph(id); + card.querySelector("small").textContent = sourceLabel(id) + (sourceAvailable(id) ? "" : " (stored / unavailable)"); + }); + elements.shiftMap.querySelectorAll("select").forEach(select => { + const map = state.schema.extra_buttons.includes(select.dataset.name) + ? state.profile.shift.extra_button_map : state.profile.shift.button_map; + select.innerHTML = buttonOptions(map[select.dataset.name], true, state.schema.buttons); + }); + document.querySelectorAll("[data-output-label]").forEach(node => { + node.textContent = controlLabel(node.dataset.outputLabel); + }); +} + +elements.form.addEventListener("focusout", event => { + const card = event.target.closest("[data-source-card]"); + if (card && !sourceAvailable(card.dataset.sourceCard) && !card.contains(event.relatedTarget)) { + card.hidden = true; + } +}); + const analogDefinitions = [ ["sticks", "left", "Left stick", [ ["center_x", "Center X", -32768, 32767], @@ -932,14 +1249,14 @@ function renderTurbo() { elements.turbo.innerHTML = state.schema.buttons.map((button) => { const override = settings.overrides[button]; const off = state.profile.turbo[button] === "off"; - return `
- + return `
+
${turboSettingFields(override || settings.defaults, button, off || !override)}

@@ -960,7 +1277,7 @@ function updateTurboTiming() { card.classList.toggle("narrow-pulse", tooNarrow); card.querySelector("[data-turbo-timing]").textContent = `${on.toFixed(2)} ms ON / ${off.toFixed(2)} ms OFF${mode === "off" ? " · inactive" : ""}`; - if (tooNarrow) narrow.push(controlLabel(button)); + if (tooNarrow) narrow.push(sourceLabel(button)); }); elements.turboTimingNotice.classList.toggle("warning", narrow.length > 0); elements.turboTimingNotice.textContent = narrow.length @@ -982,9 +1299,9 @@ function actionChordCard(action, title, description, selectedButtons, defaults) const effectiveButtons = inherited ? defaults : selectedButtons; const selected = new Set(effectiveButtons); const defaultText = inherited - ? `Using default: ${defaults.map((button) => controlLabel(button)).join(" + ")}.` + ? `Using default: ${defaults.map(button => sourceLabel(button)).join(" + ")}.` : defaults?.length - ? `Clear every selection to restore ${defaults.map((button) => controlLabel(button)).join(" + ")}.` + ? `Clear every selection to restore ${defaults.map(button => sourceLabel(button)).join(" + ")}.` : "Empty disables this action."; return `
@@ -994,11 +1311,11 @@ function actionChordCard(action, title, description, selectedButtons, defaults)
${state.schema.controls.map((button) => ` -
@@ -1192,6 +1509,7 @@ function renderCapture() { ...elements.profileList.querySelectorAll("button"), ].forEach((control) => { control.disabled = true; }); } + renderJoyconMode(); } function capturePath(session, action) { @@ -1615,8 +1933,7 @@ function renderMacro() { stopMacroPreview("Ready", true); draggedMacroStep = null; macroNotice(""); - const controllerStyle = - state.identities[state.identityIndex]?.controller?.style || "generic"; + const controllerStyle = currentControllerStyle(); elements.builtinActions.dataset.controllerStyle = controllerStyle; elements.macroControls.dataset.controllerStyle = controllerStyle; const macro = state.profile.macros[state.selectedMacro]; @@ -1661,7 +1978,7 @@ function renderMacro() {
@@ -1688,7 +2005,7 @@ function renderMacro() { elements.macroPreviewTitle.textContent = `Macro ${state.selectedMacro + 1} draft preview`; elements.macroPreviewButtons.innerHTML = state.schema.buttons.map((button) => ` - ${escapeHtml(controlLabel(button, controllerStyle))}Passthrough + ${escapeHtml(controlLabel(button, controllerStyle))}Passthrough `).join(""); renderMacroSteps(); updateMacroPlayback(); @@ -1745,7 +2062,7 @@ function renderMacroSteps() { ${state.schema.buttons.map((button) => ` `).join("")}
@@ -1756,6 +2073,7 @@ function renderMacroSteps() { } function renderEditor() { + state.playtestShift = { key: "", held: false, active: false }; elements.profileTitle.textContent = state.profileNames[state.profileIndex] || `Profile ${state.profileIndex + 1}`; elements.activeBadge.hidden = !state.active; @@ -1781,6 +2099,7 @@ function renderEditor() { renderFeedback(); renderTurbo(); renderMacro(); + refreshSourceControls(); elements.loading.hidden = true; elements.form.hidden = false; updateDirtyState(); @@ -1805,6 +2124,12 @@ async function loadProfile() { if (owner) owner.alias = payload.alias; state.active = payload.active; setConnection("ready", "Adapter connected"); + syncControllerPresentation(); + if (state.presentation.sources.length && + !sourceAvailable(state.selectedButton) && + getControlMapping(state.selectedButton) == null) { + state.selectedButton = state.presentation.sources[0]; + } renderEditor(); } catch (error) { state.profile = null; @@ -1819,6 +2144,7 @@ async function loadProfile() { async function loadLibrary(preserveDraft = false) { setBusy(true); try { + await refreshJoyconMode(true); const payload = await api("/api/profiles"); syncLibraryMetadata(payload.identities, !preserveDraft); if (!preserveDraft || !state.profile) { @@ -1908,7 +2234,9 @@ function handleFormChange(event) { renderShift(); elements.shift.querySelector(`#${kind}`).focus(); } else if (kind === "shift-map") { - state.profile.shift.button_map[target.dataset.name] = target.value || null; + const map = state.schema.extra_buttons.includes(target.dataset.name) + ? state.profile.shift.extra_button_map : state.profile.shift.button_map; + map[target.dataset.name] = target.value || null; } else if (kind === "turbo") { state.profile.turbo[target.dataset.name] = target.value; renderTurbo(); @@ -1996,6 +2324,7 @@ function handleFormChange(event) { updateMacroBudgets(); } updateDirtyState(); + if (state.liveSample) renderPlaytest(state.liveSample); } elements.analog.addEventListener("click", (event) => { @@ -2032,6 +2361,40 @@ elements.refresh.addEventListener("click", async () => { await loadLibrary(true); }); +elements.joyconMode.addEventListener("change", () => { + if (elements.joyconMode.disabled) return; + joyconMode.selected = elements.joyconMode.value; + joyconMode.pending = joyconModeChanged(); + joyconMode.applyError = ""; + renderJoyconMode(); +}); + +elements.applyJoyconMode.addEventListener("click", async () => { + if (elements.applyJoyconMode.disabled || state.busy || captureBlocking() || + macroCapture.requestActive) return; + joyconMode.applying = true; + joyconMode.applyError = ""; + joyconMode.revision += 1; + setBusy(true); + try { + const payload = await api("/api/joycon-mode", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: joyconMode.selected }), + }); + acceptJoyconMode(payload, true); + toast(`${label(payload.mode)} default saved. Your profile draft is unchanged; live playtest reports the actual arrangement.`); + } catch (error) { + joyconMode.applyError = `Could not confirm player mode: ${error.message}`; + toast(joyconMode.applyError, true); + } finally { + joyconMode.applying = false; + setBusy(false); + window.clearTimeout(state.libraryTimer); + state.libraryTimer = window.setTimeout(pollLibraryMetadata, 0); + } +}); + 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); @@ -2329,6 +2692,7 @@ document.querySelectorAll("[data-reset-section]").forEach((button) => { const section = button.dataset.resetSection; if (section === "mapping") { state.profile.button_map = clone(defaults.button_map); + state.profile.extra_button_map = clone(defaults.extra_button_map); state.profile.triggers.left.output = defaults.triggers.left.output; state.profile.triggers.right.output = defaults.triggers.right.output; } else if (section === "analog") { @@ -2415,7 +2779,7 @@ elements.activate.addEventListener("click", async () => { window.addEventListener("beforeunload", (event) => { stopMacroPreview("Preview stopped: leaving the editor."); stopCapture("Recording stopped because you left the editor."); - if (!isDirty() && !macroCapture.session) return; + if (!isDirty() && !macroCapture.session && !joyconModeChanged()) return; event.preventDefault(); event.returnValue = ""; }); diff --git a/src/switch_pico_bridge/web/profile_playtest.js b/src/switch_pico_bridge/web/profile_playtest.js index e9d6357..cb7293b 100644 --- a/src/switch_pico_bridge/web/profile_playtest.js +++ b/src/switch_pico_bridge/web/profile_playtest.js @@ -88,6 +88,41 @@ ); } + function transformMappings(sample, profile, shiftActive = false) { + const buttons = new Set(); + const triggers = { left: 0, right: 0 }; + const consumed = profile.shift.mode !== "off" ? profile.shift.modifier : null; + const map = shiftActive ? profile.shift : profile; + const route = (output, value = 65535) => { + if (output === "left_trigger" || output === "right_trigger") { + const side = output === "left_trigger" ? "left" : "right"; + triggers[side] = Math.max(triggers[side], value); + } else if (output && Object.hasOwn(profile.button_map, output)) { + buttons.add(output); + } + }; + for (const source of sample.buttons) { + if (source !== consumed) route(map.button_map[source]); + } + for (const source of sample.extra_buttons || []) { + if (source !== consumed) route(map.extra_button_map[source]); + } + for (const side of ["left", "right"]) { + if (`${side}_trigger` === consumed) continue; + const config = profile.triggers[side]; + const value = transformTrigger(sample.triggers[side], config); + if (config.output === "left_trigger" || config.output === "right_trigger") { + route(config.output, value); + } else if (value >= config.digital_threshold) { + route(config.output); + } + } + return { + buttons: Object.keys(profile.button_map).filter((button) => buttons.has(button)), + triggers, + }; + } + function stickPosition(value) { return Math.max(3, Math.min(97, 50 + value / 32768 * 47)); } @@ -106,6 +141,7 @@ root.ProfilePlaytestMath = Object.freeze({ transformStick, transformTrigger, + transformMappings, stickCoordinates, triggerPercent, }); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index 8a74540..2efe776 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -2,25 +2,29 @@ #include #include #include +#include #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT #include #include -#include #endif #include +#include "parser/uni_hid_parser_switch2.h" +#include "parser/uni_switch2_haptics.h" +#include "parser/uni_switch2_pairing.h" #include "platform/pico/controller_color_config.h" #include "input/switch2_wake.h" +#include "pico/critical_section.h" +#include "profile/profile_storage.h" +#include "profile/controller_profile_runtime.h" namespace { bool incoming_connections = false; -int scan_starts = 0; -int scan_stops = 0; bool scanning_enabled = false; -int classic_scan_starts = 0; -int classic_scan_stops = 0; +bool aggregate_scanning_enabled = false; +bool background_scan_parameters = false; bool classic_scanning_enabled = false; uni_platform* installed_platform = nullptr; bool observed_status_led_on = false; @@ -50,6 +54,7 @@ bool expect_configuration_timer_prearmed = false; uint32_t expected_configuration_timer_add_count = 0; int cyw43_init_calls = 0; int uni_init_calls = 0; +void (*during_uni_init)() = nullptr; int switch2_wake_initializations = 0; int switch2_wake_requests = 0; bool switch2_connections_ready = true; @@ -64,9 +69,39 @@ void require_clear_completion_pending(); void require_clear_snapshot_published(); void request_repeated_clear_during_disconnect(); gap_connection_type_t gap_connection_types[256]{}; +uint16_t negotiated_intervals[256]{}; +uint16_t pending_intervals[256]{}; +unsigned interval_requests[256]{}; +bool defer_interval_updates = false; +bool reject_interval_updates = false; uint8_t xbox_left_trigger = 0; uint8_t xbox_right_trigger = 0; unsigned xbox_quad_calls = 0; +unsigned ordinary_smp_requests = 0; +bd_addr_t switch2_pairings[UNI_SWITCH2_PAIRING_CAPACITY]{}; +uint8_t switch2_pairing_types[UNI_SWITCH2_PAIRING_CAPACITY]{}; +uint8_t switch2_pairing_count = 0; +bool switch2_clear_succeeds = true; + +struct Switch2HostEvent { + uni_hid_device_t* device; + uni_switch2_haptics_frame_t frame; + uint32_t received_ms; + uint16_t duration_ms; + uint8_t weak; + uint8_t strong; + bool hd; +}; +std::vector switch2_host_events; +uint32_t switch2_output_drops = 0; + +struct LocalRumbleEvent { + uni_hid_device_t* device; + uint16_t duration_ms; + uint8_t high; + uint8_t low; +}; +std::vector local_rumble_events; struct CoreStopped {}; @@ -80,6 +115,7 @@ void require(bool condition, const char* message) { void play_rumble(uni_hid_device_t* device, uint16_t, uint16_t duration_ms, uint8_t high, uint8_t low) { + local_rumble_events.push_back({device, duration_ms, high, low}); ++device->rumble_calls; device->last_high = high; device->last_low = low; @@ -107,6 +143,8 @@ uni_hid_device_t device( result.gamepad = gamepad; result.conn.protocol = protocol; result.conn.handle = static_cast(0x40 + idx); + negotiated_intervals[result.conn.handle] = 6; + pending_intervals[result.conn.handle] = 0; result.conn.btaddr[5] = static_cast(idx + 1); result.vendor_id = static_cast(0x1000 + idx); result.product_id = static_cast(0x2000 + idx); @@ -145,6 +183,103 @@ void uni_hid_parser_xboxone_play_dual_rumble( } +extern "C" void __real_sm_request_pairing(hci_con_handle_t) { + ++ordinary_smp_requests; +} + +extern "C" bool uni_switch2_pairing_get( + uint8_t index, uint8_t* address_type, uint8_t address[6]) { + if (index >= switch2_pairing_count) { + return false; + } + *address_type = switch2_pairing_types[index]; + memcpy(address, switch2_pairings[index], sizeof(bd_addr_t)); + return true; +} + +extern "C" bool uni_switch2_pairing_known( + uint8_t address_type, const uint8_t address[6]) { + for (uint8_t index = 0; index < switch2_pairing_count; ++index) { + if (switch2_pairing_types[index] == address_type && + memcmp(switch2_pairings[index], address, sizeof(bd_addr_t)) == 0) { + return true; + } + } + return false; +} + +extern "C" bool uni_switch2_pairing_clear(void) { + if (!switch2_clear_succeeds) { + return false; + } + switch2_pairing_count = 0; + return true; +} + +extern "C" bool uni_hid_parser_switch2_is_ble_device( + const uni_hid_device_t* device) { + return device != nullptr && + device->conn.protocol == UNI_BT_CONN_PROTOCOL_BLE && + device->vendor_id == UNI_SW2_NINTENDO_VID && + (device->product_id == UNI_SW2_PRO_PID || + device->product_id == UNI_SW2_JOYCON_L_PID || + device->product_id == UNI_SW2_JOYCON_R_PID); +} + +extern "C" bool uni_hid_parser_switch2_queue_haptics( + uni_hid_device_t* device, const uni_switch2_haptics_frame_t* frame, + uint32_t received_ms) { + require(uni_switch2_haptics_valid(frame), "backend emitted invalid physical HD frame"); + const bool stop = device->product_id == UNI_SW2_PRO_PID ? + uni_switch2_haptics_is_stop(frame) : [&]() { + uni_switch2_haptics_frame_t stereo = *frame; + stereo.sides[1] = stereo.sides[0]; + return uni_switch2_haptics_is_stop(&stereo); + }(); + if (device->switch2_host_blocked && !stop) return false; + ++device->switch2_host_calls; + switch2_host_events.push_back({device, *frame, received_ms, 0, 0, 0, true}); + return true; +} + +extern "C" bool uni_hid_parser_switch2_queue_rumble( + uni_hid_device_t* device, uint8_t weak, uint8_t strong, + uint16_t duration_ms, uint32_t received_ms) { + if (device->switch2_host_blocked && (weak | strong) != 0) return false; + ++device->switch2_host_calls; + device->last_high = weak; + device->last_low = strong; + device->last_rumble_duration_ms = duration_ms; + switch2_host_events.push_back({device, {}, received_ms, duration_ms, weak, strong, false}); + return true; +} + +extern "C" void uni_hid_parser_switch2_reset_haptics(uni_hid_device_t* device) { + ++device->switch2_haptics_resets; + device->last_high = 0; + device->last_low = 0; + device->last_rumble_duration_ms = 0; +} + +extern "C" uint32_t uni_hid_parser_switch2_haptics_dropped(void) { + return switch2_output_drops; +} + +extern "C" uint8_t uni_hid_parser_switch2_extra_buttons( + const uni_hid_device_t* device) { + return uni_hid_parser_switch2_is_ble_device(device) + ? device->switch2_extra_buttons : 0; +} + +extern "C" bool uni_hid_parser_switch2_identity_address_type( + const uni_hid_device_t* device, uint8_t* output) { + if (!uni_hid_parser_switch2_is_ble_device(device) || + !device->switch2_identity_valid || output == nullptr) { + return false; + } + *output = device->switch2_identity_address_type; + return true; +} bool uni_hid_device_is_gamepad(const uni_hid_device_t* device) { return device != nullptr && device->gamepad; } @@ -162,6 +297,15 @@ uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle( } return nullptr; } + +uni_hid_device_t* uni_hid_device_get_instance_for_address(const bd_addr_t address) { + for (size_t index = 0; index < lookup_device_count; ++index) { + if (memcmp(lookup_devices[index]->conn.btaddr, address, sizeof(bd_addr_t)) == 0) { + return lookup_devices[index]; + } + } + return nullptr; +} void uni_hid_device_disconnect(uni_hid_device_t* device) { require_clear_completion_pending(); request_repeated_clear_during_disconnect(); @@ -175,37 +319,42 @@ void uni_bt_allow_incoming_connections(bool enabled) { void uni_bt_bredr_scan_start() { - ++classic_scan_starts; classic_scanning_enabled = true; } void uni_bt_bredr_scan_stop() { - if (classic_scanning_enabled) { - ++classic_scan_stops; - } classic_scanning_enabled = false; } void uni_bt_le_scan_start() { - ++scan_starts; scanning_enabled = true; } void uni_bt_le_scan_stop() { - if (scanning_enabled) { - ++scan_stops; - } scanning_enabled = false; } +void uni_bt_le_set_background_scan(bool enabled) { + require(!scanning_enabled, "LE scan timing must change only while scanning is stopped"); + background_scan_parameters = enabled; +} + void uni_bt_start_scanning_and_autoconnect_unsafe() { require_clear_completion_pending(); require_clear_snapshot_published(); + if (aggregate_scanning_enabled) { + return; + } + aggregate_scanning_enabled = true; uni_bt_bredr_scan_start(); uni_bt_le_scan_start(); } void uni_bt_stop_scanning_unsafe() { + if (!aggregate_scanning_enabled) { + return; + } + aggregate_scanning_enabled = false; uni_bt_bredr_scan_stop(); uni_bt_le_scan_stop(); } @@ -225,6 +374,25 @@ gap_connection_type_t gap_get_connection_type( : GAP_CONNECTION_INVALID; } +uint16_t gap_le_connection_interval(hci_con_handle_t handle) { + require(handle < 256, "interval read must target a valid fake connection"); + return negotiated_intervals[handle]; +} + +int gap_update_connection_parameters(hci_con_handle_t handle, uint16_t minimum, + uint16_t maximum, uint16_t latency, + uint16_t supervision_timeout) { + require(handle < 256 && gap_get_connection_type(handle) == GAP_CONNECTION_LE, + "interval policy must not update a Classic or invalid link"); + require(minimum == maximum && latency == 0 && supervision_timeout >= 100, + "interval request must retain valid supervision and peripheral latency"); + ++interval_requests[handle]; + if (reject_interval_updates) return 0x0c; + if (defer_interval_updates) pending_intervals[handle] = minimum; + else negotiated_intervals[handle] = minimum; + return ERROR_CODE_SUCCESS; +} + int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator) { iterator->index = 0; return 1; @@ -438,6 +606,7 @@ void uni_platform_set_custom(uni_platform* platform) { int uni_init(int, const char**) { ++uni_init_calls; + if (during_uni_init != nullptr) during_uni_init(); return 0; } @@ -493,7 +662,26 @@ void switch2_wake_diagnostics(Switch2WakeDiagnostics*) { } #include "core/controller_identity.cpp" +namespace { +unsigned state_lock_depth = 0; + +void tracked_state_lock_enter(critical_section_t* lock) { + critical_section_enter_blocking(lock); + ++state_lock_depth; +} + +void tracked_state_lock_exit(critical_section_t* lock) { + require(state_lock_depth != 0, "state lock exit must match an enter"); + --state_lock_depth; + critical_section_exit(lock); +} +} // namespace + +#define critical_section_enter_blocking tracked_state_lock_enter +#define critical_section_exit tracked_state_lock_exit #include "input/bluepad32_input_backend.cpp" +#undef critical_section_enter_blocking +#undef critical_section_exit #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT namespace { @@ -546,6 +734,64 @@ void haptics_transport_probe_send(uint32_t, uint32_t, bool) {} #endif namespace { +uint8_t profile_flash[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE]{}; +ProfileStorage runtime_profile_storage; +bool runtime_profile_storage_initialized = false; +bool fail_profile_program = false; +unsigned profile_write_attempts = 0; +unsigned pair_observation_count = 0; +void (*before_pair_seed)(const ControllerIdentity&) = nullptr; + +bool runtime_profile_read(void*, uint8_t arena, size_t offset, + uint8_t* output, size_t size) { + require(state_lock_depth == 0, "profile reads must not hold the input state lock"); + if (arena >= PROFILE_STORAGE_ARENA_COUNT || + offset > PROFILE_STORAGE_ARENA_SIZE || + size > PROFILE_STORAGE_ARENA_SIZE - offset) { + return false; + } + memcpy(output, &profile_flash[arena][offset], size); + return true; +} + +bool runtime_profile_erase(void*, uint8_t arena) { + ++profile_write_attempts; + require(state_lock_depth == 0, "profile erases must not hold the input state lock"); + if (arena >= PROFILE_STORAGE_ARENA_COUNT) return false; + memset(profile_flash[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE); + return true; +} + +bool runtime_profile_program(void*, uint8_t arena, size_t offset, + const uint8_t* page, size_t size) { + ++profile_write_attempts; + require(state_lock_depth == 0, "profile writes must not hold the input state lock"); + if (fail_profile_program || arena >= PROFILE_STORAGE_ARENA_COUNT || + size != PROFILE_STORAGE_PAGE_SIZE || + offset % PROFILE_STORAGE_PAGE_SIZE != 0 || + offset > PROFILE_STORAGE_ARENA_SIZE - size) { + return false; + } + for (size_t index = 0; index < size; ++index) { + profile_flash[arena][offset + index] &= page[index]; + } + return memcmp(&profile_flash[arena][offset], page, size) == 0; +} + +ProfileStorageIo runtime_profile_io() { + return {nullptr, PROFILE_STORAGE_ARENA_SIZE, + PROFILE_STORAGE_SECTOR_SIZE, PROFILE_STORAGE_PAGE_SIZE, + runtime_profile_read, runtime_profile_erase, runtime_profile_program}; +} + +void initialize_runtime_profile_storage() { + if (runtime_profile_storage_initialized) return; + memset(profile_flash, 0xff, sizeof(profile_flash)); + require(runtime_profile_storage.initialize(runtime_profile_io()), + "runtime profile catalog must initialize"); + runtime_profile_storage_initialized = true; +} + void require_clear_completion_pending() { if (expected_pending_clear_token != 0) { require(!bluepad32_input_backend_clear_pairings_completed( @@ -571,6 +817,8 @@ void request_repeated_clear_during_disconnect() { } // namespace ControllerIdentity observed_profile_identities[8]{}; size_t observed_profile_identity_count = 0; +AdapterConfiguration runtime_configuration{}; +uint32_t runtime_configuration_generation = 1; void configuration_service_prepare() {} void configuration_service_initialize_on_storage_core() {} void configuration_service_task_on_storage_core(uint32_t) { @@ -597,13 +845,52 @@ bool profile_service_observe_identity_on_storage_core( "profile identity observation fixture overflow"); observed_profile_identities[observed_profile_identity_count++] = identity; + if (runtime_profile_storage_initialized) { + const ProfileStorageResult result = runtime_profile_storage.ensure_identity(identity); + return result == ProfileStorageResult::kOk || + result == ProfileStorageResult::kUnchanged; + } return true; } +bool profile_service_observe_joycon_pair_on_storage_core( + const ControllerIdentity& identity) { + require(state_lock_depth == 0, "pair admission must release the input state lock"); + ++pair_observation_count; + if (before_pair_seed != nullptr) before_pair_seed(identity); + initialize_runtime_profile_storage(); + const ProfileStorageResult result = runtime_profile_storage.ensure_joycon_pair(identity); + return result == ProfileStorageResult::kOk || + result == ProfileStorageResult::kUnchanged; +} void configuration_service_snapshot(ConfigurationServiceSnapshot* output) { *output = {}; output->state = ConfigurationServiceState::kReady; - output->configuration.pairing_window_seconds = - ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT; + output->configuration = runtime_configuration; + output->generation = runtime_configuration_generation; +} +uint32_t configuration_service_reset_generation() { + return 0; +} +uint32_t profile_service_database_generation() { + return runtime_profile_storage.snapshot().generation; +} +void profile_service_active_profile_snapshot( + const ControllerIdentity& identity, ProfileServiceActiveProfileSnapshot* output) { + *output = {}; + output->metadata.state = ProfileServiceState::kReady; + output->metadata.generation = profile_service_database_generation(); + const auto* owner = runtime_profile_storage.find(identity); + if (owner == nullptr) return; + output->profile_index = owner->active_profile; + output->valid = runtime_profile_storage.get( + identity, owner->active_profile, &output->profile) == ProfileStorageResult::kOk; +} +ConfigurationTransactionStatus profile_service_activate_internal( + uint32_t, const ControllerIdentity& identity, uint8_t profile_index) { + const auto result = runtime_profile_storage.activate(identity, profile_index); + return result == ProfileStorageResult::kOk || result == ProfileStorageResult::kUnchanged + ? ConfigurationTransactionStatus::kCommitted + : ConfigurationTransactionStatus::kStorageError; } #ifdef SWITCH_PICO_USB_OUTPUT_MODES AdapterUsbMode test_adapter_mode = AdapterUsbMode::kXInput; @@ -643,7 +930,7 @@ void start_backend() { bluepad32_input_backend_init(); platform_on_init_complete(); require(incoming_connections && scanning_enabled && - classic_scanning_enabled && scan_starts == 1 && + classic_scanning_enabled && link_supervision_timeout == kClassicLinkSupervisionTimeout && !bondable && accepted_stk_methods == 0 && @@ -685,6 +972,10 @@ void dispatch_identity_event(uint8_t event_type, packet[2] = static_cast(controller.conn.handle); packet[3] = static_cast(controller.conn.handle >> 8); switch (event_type) { + case SM_EVENT_IDENTITY_RESOLVING_STARTED: + case SM_EVENT_IDENTITY_RESOLVING_FAILED: + packet_size = 11; + break; case SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED: packet_size = sizeof(packet); packet[4] = BD_ADDR_TYPE_LE_RANDOM; @@ -783,6 +1074,2297 @@ void tick_backend_timer(int ticks) { } } +uni_hid_device_t switch2_device(int index, uint16_t product) { + uni_hid_device_t result = device(index, true, UNI_BT_CONN_PROTOCOL_BLE); + result.vendor_id = UNI_SW2_NINTENDO_VID; + result.product_id = product; + result.switch2_identity_valid = true; + result.switch2_identity_address_type = BD_ADDR_TYPE_LE_PUBLIC; + result.report_parser.set_player_leds = set_player_leds; + return result; +} + +void ready_switch2(uni_hid_device_t& controller) { + platform_on_device_connected(&controller); + require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS, + "Switch2 physical device must become ready"); +} + +void remember_switch2(const uni_hid_device_t& controller) { + require(switch2_pairing_count < UNI_SWITCH2_PAIRING_CAPACITY, + "test proprietary trust inventory overflow"); + switch2_pairing_types[switch2_pairing_count] = controller.switch2_identity_address_type; + memcpy(switch2_pairings[switch2_pairing_count], controller.conn.btaddr, sizeof(bd_addr_t)); + ++switch2_pairing_count; +} + +Bluepad32SlotSnapshot slot_snapshot(uint8_t index) { + Bluepad32SlotSnapshot result{}; + bluepad32_input_backend_snapshot(index, &result); + return result; +} + +void require_pair_owner(const ControllerIdentity& identity, + const uni_hid_device_t& left, + const uni_hid_device_t& right) { + ControllerIdentity left_member{}; + ControllerIdentity right_member{}; + require(controller_identity_is_joycon_pair(identity) && + controller_identity_joycon_pair_members( + identity, &left_member, &right_member) && + controller_identity_equal(left_member, identity_for_device(&left)) && + controller_identity_equal(right_member, identity_for_device(&right)) && + !controller_identity_equal(identity, left_member) && + !controller_identity_equal(identity, right_member), + "logical owner must distinguish both typed physical members from their solo owners"); +} + +void require_active_profile(const ControllerIdentity& identity, + uint8_t expected_index, uint8_t expected_scale) { + const ProfileStorageIdentityIndex* owner = runtime_profile_storage.find(identity); + ControllerProfile profile{}; + require(owner != nullptr && owner->active_profile == expected_index && + runtime_profile_storage.get(identity, owner->active_profile, &profile) == + ProfileStorageResult::kOk && + profile.weak_rumble_scale == expected_scale, + "live owner must resolve its independent persisted active profile"); +} + +void set_runtime_joycon_mode(JoyConMode mode) { + runtime_configuration.joycon_mode = mode; + ++runtime_configuration_generation; + process_configuration_timer(&g_configuration_timer); + ConfigurationServiceSnapshot saved{}; + configuration_service_snapshot(&saved); + require(saved.configuration.joycon_mode == mode && + saved.generation == runtime_configuration_generation && + device_disconnect_calls == 0 && uni_init_calls == 0 && + core1_launch_calls == 0, + "live mode updates must retain the committed preference without disconnect or restart"); +} +void test_switch2_individual_core_start() { + runtime_configuration.joycon_mode = JoyConMode::kIndividual; + bluepad32_input_backend_init(); + auto right = switch2_device(0, UNI_SW2_JOYCON_R_PID); + auto left = switch2_device(1, UNI_SW2_JOYCON_L_PID); + register_lookup_device(&right); + register_lookup_device(&left); + during_uni_init = []() { + require(installed_platform != nullptr && + installed_platform->on_device_ready(lookup_devices[0]) == UNI_ERROR_SUCCESS && + installed_platform->on_device_ready(lookup_devices[1]) == UNI_ERROR_SUCCESS && + slot_snapshot(0).active && slot_snapshot(1).active && + pair_observation_count == 0, + "persisted Individual must govern ready callbacks before BTstack initialization completes"); + }; + bool stopped = false; + try { + core1_main(); + } catch (const CoreStopped&) { + stopped = true; + } + during_uni_init = nullptr; + require(stopped && uni_init_calls == 1 && slot_snapshot(0).active && slot_snapshot(1).active, + "Core1 initialization must retain both solo players without an initial pair race"); +} + + +void test_switch2_individual_boot(bool right_first) { + runtime_configuration.joycon_mode = JoyConMode::kIndividual; + start_backend(); + auto left = switch2_device(right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID); + auto& first = right_first ? right : left; + auto& second = right_first ? left : right; + remember_switch2(left); + remember_switch2(right); + register_lookup_device(&left); + register_lookup_device(&right); + ready_switch2(first); + require(scanning_enabled && background_scan_parameters && + !classic_scanning_enabled && !bondable && + accepted_stk_methods == 0 && !switch_pico_switch2_pairing_allowed() && + platform_on_device_discovered(second.conn.btaddr, nullptr, 0, 0) == + UNI_ERROR_SUCCESS, + "Individual must still admit a remembered opposite half without BOOTSEL or authentication"); + const auto first_solo = slot_snapshot(0); + platform_on_device_connected(&second); + require(!scanning_enabled && + platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, + "remembered Individual mate setup must pause scanning and become ready"); + process_configuration_timer(&g_configuration_timer); + require(slot_snapshot(0).active && slot_snapshot(1).active && + slot_snapshot(0).connection_generation == first_solo.connection_generation && + !scanning_enabled && !classic_scanning_enabled && pair_observation_count == 0, + "persisted Individual must avoid transient boot merging and stop scanning once balanced"); + require(controller_identity_equal(slot_snapshot(left.idx).identity, identity_for_device(&left)) && + controller_identity_equal(slot_snapshot(right.idx).identity, identity_for_device(&right)), + "both Individual players must publish their own solo profile identity"); + uni_controller_t data{}; + data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + data.gamepad.dpad = DPAD_LEFT; + data.gamepad.axis_x = -512; + data.gamepad.axis_y = 100; + left.switch2_extra_buttons = UNI_SW2_BUTTON_LEFT_SL; + platform_on_controller_data(&left, &data); + const auto left_state = slot_snapshot(left.idx).state; + data.gamepad = {}; + data.gamepad.buttons = BUTTON_B | BUTTON_THUMB_R; + data.gamepad.axis_rx = 200; + data.gamepad.axis_ry = -512; + right.switch2_extra_buttons = UNI_SW2_BUTTON_RIGHT_SR; + platform_on_controller_data(&right, &data); + const auto right_state = slot_snapshot(right.idx).state; + require(left_state.button_south && !left_state.dpad_left && + left_state.button_left_shoulder && !left_state.button_right_shoulder && + left_state.left_stick_x == scale_axis(100) && + left_state.left_stick_y == INT16_MAX && + right_state.button_south && right_state.button_left_stick && + !right_state.button_right_stick && right_state.button_right_shoulder && + !right_state.button_left_shoulder && + right_state.left_stick_x == INT16_MAX && + right_state.left_stick_y == scale_axis(200) && + slot_snapshot(left.idx).state.left_stick_x == left_state.left_stick_x, + "two Individual halves must keep independent sideways input and rail routing"); + Bluepad32PlaytestSnapshot playtest{}; + bluepad32_input_backend_playtest_snapshot(left.idx, &playtest); + require(playtest.controller_layout == Bluepad32ControllerLayout::kJoyCon2LeftSolo, + "Individual left must report actual solo topology"); + bluepad32_input_backend_playtest_snapshot(right.idx, &playtest); + require(playtest.controller_layout == Bluepad32ControllerLayout::kJoyCon2RightSolo, + "Individual right must report actual solo topology"); + bluepad32_input_backend_queue_rumble(left.idx, ControllerRumbleOutput{17, 27}); + process_rumble_timer(&g_rumble_timer); + require(left.last_low == 17 && right.last_low == 0, + "Individual player rumble must not fan out to its opposite half"); + platform_on_device_disconnected(&second); + require(scanning_enabled && !classic_scanning_enabled && + platform_on_device_discovered(second.conn.btaddr, nullptr, 0, 0) == + UNI_ERROR_SUCCESS, + "losing an Individual half must resume only remembered opposite-half scanning"); + ready_switch2(second); + require(!scanning_enabled && !classic_scanning_enabled && + slot_snapshot(0).active && slot_snapshot(1).active, + "remembered Individual reconnection must stop scanning again without merging"); +} + +void test_switch2_mode_roundtrip(bool right_first) { + start_backend(); + initialize_runtime_profile_storage(); + auto left = switch2_device(right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID); + const auto left_identity = identity_for_device(&left); + const auto right_identity = identity_for_device(&right); + auto left_profile = controller_profile_default(left_identity, 2); + left_profile.weak_rumble_scale = 37; + auto right_profile = controller_profile_default(right_identity, 5); + right_profile.weak_rumble_scale = 63; + require(runtime_profile_storage.set(left_identity, 2, left_profile) == ProfileStorageResult::kOk && + runtime_profile_storage.activate(left_identity, 2) == ProfileStorageResult::kOk && + runtime_profile_storage.set(right_identity, 5, right_profile) == ProfileStorageResult::kOk && + runtime_profile_storage.activate(right_identity, 5) == ProfileStorageResult::kOk, + "roundtrip requires independently selected saved solo banks"); + ready_switch2(right_first ? right : left); + ready_switch2(right_first ? left : right); + const auto pair_identity = slot_snapshot(0).identity; + auto pair_profile = controller_profile_default(pair_identity, 7); + pair_profile.weak_rumble_scale = 95; + require(runtime_profile_storage.set(pair_identity, 7, pair_profile) == ProfileStorageResult::kOk && + runtime_profile_storage.activate(pair_identity, 7) == ProfileStorageResult::kOk, + "roundtrip requires a distinct saved composite-bank selection"); + auto classic = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + platform_on_device_connected(&classic); + require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS, + "Classic must coexist with either Joy-Con player mode"); + uni_controller_t data{}; + data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + data.gamepad.buttons = BUTTON_Y; + platform_on_controller_data(&classic, &data); + data.gamepad = {}; + data.gamepad.dpad = DPAD_UP; + data.gamepad.axis_x = -512; + data.gamepad.axis_y = 100; + left.switch2_extra_buttons = UNI_SW2_BUTTON_GL | UNI_SW2_BUTTON_LEFT_SL; + platform_on_controller_data(&left, &data); + data.gamepad = {}; + data.gamepad.buttons = BUTTON_B; + data.gamepad.axis_rx = 200; + data.gamepad.axis_ry = -512; + right.switch2_extra_buttons = UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_RIGHT_SR; + platform_on_controller_data(&right, &data); + bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{71, 81}); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{31, 41}); + process_rumble_timer(&g_rumble_timer); + const auto pair_before = slot_snapshot(0); + const auto spare_before = slot_snapshot(1); + const auto classic_before = slot_snapshot(2); + const int classic_calls = classic.rumble_calls; + const unsigned left_resets = left.switch2_haptics_resets; + const unsigned right_resets = right.switch2_haptics_resets; + require(bluepad32_input_backend_capture_start( + 0, pair_before.connection_generation, CaptureOptions{}), + "pair capture must start before splitting"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101}); + bluepad32_input_backend_queue_profile_feedback( + 0, pair_before.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed); + set_runtime_joycon_mode(JoyConMode::kIndividual); + const auto left_solo = slot_snapshot(0); + const auto right_solo = slot_snapshot(1); + require(left_solo.active && right_solo.active && + left_solo.connection_generation != pair_before.connection_generation && + right_solo.connection_generation != spare_before.connection_generation && + controller_identity_equal(left_solo.identity, left_identity) && + controller_identity_equal(right_solo.identity, right_identity) && + left_solo.state.button_west && !left_solo.state.dpad_up && + left_solo.state.left_stick_x == scale_axis(100) && + left_solo.state.left_stick_y == INT16_MAX && + right_solo.state.button_south && !right_solo.state.button_east && + right_solo.state.left_stick_x == INT16_MAX && + right_solo.state.left_stick_y == scale_axis(200) && + left_solo.state.motion_sample_count == 0 && right_solo.state.motion_sample_count == 0, + "split must retain both raw halves and immediately restore rotated solo owners at stable slots"); + require_active_profile(left_solo.identity, 2, 37); + require_active_profile(right_solo.identity, 5, 63); + Bluepad32CaptureSnapshot capture{}; + require(bluepad32_input_backend_capture_page(0, 0, &capture) && + capture.state == CaptureState::kDisconnected && + left.switch2_haptics_resets > left_resets && + right.switch2_haptics_resets > right_resets && + left.last_rumble_duration_ms == 0 && right.last_rumble_duration_ms == 0, + "split must cancel recording and both physical haptics epochs"); + const int stops = left.rumble_calls + right.rumble_calls; + bluepad32_input_backend_queue_profile_feedback( + 0, pair_before.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed); + process_rumble_timer(&g_rumble_timer); + require(left.rumble_calls + right.rumble_calls == stops && + left.last_low == 0 && right.last_low == 0 && + left.player_leds == 1 && right.player_leds == 2 && + !bluepad32_input_backend_identify(pair_identity), + "retired pair feedback and identify must not leak into Individual players"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{17, 27}); + bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{47, 57}); + process_rumble_timer(&g_rumble_timer); + require(left.last_low == 17 && right.last_low == 47, + "split outputs must route independent player haptics"); + const uint8_t capture_slot = right_first ? 1 : 0; + require(bluepad32_input_backend_capture_start( + capture_slot, slot_snapshot(capture_slot).connection_generation, CaptureOptions{}), + "either Individual slot must remain recordable before remerging"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101}); + bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{111, 121}); + bluepad32_input_backend_queue_profile_feedback( + 1, right_solo.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed); + before_pair_seed = [](const ControllerIdentity&) { + require(slot_snapshot(0).active && slot_snapshot(1).active && + !controller_identity_is_joycon_pair(slot_snapshot(0).identity) && + !controller_identity_is_joycon_pair(slot_snapshot(1).identity), + "live pair storage admission must precede publication of either topology change"); + }; + set_runtime_joycon_mode(JoyConMode::kPaired); + before_pair_seed = nullptr; + const auto restored = slot_snapshot(0); + require_pair_owner(restored.identity, left, right); + require(restored.connection_generation != left_solo.connection_generation && + !slot_snapshot(1).active && + slot_snapshot(1).connection_generation != right_solo.connection_generation && + !slot_snapshot(1).state.button_south && + slot_snapshot(1).state.extra_buttons == 0 && + restored.state.dpad_up && restored.state.button_east && + restored.state.left_stick_x == INT16_MIN && + restored.state.right_stick_x == scale_axis(200) && + restored.state.motion_sample_count == 0 && + bluepad32_input_backend_capture_page(0, 0, &capture) && + capture.state == CaptureState::kDisconnected && + left.last_rumble_duration_ms == 0 && right.last_rumble_duration_ms == 0, + "remerge must retain raw pair inputs, cancel either recording, and neutralize the retired solo"); + require_active_profile(restored.identity, 7, 95); + require_active_profile(left_identity, 2, 37); + require_active_profile(right_identity, 5, 63); + process_rumble_timer(&g_rumble_timer); + require(left.last_low == 0 && right.last_low == 0 && + slot_snapshot(2).connection_generation == classic_before.connection_generation && + slot_snapshot(2).state.button_north && + classic.rumble_calls == classic_calls && classic.last_low == 71 && + negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6, + "mode roundtrip must preserve unrelated Classic state, haptics and fast Joy-Con links"); + ControllerProfile inactive{}; + require(runtime_profile_storage.get(pair_identity, 2, &inactive) == ProfileStorageResult::kOk && + inactive.weak_rumble_scale == 37, + "existing composite profiles, including inactive seeded rows, must survive mode roundtrips"); +} + +void test_switch2_mode_two_pairs() { + start_pairing_backend(); + auto right0 = switch2_device(0, UNI_SW2_JOYCON_R_PID); + auto right1 = switch2_device(1, UNI_SW2_JOYCON_R_PID); + auto left0 = switch2_device(2, UNI_SW2_JOYCON_L_PID); + auto left1 = switch2_device(3, UNI_SW2_JOYCON_L_PID); + ready_switch2(right0); + ready_switch2(right1); + ready_switch2(left0); + ready_switch2(left1); + const auto pair0 = slot_snapshot(0).identity; + const auto pair1 = slot_snapshot(1).identity; + require(runtime_profile_storage.activate(pair0, 6) == ProfileStorageResult::kOk && + runtime_profile_storage.activate(pair1, 3) == ProfileStorageResult::kOk, + "two pairs need distinguishable saved selections"); + for (unsigned roundtrip = 0; roundtrip < 2; ++roundtrip) { + set_runtime_joycon_mode(JoyConMode::kIndividual); + for (uint8_t slot = 0; slot < 4; ++slot) { + require(slot_snapshot(slot).active, "splitting two pairs must expose all four live players"); + } + require(controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left0)) && + controller_identity_equal(slot_snapshot(1).identity, identity_for_device(&left1)) && + controller_identity_equal(slot_snapshot(2).identity, identity_for_device(&right0)) && + controller_identity_equal(slot_snapshot(3).identity, identity_for_device(&right1)) && + !scanning_enabled && !incoming_connections, + "full-capacity split must choose lowest free slots without moving existing left owners"); + set_runtime_joycon_mode(JoyConMode::kPaired); + require_pair_owner(slot_snapshot(0).identity, left0, right0); + require_pair_owner(slot_snapshot(1).identity, left1, right1); + require(!slot_snapshot(2).active && !slot_snapshot(3).active && + !scanning_enabled && !incoming_connections, + "live pairing hints must restore both exact member combinations and original output slots"); + require_active_profile(pair0, 6, UINT8_MAX); + require_active_profile(pair1, 3, UINT8_MAX); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{11, 21}); + bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{31, 41}); + process_rumble_timer(&g_rumble_timer); + require(left0.last_low == 11 && right0.last_low == 11 && + left1.last_low == 31 && right1.last_low == 31, + "roundtripped pair membership must remain visible in physical haptic routing"); + } +} + +void test_switch2_live_pair_failure(bool invalid_identity) { + runtime_configuration.joycon_mode = JoyConMode::kIndividual; + start_backend(); + initialize_runtime_profile_storage(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + ready_switch2(left); + ready_switch2(right); + uni_controller_t data{}; + data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + data.gamepad.dpad = DPAD_LEFT; + platform_on_controller_data(&left, &data); + data.gamepad = {}; + data.gamepad.buttons = BUTTON_B; + platform_on_controller_data(&right, &data); + const auto left_before = slot_snapshot(0); + const auto right_before = slot_snapshot(1); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{17, 27}); + bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{47, 57}); + require(bluepad32_input_backend_capture_start( + 1, right_before.connection_generation, CaptureOptions{}), + "active solo capture must survive a rejected live merge"); + before_pair_seed = [](const ControllerIdentity&) { + require(slot_snapshot(0).active && slot_snapshot(1).active && + slot_snapshot(0).state.button_south && slot_snapshot(1).state.button_south, + "both active solos must stay intact while a new composite bank is seeded"); + }; + if (invalid_identity) right.switch2_identity_valid = false; + else fail_profile_program = true; + set_runtime_joycon_mode(JoyConMode::kPaired); + before_pair_seed = nullptr; + Bluepad32CaptureSnapshot capture{}; + require(slot_snapshot(0).active && slot_snapshot(1).active && + slot_snapshot(0).connection_generation == left_before.connection_generation && + slot_snapshot(1).connection_generation == right_before.connection_generation && + controller_identity_equal(slot_snapshot(0).identity, left_before.identity) && + controller_identity_equal(slot_snapshot(1).identity, right_before.identity) && + slot_snapshot(0).state.button_south && slot_snapshot(1).state.button_south && + bluepad32_input_backend_capture_page(0, 0, &capture) && + capture.state == CaptureState::kRecording && + left.switch2_haptics_resets == 0 && right.switch2_haptics_resets == 0, + "failed live pair admission must preserve both solo identities, states, generations and recording"); + const unsigned expected_observations = invalid_identity ? 0 : 1; + require(pair_observation_count == expected_observations, + "invalid pair identity must fail before I/O and failed storage admission must be attempted once"); + process_rumble_timer(&g_rumble_timer); + require(left.last_low == 17 && right.last_low == 47, + "failed admission must preserve both already-queued solo haptic commands"); + for (unsigned tick = 0; tick < 100; ++tick) { + now_ms += 50; + process_configuration_timer(&g_configuration_timer); + } + ++runtime_configuration.pairing_window_seconds; + ++runtime_configuration_generation; + process_configuration_timer(&g_configuration_timer); + require(pair_observation_count == expected_observations, + "pending Paired preference must not create timer or unrelated-configuration retry storms"); + fail_profile_program = false; + right.switch2_identity_valid = true; + if (!invalid_identity) { + require(runtime_profile_storage.initialize(runtime_profile_io()), + "pair failure recovery must replay intact solo profile banks"); + } + set_runtime_joycon_mode(JoyConMode::kIndividual); + set_runtime_joycon_mode(JoyConMode::kPaired); + require_pair_owner(slot_snapshot(0).identity, left, right); + require(!slot_snapshot(1).active, "explicit mode retry must recover without Bluetooth reconnect"); +} + +int live_identity_slot(const ControllerIdentity& identity) { + for (uint8_t index = 0; index < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++index) { + const auto snapshot = slot_snapshot(index); + if (snapshot.active && controller_identity_equal(snapshot.identity, identity)) return index; + } + return -1; +} + +int live_pair_slot(const uni_hid_device_t& left, const uni_hid_device_t& right) { + ControllerIdentity pair{}; + require(controller_identity_make_joycon_pair( + identity_for_device(&left), identity_for_device(&right), &pair), + "gesture participants must have a valid typed composite identity"); + return live_identity_slot(pair); +} + +void require_live_solo(const uni_hid_device_t& controller, const char* message) { + require(live_identity_slot(identity_for_device(&controller)) >= 0, message); +} + +uni_controller_t gesture_input(bool right, bool trigger = true, bool menu = true) { + uni_controller_t report{}; + report.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + if (right) { + report.gamepad.buttons = BUTTON_B | (trigger ? BUTTON_TRIGGER_R : 0); + report.gamepad.misc_buttons = menu ? MISC_BUTTON_START : 0; + report.gamepad.throttle = trigger ? 1023 : 0; + report.gamepad.axis_rx = 200; + report.gamepad.axis_ry = -512; + } else { + report.gamepad.buttons = trigger ? BUTTON_TRIGGER_L : 0; + report.gamepad.misc_buttons = menu ? MISC_BUTTON_SELECT : 0; + report.gamepad.brake = trigger ? 1023 : 0; + report.gamepad.dpad = DPAD_UP; + report.gamepad.axis_x = -512; + report.gamepad.axis_y = 100; + } + return report; +} + +bool gesture_profile_consumer_enabled = false; +ControllerState gesture_profile_outputs[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{}; + +void consume_gesture_profiles() { + if (!gesture_profile_consumer_enabled) return; + for (uint8_t index = 0; index < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++index) { + gesture_profile_outputs[index] = controller_profile_runtime_transform( + index, slot_snapshot(index), now_ms, AdapterUsbMode::kXInput).state; + } +} + +void gesture_report(uni_hid_device_t& controller, bool trigger = true, bool menu = true) { + auto report = gesture_input(controller.product_id == UNI_SW2_JOYCON_R_PID, trigger, menu); + platform_on_controller_data(&controller, &report); + consume_gesture_profiles(); +} + +void gesture_tick() { + process_rumble_timer(&g_rumble_timer); + process_configuration_timer(&g_configuration_timer); + consume_gesture_profiles(); +} + +void gesture_reports(uni_hid_device_t& left, uni_hid_device_t& right) { + gesture_report(left); + gesture_report(right); + gesture_tick(); +} + +void hold_gesture(uni_hid_device_t& left, uni_hid_device_t& right, uint32_t duration_ms) { + gesture_reports(left, right); + for (uint32_t elapsed = 0; elapsed < duration_ms;) { + const uint32_t step = duration_ms - elapsed < 50 ? duration_ms - elapsed : 50; + elapsed += step; + now_ms += step; + gesture_reports(left, right); + } +} + +void release_gesture(uni_hid_device_t& left, uni_hid_device_t& right) { + ++now_ms; + gesture_report(left, false, false); + gesture_report(right, false, false); + gesture_tick(); +} + +void require_gesture_masked(const Bluepad32SlotSnapshot& snapshot) { + const uint16_t menus = + logical_button_bit(ControllerProfileLogicalButton::kSelect) | + logical_button_bit(ControllerProfileLogicalButton::kStart); + require(!snapshot.state.button_select && !snapshot.state.button_start && + snapshot.state.left_trigger == 0 && snapshot.state.right_trigger == 0 && + (snapshot.pre_hotkey_button_mask & menus) == 0, + "reserved physical chord must not reach host state or pre-profile hotkeys"); +} + +void require_gesture_confirmation(size_t first, const uni_hid_device_t& left, + const uni_hid_device_t& right) { + unsigned left_pulses = 0; + unsigned right_pulses = 0; + for (size_t index = first; index < local_rumble_events.size(); ++index) { + const auto& event = local_rumble_events[index]; + if ((event.high | event.low) == 0 || event.duration_ms == 0) continue; + require(event.duration_ms <= 150 && + (event.device == &left || event.device == &right), + "gesture confirmation must be short and restricted to its two physical participants"); + if (event.device == &left) ++left_pulses; + else ++right_pulses; + } + require(left_pulses == 1 && right_pulses == 1, + "one successful gesture must acknowledge each physical half exactly once"); +} + +void test_switch2_gesture_timing() { + runtime_configuration.joycon_mode = JoyConMode::kIndividual; + start_backend(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + ready_switch2(left); + ready_switch2(right); + const uint32_t configuration_generation = runtime_configuration_generation; + gesture_report(left, true, false); + require(slot_snapshot(0).state.left_trigger == UINT16_MAX, + "a trigger alone is not a reserved chord"); + for (unsigned elapsed = 0; elapsed <= 2500; elapsed += 50) { + now_ms = elapsed; + gesture_report(left); + gesture_report(right, true, false); + gesture_tick(); + } + require_live_solo(left, "one complete half and one partial half must not join"); + require_live_solo(right, "partial participant must retain its solo owner"); + require(pair_observation_count == 0, "a partial gesture must never enroll a composite bank"); + release_gesture(left, right); + for (unsigned elapsed = 0; elapsed < 500; elapsed += 50) { + now_ms += 50; + gesture_report(left); + gesture_report(right, false, false); + gesture_tick(); + } + const uint32_t shared_start = now_ms; + hold_gesture(left, right, 1999); + require_live_solo(left, "two seconds must start at concurrent hold, not the earlier left press"); + require(pair_observation_count == 0, "1999ms must not seed or publish a pair"); + now_ms = shared_start + 2000; + gesture_report(left); + gesture_tick(); + require_live_solo(right, "a fresh left report must not stand in for the right's 2000ms held report"); + const size_t confirmation_start = local_rumble_events.size(); + gesture_report(right); + gesture_tick(); + require(live_pair_slot(left, right) >= 0, "both fresh held spans at 2000ms must join Individual halves"); + require_gesture_confirmation(confirmation_start, left, right); + const uint32_t joined_generation = slot_snapshot(live_pair_slot(left, right)).connection_generation; + hold_gesture(left, right, 2500); + gesture_report(left, false, false); + gesture_report(right, false, true); + gesture_tick(); + hold_gesture(left, right, 2500); + require(live_pair_slot(left, right) >= 0 && + slot_snapshot(live_pair_slot(left, right)).connection_generation == joined_generation && + pair_observation_count == 1, + "held input and one participant's incomplete release must not toggle or reseed again"); + release_gesture(left, right); + hold_gesture(left, right, 2000); + require_live_solo(left, "both participants' full release must permit a second toggle"); + require_live_solo(right, "second toggle must expose the other physical solo"); + require(runtime_configuration.joycon_mode == JoyConMode::kIndividual && + runtime_configuration_generation == configuration_generation, + "connection gestures must never write the saved adapter preference"); +} + +void test_switch2_gesture_slot_order(bool left_first, uint8_t lower_slot) { + runtime_configuration.joycon_mode = JoyConMode::kIndividual; + start_backend(); + auto ordinary = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto left = switch2_device(lower_slot + 1, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(lower_slot, UNI_SW2_JOYCON_R_PID); + if (lower_slot != 0) ready_switch2(ordinary); + // Transport indices can put the left half in the higher player slot, + // even when its ready callback arrives first. + ready_switch2(left_first ? left : right); + ready_switch2(left_first ? right : left); + const auto lower_before = slot_snapshot(lower_slot); + const auto higher_before = slot_snapshot(lower_slot + 1); + const auto unrelated_before = slot_snapshot(0); + hold_gesture(left, right, 2000); + require(live_pair_slot(left, right) == lower_slot && + !slot_snapshot(lower_slot + 1).active, + "gesture join must retain the lower participating player slot regardless of side or ready order"); + const auto joined = slot_snapshot(lower_slot); + require_pair_owner(joined.identity, left, right); + require(joined.connection_generation != lower_before.connection_generation && + slot_snapshot(lower_slot + 1).connection_generation != higher_before.connection_generation && + slot_snapshot(lower_slot + 1).state.left_stick_x == 0 && + slot_snapshot(lower_slot + 1).state.right_stick_x == 0 && + left.player_leds == (1u << lower_slot) && + right.player_leds == (1u << lower_slot), + "join must invalidate old player epochs, neutralize the retired slot and light both halves for the retained player"); + if (lower_slot != 0) { + require(slot_snapshot(0).active && + slot_snapshot(0).connection_generation == unrelated_before.connection_generation && + controller_identity_equal(slot_snapshot(0).identity, unrelated_before.identity), + "gesture join must not take an earlier slot occupied by an unrelated controller"); + } +} + +void test_switch2_gesture_stale() { + runtime_configuration.joycon_mode = JoyConMode::kIndividual; + start_backend(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + ready_switch2(left); + ready_switch2(right); + hold_gesture(left, right, 1000); + for (unsigned elapsed = 0; elapsed < 2500; elapsed += 50) { + now_ms += 50; + gesture_report(left); + gesture_tick(); + } + require_live_solo(left, "cached right input must not fire after it goes stale"); + hold_gesture(left, right, 2200); + require_live_solo(right, "resumed held reports must not rearm a stale attempt without release"); + require(pair_observation_count == 0, "stale attempts must not touch pair storage"); + release_gesture(left, right); + hold_gesture(left, right, 1000); + now_ms += 1100; + gesture_reports(left, right); + require_live_solo(left, "new reports must not hide a stale inter-report gap from a delayed timer"); + hold_gesture(left, right, 2200); + require_live_solo(right, "a delayed-timer stale attempt must also require full release before retry"); + release_gesture(left, right); + hold_gesture(left, right, 1900); + now_ms += 100; + gesture_tick(); + require_live_solo(left, "fresh cached reports alone cannot establish two full held spans"); + gesture_report(left); + gesture_tick(); + require_live_solo(right, "a timer and one participant cannot complete the other's held span"); + gesture_report(right); + gesture_tick(); + require(live_pair_slot(left, right) >= 0, "release and fresh report spans must recover a stale attempt"); +} + +void test_switch2_gesture_clock_wrap() { + runtime_configuration.joycon_mode = JoyConMode::kIndividual; + start_backend(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + ready_switch2(left); + ready_switch2(right); + now_ms = UINT32_MAX - 1000u; + hold_gesture(left, right, 1999); + require_live_solo(left, "clock wrap must not shorten the hold"); + ++now_ms; + gesture_reports(left, right); + require(live_pair_slot(left, right) >= 0, "continuous held reports must complete across millisecond wrap"); +} + +void configure_gesture_profile(const ControllerIdentity& identity, uint8_t index, uint8_t scale) { + auto profile = controller_profile_default(identity, index); + profile.weak_rumble_scale = scale; + profile.button_map[static_cast(ControllerProfileLogicalButton::kSelect)] = + static_cast(ControllerProfileLogicalButton::kNorth); + profile.button_map[static_cast(ControllerProfileLogicalButton::kStart)] = + static_cast(ControllerProfileLogicalButton::kCapture); + profile.switching_chord = + logical_button_bit(ControllerProfileLogicalButton::kSelect) | + (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); + profile.motion_toggle_chord = + logical_button_bit(ControllerProfileLogicalButton::kStart) | + (1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL); + profile.macros[0].trigger_mask = logical_button_bit(ControllerProfileLogicalButton::kSouth); + profile.macros[0].first_step = 0; + profile.macros[0].step_count = 1; + profile.macros[0].mode = ControllerProfileMacroMode::kToggle; + profile.macro_step_count = 1; + for (uint8_t macro = 1; macro < CONTROLLER_PROFILE_MACRO_COUNT; ++macro) { + profile.macros[macro].first_step = 1; + } + profile.macro_steps[0].override_flags = kControllerProfileOverrideButtons; + profile.macro_steps[0].duration_ms = 10000; + profile.macro_steps[0].output_button_mask = logical_button_bit(ControllerProfileLogicalButton::kSystem); + require(runtime_profile_storage.set(identity, index, profile) == ProfileStorageResult::kOk && + runtime_profile_storage.activate(identity, index) == ProfileStorageResult::kOk, + "gesture fixture needs independent profiles with observable macro and reserved-button mappings"); +} + +void test_switch2_gesture_masking_epochs() { + start_backend(); + initialize_runtime_profile_storage(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + const auto left_identity = identity_for_device(&left); + const auto right_identity = identity_for_device(&right); + configure_gesture_profile(left_identity, 2, 37); + configure_gesture_profile(right_identity, 5, 63); + ready_switch2(left); + ready_switch2(right); + const auto pair_identity = slot_snapshot(0).identity; + configure_gesture_profile(pair_identity, 7, 95); + auto ordinary = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + ready_switch2(ordinary); + auto ordinary_report = gesture_input(true, false, false); + ordinary_report.gamepad.buttons = BUTTON_Y; + platform_on_controller_data(&ordinary, &ordinary_report); + const auto ordinary_before = slot_snapshot(2); + bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{71, 81}); + gesture_tick(); + const int ordinary_rumble_calls = ordinary.rumble_calls; + const unsigned writes_before = profile_write_attempts; + const uint32_t saved_generation = runtime_configuration_generation; + gesture_profile_consumer_enabled = true; + controller_profile_runtime_reset(); + release_gesture(left, right); + auto macro_press = gesture_input(true, false, false); + macro_press.gamepad.buttons = BUTTON_A; + ++now_ms; + platform_on_controller_data(&right, ¯o_press); + consume_gesture_profiles(); + require(gesture_profile_outputs[0].button_system, "a real profile macro must be running before the topology epoch"); + release_gesture(left, right); + CaptureOptions options{}; + options.channels = 0x1f; + options.max_events = 32; + require(bluepad32_input_backend_capture_start( + 0, slot_snapshot(0).connection_generation, options), + "pair capture must record the arming path before splitting"); + const auto pair_before = slot_snapshot(0); + ++now_ms; + gesture_report(left); + require_gesture_masked(slot_snapshot(0)); + require(slot_snapshot(0).state.dpad_up && slot_snapshot(0).state.button_east && + slot_snapshot(0).state.left_stick_x == INT16_MIN && + slot_snapshot(0).state.right_stick_x == scale_axis(200), + "masking a single physical chord must preserve unrelated buttons, axes and companion input"); + gesture_report(right); + gesture_tick(); + require_gesture_masked(slot_snapshot(0)); + hold_gesture(left, right, 1999); + require(gesture_profile_outputs[0].button_system && + !gesture_profile_outputs[0].button_north && + !gesture_profile_outputs[0].button_capture, + "arming must not leak remapped menus or cancel the preexisting macro"); + Bluepad32CaptureSnapshot capture{}; + require(bluepad32_input_backend_capture_page(0, 0, &capture) && + capture.state == CaptureState::kRecording, + "reserved chord consumption must not interrupt recording before success"); + for (uint8_t index = 0; index < capture.event_count; ++index) { + require((capture.events[index].buttons & + (logical_button_bit(ControllerProfileLogicalButton::kSelect) | + logical_button_bit(ControllerProfileLogicalButton::kStart))) == 0 && + capture.events[index].left_trigger == 0 && capture.events[index].right_trigger == 0, + "recorded macros must never contain reserved gesture components"); + } + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101}); + bluepad32_input_backend_queue_profile_feedback( + 0, pair_before.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed); + const size_t host_events_before = switch2_host_events.size(); + const size_t confirmation_start = local_rumble_events.size(); + ++now_ms; + gesture_reports(left, right); + require_live_solo(left, "Paired default must allow a gesture split"); + require_live_solo(right, "gesture split must expose both members"); + require_gesture_masked(slot_snapshot(0)); + require_gesture_masked(slot_snapshot(1)); + require(slot_snapshot(0).state.button_west && slot_snapshot(1).state.button_south && + slot_snapshot(0).state.left_stick_x == scale_axis(100) && + slot_snapshot(1).state.left_stick_x == INT16_MAX && + !gesture_profile_outputs[0].button_system && !gesture_profile_outputs[1].button_system && + bluepad32_input_backend_capture_page(0, 0, &capture) && + capture.state == CaptureState::kDisconnected && + switch2_host_events.size() == host_events_before, + "successful split must rotate retained input, cancel macro/capture epochs and discard queued old rumble"); + require_gesture_confirmation(confirmation_start, left, right); + bluepad32_input_backend_queue_profile_feedback( + 0, pair_before.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed); + now_ms += 200; + gesture_report(left, false, true); + gesture_report(right, true, false); + gesture_tick(); + require_gesture_masked(slot_snapshot(0)); + require_gesture_masked(slot_snapshot(1)); + require_gesture_confirmation(confirmation_start, left, right); + require(slot_snapshot(2).connection_generation == ordinary_before.connection_generation && + slot_snapshot(2).state.button_north && + ordinary.rumble_calls == ordinary_rumble_calls && ordinary.last_low == 71, + "gesture confirmation and retired feedback must not disturb unrelated input or rumble"); + release_gesture(left, right); + gesture_report(left, true, false); + gesture_report(right, false, true); + require(slot_snapshot(0).state.left_trigger == UINT16_MAX && slot_snapshot(1).state.button_start, + "each half must return its component inputs after both components have released"); + release_gesture(left, right); + auto solo_macro_press = gesture_input(false, false, false); + solo_macro_press.gamepad.dpad = DPAD_LEFT; + ++now_ms; + platform_on_controller_data(&left, &solo_macro_press); + consume_gesture_profiles(); + require(gesture_profile_outputs[0].button_system, + "a real solo profile macro must be running before rejoining"); + release_gesture(left, right); + require(bluepad32_input_backend_capture_start( + 1, slot_snapshot(1).connection_generation, options), + "the other solo must remain recordable before gesture join"); + hold_gesture(left, right, 1999); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{111, 121}); + bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{131, 141}); + const size_t solo_host_events_before = switch2_host_events.size(); + ++now_ms; + gesture_reports(left, right); + require(live_pair_slot(left, right) >= 0 && !gesture_profile_outputs[0].button_system && + !gesture_profile_outputs[1].button_system, + "rejoining must retire both solo macro contexts without reviving the former pair macro"); + require(bluepad32_input_backend_capture_page(0, 0, &capture) && + capture.state == CaptureState::kDisconnected && + switch2_host_events.size() == solo_host_events_before, + "gesture join must disconnect either solo recorder and discard both queued solo rumble epochs"); + require_active_profile(pair_identity, 7, 95); + require_active_profile(left_identity, 2, 37); + require_active_profile(right_identity, 5, 63); + require(profile_write_attempts == writes_before && + runtime_configuration.joycon_mode == JoyConMode::kPaired && + runtime_configuration_generation == saved_generation, + "gesture roundtrip must preserve all existing profile banks and saved preference without flash writes"); +} + +void test_switch2_gesture_override_lifetime() { + runtime_configuration.joycon_mode = JoyConMode::kIndividual; + start_backend(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + remember_switch2(left); + remember_switch2(right); + register_lookup_device(&left); + register_lookup_device(&right); + ready_switch2(left); + ready_switch2(right); + hold_gesture(left, right, 1900); + platform_on_device_disconnected(&right); + now_ms += 100; + gesture_reports(left, right); + require_live_solo(left, "disconnect during arming must leave the survivor solo"); + require(pair_observation_count == 0, "late detached reports cannot complete a pending gesture"); + ready_switch2(right); + release_gesture(left, right); + hold_gesture(left, right, 2000); + require(live_pair_slot(left, right) >= 0, "reconnected halves must support a new connection-only join"); + auto ordinary = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + ready_switch2(ordinary); + ++runtime_configuration.pairing_window_seconds; + ++runtime_configuration_generation; + gesture_tick(); + require(live_pair_slot(left, right) >= 0, "unrelated ready and configuration reconciliation must retain a join override"); + platform_on_device_disconnected(&left); + gesture_tick(); + ready_switch2(left); + gesture_tick(); + require_live_solo(left, "disconnect must restore Individual for the returning participant"); + require_live_solo(right, "disconnect must restore Individual for its still-connected partner"); + release_gesture(left, right); + hold_gesture(left, right, 2000); + set_runtime_joycon_mode(JoyConMode::kPaired); + release_gesture(left, right); + hold_gesture(left, right, 2000); + require_live_solo(left, "a gesture must split while the saved default remains Paired"); + for (unsigned tick = 0; tick < 20; ++tick) { + now_ms += 50; + gesture_tick(); + require(!scanning_enabled && !classic_scanning_enabled && + platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) == + UNI_ERROR_IGNORE_DEVICE, + "forced solos under Paired must stop background mate discovery despite free transport capacity"); + } + require_live_solo(left, "background reconciliation must not rematch a manually split pair"); + require_live_solo(right, "the second forced solo must stay independent beside an unrelated Classic link"); + auto pro = switch2_device(3, UNI_SW2_PRO_PID); + ready_switch2(pro); + gesture_tick(); + require_live_solo(right, "new ready events must not immediately undo the forced split"); + platform_on_device_disconnected(&right); + gesture_tick(); + require(scanning_enabled && background_scan_parameters && !classic_scanning_enabled && + !bondable && !switch_pico_switch2_pairing_allowed() && + platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) == UNI_ERROR_SUCCESS, + "participant disconnect must clear the override and resume remembered-mate discovery without fresh pairing"); + ready_switch2(right); + gesture_tick(); + require(live_pair_slot(left, right) >= 0, "either split participant disconnecting must restore Paired for both"); + require(!scanning_enabled && !classic_scanning_enabled, + "default reconnection must stop background discovery after restoring the pair"); + release_gesture(left, right); + hold_gesture(left, right, 2000); + platform_on_device_disconnected(&right); + auto replacement = switch2_device(1, UNI_SW2_JOYCON_R_PID); + replacement.conn.btaddr[5] = 0x55; + ready_switch2(replacement); + gesture_tick(); + require(live_pair_slot(left, replacement) >= 0 && live_pair_slot(left, right) < 0, + "reusing the physical index for a different identity must not inherit either member's former override"); + platform_on_device_disconnected(&right); + gesture_report(right); + gesture_tick(); + require(live_pair_slot(left, replacement) >= 0, + "late reports and disconnects from the retired participant must not clear its replacement's topology"); + release_gesture(left, replacement); + hold_gesture(left, replacement, 2000); + set_runtime_joycon_mode(JoyConMode::kIndividual); + set_runtime_joycon_mode(JoyConMode::kPaired); + require(live_pair_slot(left, replacement) >= 0, "changing the saved default must clear a forced-solo override"); + set_runtime_joycon_mode(JoyConMode::kIndividual); + release_gesture(left, replacement); + hold_gesture(left, replacement, 2000); + require(live_pair_slot(left, replacement) >= 0, "Individual must still permit an explicit current-pair join"); + set_runtime_joycon_mode(JoyConMode::kPaired); + set_runtime_joycon_mode(JoyConMode::kIndividual); + require_live_solo(left, "changing the saved default must also clear a forced-pair override"); + require_live_solo(replacement, "clearing a forced-pair override must restore both Individual owners"); +} + +void test_switch2_gesture_two_pairs() { + start_backend(); + auto right0 = switch2_device(0, UNI_SW2_JOYCON_R_PID); + auto right1 = switch2_device(1, UNI_SW2_JOYCON_R_PID); + auto left0 = switch2_device(2, UNI_SW2_JOYCON_L_PID); + auto left1 = switch2_device(3, UNI_SW2_JOYCON_L_PID); + ready_switch2(right0); + ready_switch2(right1); + ready_switch2(left0); + ready_switch2(left1); + const auto pair0 = slot_snapshot(live_pair_slot(left0, right0)); + const auto pair1 = slot_snapshot(live_pair_slot(left1, right1)); + hold_gesture(left0, right1, 2000); + require(live_pair_slot(left0, right0) >= 0 && live_pair_slot(left1, right1) >= 0 && + slot_snapshot(live_pair_slot(left0, right0)).connection_generation == pair0.connection_generation && + slot_snapshot(live_pair_slot(left1, right1)).connection_generation == pair1.connection_generation, + "chords from different live pairs must never steal or split either pair's members"); + release_gesture(left0, right1); + const size_t first_confirmation = local_rumble_events.size(); + hold_gesture(left0, right0, 2000); + require_live_solo(left0, "only the first current pair must split for its own two participants"); + require_live_solo(right0, "first pair's right member must become independent"); + require(live_pair_slot(left1, right1) >= 0 && + slot_snapshot(live_pair_slot(left1, right1)).connection_generation == pair1.connection_generation, + "unjoining one pair must not split or invalidate the unrelated pair"); + require_gesture_confirmation(first_confirmation, left0, right0); + const size_t second_confirmation = local_rumble_events.size(); + hold_gesture(left1, right1, 2000); + require_gesture_confirmation(second_confirmation, left1, right1); + require_live_solo(left1, "second current pair must split independently"); + require_live_solo(right1, "second pair's right member must become independent"); + release_gesture(left0, right0); + release_gesture(left1, right1); + const auto former_right = slot_snapshot(live_identity_slot(identity_for_device(&right0))); + const auto former_left = slot_snapshot(live_identity_slot(identity_for_device(&left1))); + const size_t confirmation_start = local_rumble_events.size(); + hold_gesture(left0, right1, 2000); + require(live_pair_slot(left0, right1) >= 0, "explicit solo participants must be allowed to choose a new cross-pair"); + require_gesture_confirmation(confirmation_start, left0, right1); + gesture_tick(); + require_live_solo(right0, "cross-joining must not regroup the untouched former right partner"); + require_live_solo(left1, "cross-joining must not regroup the untouched former left partner"); + require(slot_snapshot(live_identity_slot(identity_for_device(&right0))).connection_generation == + former_right.connection_generation && + slot_snapshot(live_identity_slot(identity_for_device(&left1))).connection_generation == + former_left.connection_generation, + "changing partners must leave former partners' logical epochs intact"); + platform_on_device_disconnected(&right1); + auto replacement = switch2_device(1, UNI_SW2_JOYCON_R_PID); + replacement.conn.btaddr[5] = 0x65; + ready_switch2(replacement); + gesture_tick(); + require(live_pair_slot(left0, replacement) >= 0, + "a former pair hint must not pin a cleared survivor to a stolen or disconnected member"); + require_live_solo(right0, "clearing the current override must preserve the untouched former right's solo override"); + require_live_solo(left1, "clearing the current override must preserve the untouched former left's solo override"); + hold_gesture(left1, right0, 2000); + require(live_pair_slot(left1, right0) >= 0 && live_pair_slot(left0, replacement) >= 0, + "orphaned former partners must be independently joinable without disturbing the current pair"); +} + +void test_switch2_gesture_ambiguous() { + runtime_configuration.joycon_mode = JoyConMode::kIndividual; + start_backend(); + auto left0 = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + auto left1 = switch2_device(2, UNI_SW2_JOYCON_L_PID); + ready_switch2(left0); + ready_switch2(right); + ready_switch2(left1); + const auto spare_before = slot_snapshot(2); + for (unsigned elapsed = 0; elapsed <= 2500; elapsed += 50) { + now_ms = elapsed; + gesture_report(left0); + gesture_report(right); + gesture_report(left1); + gesture_tick(); + } + require_live_solo(left0, "two armed left solos must not choose an arbitrary pairing"); + require_live_solo(right, "ambiguous solo arming must preserve the only right player"); + require_live_solo(left1, "ambiguous solo arming must preserve the other left player"); + gesture_report(left1, false, false); + hold_gesture(left0, right, 2200); + require(pair_observation_count == 0 && local_rumble_events.empty(), + "resolving ambiguity without releasing the attempt must not retry storage or acknowledge success"); + release_gesture(left0, right); + hold_gesture(left0, right, 2000); + require(live_pair_slot(left0, right) >= 0 && + slot_snapshot(2).connection_generation == spare_before.connection_generation && + controller_identity_equal(slot_snapshot(2).identity, spare_before.identity), + "full release must allow one eligible L/R while preserving the unarmed extra solo"); +} + +void test_switch2_gesture_seed_failure() { + runtime_configuration.joycon_mode = JoyConMode::kIndividual; + start_backend(); + initialize_runtime_profile_storage(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + const auto left_identity = identity_for_device(&left); + const auto right_identity = identity_for_device(&right); + configure_gesture_profile(left_identity, 2, 37); + configure_gesture_profile(right_identity, 5, 63); + ready_switch2(left); + ready_switch2(right); + // Establish the current USB host mode before testing a gesture failure; + // the adapter variant legitimately resets its initial haptics epoch. + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{}); + bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{}); + release_gesture(left, right); + const unsigned left_resets_before = left.switch2_haptics_resets; + const unsigned right_resets_before = right.switch2_haptics_resets; + const auto left_before = slot_snapshot(0); + const auto right_before = slot_snapshot(1); + CaptureOptions options{}; + options.max_duration_ms = 20000; + require(bluepad32_input_backend_capture_start(1, right_before.connection_generation, options), + "failed admission must preserve a live solo capture"); + hold_gesture(left, right, 1999); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{17, 27}); + bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{47, 57}); + fail_profile_program = true; + ++now_ms; + gesture_reports(left, right); + Bluepad32CaptureSnapshot capture{}; + require_live_solo(left, "failed seed must not retire the left solo"); + require_live_solo(right, "failed seed must not retire the right solo"); + require(slot_snapshot(0).connection_generation == left_before.connection_generation && + slot_snapshot(1).connection_generation == right_before.connection_generation && + slot_snapshot(0).state.button_west && slot_snapshot(1).state.button_south && + bluepad32_input_backend_capture_page(0, 0, &capture) && + capture.state == CaptureState::kRecording && + left.switch2_haptics_resets == left_resets_before && + right.switch2_haptics_resets == right_resets_before && + left.last_low == 17 && right.last_low == 47 && pair_observation_count == 1, + "seed failure must preserve identities, input, generations, recording and queued independent rumble"); + const unsigned failed_writes = profile_write_attempts; + hold_gesture(left, right, 5000); + ++runtime_configuration.pairing_window_seconds; + ++runtime_configuration_generation; + gesture_tick(); + require(pair_observation_count == 1 && profile_write_attempts == failed_writes && + local_rumble_events.empty(), + "failed held gesture must not retry flash, churn topology or emit confirmation pulses"); + fail_profile_program = false; + require(runtime_profile_storage.initialize(runtime_profile_io()), + "catalog replay must retain intact solo banks after failed pair admission"); + require_active_profile(left_identity, 2, 37); + require_active_profile(right_identity, 5, 63); + release_gesture(left, right); + hold_gesture(left, right, 2000); + require(live_pair_slot(left, right) >= 0 && pair_observation_count == 2, + "explicit release and retry must recover once profile storage is available"); + require_active_profile(slot_snapshot(live_pair_slot(left, right)).identity, 2, 37); + require_active_profile(right_identity, 5, 63); + require(runtime_configuration.joycon_mode == JoyConMode::kIndividual, + "failed and successful connection gestures must both leave the saved preference Individual"); +} + +void test_switch2_gesture_device_scope() { + start_backend(); + auto original_left = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto original_right = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + original_left.vendor_id = original_right.vendor_id = UNI_SW2_NINTENDO_VID; + original_left.product_id = 0x2006; + original_right.product_id = 0x2007; + auto pro = switch2_device(2, UNI_SW2_PRO_PID); + auto other = device(3, true, UNI_BT_CONN_PROTOCOL_BLE); + uni_hid_device_t* controllers[] = {&original_left, &original_right, &pro, &other}; + for (auto* controller : controllers) ready_switch2(*controller); + auto report = gesture_input(false); + report.gamepad.buttons |= BUTTON_TRIGGER_R; + report.gamepad.misc_buttons |= MISC_BUTTON_START; + report.gamepad.throttle = 1023; + for (unsigned elapsed = 0; elapsed <= 2500; elapsed += 50) { + now_ms = elapsed; + for (auto* controller : controllers) platform_on_controller_data(controller, &report); + gesture_tick(); + } + for (uint8_t index = 0; index < 4; ++index) { + const auto snapshot = slot_snapshot(index); + require(snapshot.active && snapshot.state.button_select && snapshot.state.button_start && + snapshot.state.left_trigger == UINT16_MAX && snapshot.state.right_trigger == UINT16_MAX, + "original JoyCons, Switch2 Pro and unrelated controllers must retain all chord inputs"); + } + require(pair_observation_count == 0 && local_rumble_events.empty(), + "the connection gesture must never operate on non-JoyCon2 controllers"); +} + +ControllerRumbleOutput ordered_switch2_hd(uint8_t frequency = 40) { + ControllerRumbleOutput rumble{17, 29}; + rumble.hd.actuators[0].sample_count = 3; + rumble.hd.actuators[1].sample_count = 2; + rumble.hd.actuators[0].samples[0] = {frequency, 61, 24000, 4000}; + rumble.hd.actuators[0].samples[1] = {41, 62, 5000, 25000}; + rumble.hd.actuators[0].samples[2] = {42, 63, 20000, 14000}; + rumble.hd.actuators[1].samples[0] = {81, 91, 3000, 21000}; + rumble.hd.actuators[1].samples[1] = {82, 92, 23000, 7000}; + return rumble; +} + +void require_switch2_sample(const uint8_t encoded[5], + const SwitchHapticsSample& source) { + uint64_t bits = 0; + for (unsigned index = 0; index < 5; ++index) { + bits |= uint64_t{encoded[index]} << (index * 8); + } + require((bits & 1023u) == 193u + 3u * source.low_frequency_index && + ((bits >> 20) & 1023u) == 289u + 3u * source.high_frequency_index && + ((bits >> 10) & 1023u) == + ((uint32_t{source.low_amplitude_q15} * 29000u / 32767u) >> 6) && + ((bits >> 30) & 1023u) == + ((uint32_t{source.high_amplitude_q15} * 29000u / 32767u) >> 6), + "native physical sample lost its measured frequency or independent band amplitude"); +} + +void test_switch2_hd_pro() { + start_pairing_backend(); + auto pro = switch2_device(0, UNI_SW2_PRO_PID); + ready_switch2(pro); + now_ms = 100; + const auto first = ordered_switch2_hd(40); + const auto second = ordered_switch2_hd(50); + bluepad32_input_backend_queue_rumble(0, first); + now_ms = 101; + bluepad32_input_backend_queue_rumble(0, second); + now_ms = 105; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 2 && pro.rumble_calls == 0 && + switch2_host_events[0].received_ms == 100 && + switch2_host_events[1].received_ms == 101, + "rapid HD commands must bypass the compatibility mailbox in original order and time"); + for (unsigned event = 0; event < 2; ++event) { + const auto& input = event == 0 ? first : second; + const auto& output = switch2_host_events[event].frame; + require(output.sides[0].count == 3 && output.sides[1].count == 2, + "Pro output must preserve both native side counts"); + for (unsigned side = 0; side < 2; ++side) { + for (unsigned step = 0; step < output.sides[side].count; ++step) { + require_switch2_sample(output.sides[side].samples[step], + input.hd.actuators[side].samples[step]); + } + } + } + ControllerRumbleOutput partial{}; + partial.hd.actuators[0].sample_count = 1; + bluepad32_input_backend_queue_rumble(0, first); + bluepad32_input_backend_queue_rumble(0, partial); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 4 && + switch2_host_events.back().frame.sides[1].count == 0, + "a silent partial update must neither flush older commands nor invent a right stop"); + + pro.switch2_host_blocked = true; + bluepad32_input_backend_queue_rumble(0, first); + ControllerRumbleOutput stop{}; + stop.hd.actuators[0].sample_count = 1; + stop.hd.actuators[1].sample_count = 1; + bluepad32_input_backend_queue_rumble(0, stop); + now_ms += 100; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 5 && + uni_switch2_haptics_is_stop(&switch2_host_events.back().frame), + "explicit native stop must clear older ingress and bypass age/full barriers"); + pro.switch2_host_blocked = false; + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{19, 23}); + process_rumble_timer(&g_rumble_timer); + require(!switch2_host_events.back().hd && switch2_host_events.back().strong == 19 && + switch2_host_events.back().weak == 23 && pro.rumble_calls == 0, + "both empty HD sides must use the dedicated conventional host API"); +} + +void test_switch2_hd_solo(bool right) { + start_pairing_backend(); + auto solo = switch2_device(0, right ? UNI_SW2_JOYCON_R_PID : UNI_SW2_JOYCON_L_PID); + ready_switch2(solo); + const auto input = ordered_switch2_hd(); + bluepad32_input_backend_queue_rumble(0, input); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 1 && + switch2_host_events[0].frame.sides[0].count == 3 && + switch2_host_events[0].frame.sides[1].count == 0, + "either solo half must receive a mono sequence in physical side zero"); + const SwitchHapticsSample expected[] = { + {40, 91, 24000, 21000}, {82, 62, 23000, 25000}, {82, 63, 23000, 14000}}; + for (unsigned index = 0; index < 3; ++index) { + require_switch2_sample(switch2_host_events[0].frame.sides[0].samples[index], + expected[index]); + } + auto tie = input; + tie.hd.actuators[0].sample_count = 1; + tie.hd.actuators[1].sample_count = 1; + tie.hd.actuators[1].samples[0].low_amplitude_q15 = 24000; + tie.hd.actuators[1].samples[0].high_amplitude_q15 = 4000; + bluepad32_input_backend_queue_rumble(0, tie); + process_rumble_timer(&g_rumble_timer); + require_switch2_sample(switch2_host_events.back().frame.sides[0].samples[0], + tie.hd.actuators[0].samples[0]); + auto one_side = input; + one_side.hd.actuators[0].sample_count = 0; + bluepad32_input_backend_queue_rumble(0, one_side); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.back().frame.sides[0].count == 2, + "solo absent source side must not fabricate substeps"); + require_switch2_sample(switch2_host_events.back().frame.sides[0].samples[1], + one_side.hd.actuators[1].samples[1]); +} + +void test_switch2_hd_pair() { + start_pairing_backend(); + auto right = switch2_device(0, UNI_SW2_JOYCON_R_PID); + auto left = switch2_device(1, UNI_SW2_JOYCON_L_PID); + ready_switch2(right); + ready_switch2(left); + right.switch2_host_blocked = true; + now_ms = 10; + const auto input = ordered_switch2_hd(); + bluepad32_input_backend_queue_rumble(0, input); + process_rumble_timer(&g_rumble_timer); + now_ms = 15; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 1 && switch2_host_events[0].device == &left, + "one blocked half must not duplicate acceptance on its ready partner"); + right.switch2_host_blocked = false; + now_ms = 20; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 2 && switch2_host_events[1].device == &right && + switch2_host_events[1].received_ms == 10, + "paired retry must retain the source timestamp and original missing half"); + for (unsigned half = 0; half < 2; ++half) { + const auto& output = switch2_host_events[half].frame; + require(output.sides[0].count == (half == 0 ? 3 : 2) && output.sides[1].count == 0, + "pair stereo must map selected source side onto each physical side zero"); + for (unsigned step = 0; step < output.sides[0].count; ++step) { + require_switch2_sample(output.sides[0].samples[step], + input.hd.actuators[half].samples[step]); + } + } + auto right_only = input; + right_only.hd.actuators[0].sample_count = 0; + bluepad32_input_backend_queue_rumble(0, right_only); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 3 && switch2_host_events.back().device == &right, + "count zero on one paired half must not submit an invented neutral update"); + right.switch2_host_blocked = true; + bluepad32_input_backend_queue_rumble(0, input); + process_rumble_timer(&g_rumble_timer); + now_ms += 50; + right.switch2_host_blocked = false; + process_rumble_timer(&g_rumble_timer); + Bluepad32BackendDiagnostics diagnostics{}; + bluepad32_input_backend_diagnostics(&diagnostics); + require(switch2_host_events.size() == 4 && diagnostics.switch2_ingress_drops == 1, + "an expired partly accepted pair must drop visibly without late or duplicate delivery"); + right.switch2_host_blocked = true; + bluepad32_input_backend_queue_rumble(0, input); + process_rumble_timer(&g_rumble_timer); + ControllerRumbleOutput stop{}; + stop.hd.actuators[0].sample_count = 1; + stop.hd.actuators[1].sample_count = 1; + bluepad32_input_backend_queue_rumble(0, stop); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 7 && + switch2_host_events[5].device == &left && + switch2_host_events[6].device == &right && + switch2_host_events[5].frame.sides[0].count == 1 && + switch2_host_events[6].frame.sides[0].count == 1, + "paired explicit stop must reach both halves even after partial acceptance and backpressure"); +} + +void test_switch2_hd_overflow() { + start_pairing_backend(); + auto pro = switch2_device(0, UNI_SW2_PRO_PID); + ready_switch2(pro); + now_ms = 100; + for (uint8_t command = 0; command < 17; ++command) { + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(40 + command)); + } + Bluepad32BackendDiagnostics diagnostics{}; + bluepad32_input_backend_diagnostics(&diagnostics); + require(diagnostics.switch2_ingress_drops == 1 && diagnostics.rumble_pending_slots == 1, + "bounded ingress overflow must expose one oldest-command drop"); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 16, + "one drain must preserve all sixteen surviving commands, not a latest-value mailbox"); + for (uint8_t index = 0; index < 16; ++index) { + const auto input = ordered_switch2_hd(41 + index); + require_switch2_sample(switch2_host_events[index].frame.sides[0].samples[0], + input.hd.actuators[0].samples[0]); + } + pro.switch2_host_blocked = true; + now_ms = UINT32_MAX - 20; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + now_ms = 28; // 49ms across wrap. + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_diagnostics(&diagnostics); + require(diagnostics.rumble_pending_slots == 1 && diagnostics.switch2_ingress_drops == 1, + "backpressure must retain an unexpired command across clock wrap"); + now_ms = 29; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_diagnostics(&diagnostics); + require(diagnostics.rumble_pending_slots == 0 && diagnostics.switch2_ingress_drops == 2 && + switch2_host_events.size() == 16, + "original 50ms age must release a backpressured ingress head exactly at expiry"); + pro.switch2_host_blocked = false; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + auto& stale = g_slots[0].switch2_ingress; + --stale.commands[stale.head].envelope.connection_generation; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_diagnostics(&diagnostics); + require(switch2_host_events.size() == 16 && diagnostics.switch2_ingress_drops == 3, + "stale logical generation must be rejected before physical submission"); + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + --stale.commands[stale.head].generation; + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_diagnostics(&diagnostics); + require(switch2_host_events.size() == 16 && diagnostics.switch2_ingress_drops == 4, + "stale haptics epoch must not survive a same-connection mode reset"); + switch2_output_drops = 7; + bluepad32_input_backend_diagnostics(&diagnostics); + require(diagnostics.switch2_output_drops == 7, + "physical output loss must remain visible separately from ingress loss"); +} + +void test_switch2_hd_epochs() { + start_pairing_backend(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + ready_switch2(left); + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + process_rumble_timer(&g_rumble_timer); + const unsigned left_resets = left.switch2_haptics_resets; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + ready_switch2(right); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 1 && left.switch2_haptics_resets > left_resets && + right.switch2_haptics_resets != 0, + "merge must reset accepted physical output and drop queued solo commands"); + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + process_rumble_timer(&g_rumble_timer); + const unsigned pair_resets = left.switch2_haptics_resets; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + platform_on_device_disconnected(&right); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 3 && left.switch2_haptics_resets > pair_resets, + "survivor transition must flush both physical and ingress pair state"); + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + platform_on_device_disconnected(&left); + auto replacement = switch2_device(0, UNI_SW2_PRO_PID); + ready_switch2(replacement); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 3, + "reconnect must not inherit a former logical generation's host output"); +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{27, 35}); + process_rumble_timer(&g_rumble_timer); + const unsigned mode_resets = replacement.switch2_haptics_resets; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + test_adapter_mode = AdapterUsbMode::kSwitchProbe; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 4 && replacement.switch2_haptics_resets > mode_resets, + "mode boundary without new input must neutralize held output and queued old-mode HD"); + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(50)); + test_adapter_mode = AdapterUsbMode::kXInput; + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{45, 55}); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 5 && !switch2_host_events.back().hd && + switch2_host_events.back().duration_ms == UINT16_MAX && + switch2_host_events.back().strong == 45, + "producer-side mode transition must discard old epoch before accepting new host state"); +#endif +} + +void test_switch2_hd_feedback() { + start_pairing_backend(); + auto pro = switch2_device(0, UNI_SW2_PRO_PID); + ready_switch2(pro); + bluepad32_input_backend_queue_profile_feedback( + 0, slot_snapshot(0).connection_generation, 2, + ControllerProfileConfirmationPolicy::kRumble); + process_rumble_timer(&g_rumble_timer); + require(pro.rumble_calls == 1, "profile confirmation must retain local-feedback ownership"); + now_ms = 10; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd()); + process_rumble_timer(&g_rumble_timer); + now_ms = 20; + bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(50)); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 2 && pro.rumble_calls == 1 && + switch2_host_events[0].received_ms == 10 && + switch2_host_events[1].received_ms == 20, + "host substeps must advance during feedback without compatibility dispatch or fresh timestamps"); + now_ms = 300; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 2, + "feedback completion must not replay historical host substeps"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{33, 44}); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 3 && !switch2_host_events.back().hd && + switch2_host_events.back().duration_ms == host_rumble_duration_ms(), + "conventional host vibration must share the bounded host queue, not local feedback"); + now_ms += 1000; + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 3, + "stateful host output must not require backend periodic resubmission"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{}); + process_rumble_timer(&g_rumble_timer); + require(switch2_host_events.size() == 4 && switch2_host_events.back().duration_ms == 0 && + switch2_host_events.back().weak == 0 && switch2_host_events.back().strong == 0, + "conventional all-zero host command must explicitly stop retained state"); + pro.switch2_host_blocked = true; + const uint32_t received_ms = now_ms; + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{65, 75}); + now_ms += 1000; + process_rumble_timer(&g_rumble_timer); + pro.switch2_host_blocked = false; + process_rumble_timer(&g_rumble_timer); + if (host_rumble_duration_ms() == UINT16_MAX) { + require(switch2_host_events.size() == 5 && + switch2_host_events.back().received_ms == received_ms && + switch2_host_events.back().strong == 65, + "held XInput host state must survive backpressure without retimestamping"); + } else { + Bluepad32BackendDiagnostics diagnostics{}; + bluepad32_input_backend_diagnostics(&diagnostics); + require(switch2_host_events.size() == 4 && diagnostics.switch2_ingress_drops == 1, + "finite conventional host state must expire under feedback/backpressure, not revive"); + } +} + +void test_switch2_pair_lifecycle(bool right_first) { + start_pairing_backend(); + uni_hid_device_t left = switch2_device( + right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID); + uni_hid_device_t right = switch2_device( + right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID); + left.conn.btaddr[0] = 0xc1; + left.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM; + initialize_runtime_profile_storage(); + const ControllerIdentity left_identity = identity_for_device(&left); + const ControllerIdentity right_identity = identity_for_device(&right); + ControllerProfile left_profile = controller_profile_default(left_identity, 2); + left_profile.weak_rumble_scale = 37; + require(runtime_profile_storage.set(left_identity, 2, left_profile) == + ProfileStorageResult::kOk && + runtime_profile_storage.activate(left_identity, 2) == ProfileStorageResult::kOk && + runtime_profile_storage.activate(right_identity, 5) == ProfileStorageResult::kOk, + "solo owners must have independently selected profiles before pairing"); + uni_hid_device_t& first = right_first ? right : left; + uni_hid_device_t& second = right_first ? left : right; + ready_switch2(first); + + uni_controller_t left_data{}; + left_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + left_data.gamepad.dpad = DPAD_UP; + left_data.gamepad.axis_x = -512; + left_data.gamepad.axis_y = 100; + left_data.gamepad.buttons = BUTTON_TRIGGER_L; + left_data.gamepad.accel[0] = 8192; + left.switch2_extra_buttons = + UNI_SW2_BUTTON_GL | UNI_SW2_BUTTON_LEFT_SL | UNI_SW2_BUTTON_LEFT_SR; + uni_controller_t right_data{}; + right_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + right_data.gamepad.buttons = BUTTON_B | BUTTON_THUMB_R | BUTTON_TRIGGER_R; + right_data.gamepad.axis_rx = 200; + right_data.gamepad.axis_ry = -512; + right_data.gamepad.accel[2] = 8192; + right.switch2_extra_buttons = + UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_GR | + UNI_SW2_BUTTON_RIGHT_SL | UNI_SW2_BUTTON_RIGHT_SR; + platform_on_controller_data(&first, right_first ? &right_data : &left_data); + const Bluepad32SlotSnapshot solo = slot_snapshot(0); + require(solo.active && solo.state.button_left_shoulder && + solo.state.button_right_shoulder && solo.state.button_left_stick == right_first && + solo.state.right_stick_x == 0 && solo.state.right_stick_y == 0 && + (right_first ? solo.state.button_south : solo.state.button_west), + "solo JoyCon must rotate face controls, stick click and rail shoulders"); + Bluepad32PlaytestSnapshot playtest{}; + bluepad32_input_backend_playtest_snapshot(0, &playtest); + require(playtest.controller_layout == + (right_first ? Bluepad32ControllerLayout::kJoyCon2RightSolo + : Bluepad32ControllerLayout::kJoyCon2LeftSolo), + "playtest must identify the live rotated solo half"); + require(solo.state.left_stick_x == + (right_first ? INT16_MAX : scale_axis(100)) && + solo.state.left_stick_y == + (right_first ? scale_axis(200) : INT16_MAX), + "solo JoyCon stick must rotate with its physical sideways orientation"); + require(bluepad32_input_backend_capture_start( + 0, solo.connection_generation, CaptureOptions{}), + "solo capture must start on its logical generation"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{31, 41}); + bluepad32_input_backend_queue_profile_feedback( + 0, solo.connection_generation, 8, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + ready_switch2(second); + + Bluepad32SlotSnapshot merged = slot_snapshot(0); + require(merged.active && !slot_snapshot(1).active && + merged.connection_generation != solo.connection_generation, + "either connection order must merge into the first-ready output"); + require_pair_owner(merged.identity, left, right); + require_active_profile(merged.identity, 2, 37); + ControllerProfile pair_profile = controller_profile_default(merged.identity, 7); + pair_profile.weak_rumble_scale = 95; + require(runtime_profile_storage.set(merged.identity, 7, pair_profile) == + ProfileStorageResult::kOk && + runtime_profile_storage.activate(merged.identity, 7) == ProfileStorageResult::kOk, + "the merged bank must be independently editable"); + require_active_profile(left_identity, 2, 37); + require_active_profile(right_identity, 5, UINT8_MAX); + bluepad32_input_backend_playtest_snapshot(0, &playtest); + require(playtest.controller_layout == + Bluepad32ControllerLayout::kJoyCon2MergedPair && + playtest.state.motion_sample_count == 0 && + controller_identity_equal(playtest.identity, merged.identity), + "playtest must detect a live companion before any merged motion report"); + Bluepad32CaptureSnapshot capture{}; + require(bluepad32_input_backend_capture_page(0, 0, &capture) && + capture.state == CaptureState::kDisconnected, + "solo recording must not silently cross into the merged generation"); + require(!slot_snapshot(1).state.extra_buttons && + !slot_snapshot(1).state.button_south && + !slot_snapshot(1).state.button_west && + left.last_rumble_duration_ms == 0 && right.last_rumble_duration_ms == 0, + "pair merge must neutralize ghost output and stop old feedback"); + const int calls_after_merge = left.rumble_calls + right.rumble_calls; + bluepad32_input_backend_queue_profile_feedback( + 0, solo.connection_generation, 8, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + process_rumble_timer(&g_rumble_timer); + require(left.rumble_calls + right.rumble_calls == calls_after_merge && + left.player_leds == 1 && right.player_leds == 1, + "old-generation solo feedback must not reach the pair"); + require(!bluepad32_input_backend_identify(left_identity) && + !bluepad32_input_backend_identify(right_identity) && + bluepad32_input_backend_identify(merged.identity), + "identify must match the pair owner, not either physical solo owner"); + process_rumble_timer(&g_rumble_timer); + require(left.last_low == UINT8_MAX && right.last_low == UINT8_MAX && + left.player_leds == 1 && right.player_leds == 1, + "identifying the pair owner must reach both physical halves"); + now_ms += 150; + process_rumble_timer(&g_rumble_timer); + dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, left, + left.switch2_identity_address_type, left.conn.btaddr); + dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, right, + right.switch2_identity_address_type, right.conn.btaddr); + require(controller_identity_equal(slot_snapshot(0).identity, merged.identity), + "physical identity publication must not replace an enrolled composite owner"); + dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_FAILED, left, + left.switch2_identity_address_type, left.conn.btaddr); + dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_STARTED, right, + right.switch2_identity_address_type, right.conn.btaddr); + require(controller_identity_equal(slot_snapshot(0).identity, merged.identity), + "physical identity resolution resets must not demote a live pair to global"); + platform_on_controller_data(&left, &left_data); + platform_on_controller_data(&right, &right_data); + merged = slot_snapshot(0); + require(merged.state.dpad_up && merged.state.button_east && + merged.state.button_right_stick && !merged.state.button_left_stick && + !merged.state.button_left_shoulder && !merged.state.button_right_shoulder && + merged.state.left_stick_x == INT16_MIN && + merged.state.right_stick_x == scale_axis(200) && + merged.state.left_trigger == UINT16_MAX && + merged.state.right_trigger == UINT16_MAX && + merged.state.extra_buttons == 0x7f && + merged.state.motion_samples[0].accel_x == -4096 && + merged.state.motion_samples[0].accel_y == 0, + "merged native controls and extras must combine with right-only aim motion"); + bluepad32_input_backend_report_sent(0); + platform_on_controller_data(&left, &left_data); + require(slot_snapshot(0).state.motion_sample_count == 0, + "left reports must not replay the last right motion sample"); + + uni_hid_device_t ordinary = device(2); + platform_on_device_connected(&ordinary); + require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS, + "ordinary device must coexist with a pair"); + uni_controller_t ordinary_data{}; + ordinary_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + ordinary_data.gamepad.buttons = BUTTON_Y; + platform_on_controller_data(&ordinary, &ordinary_data); + const Bluepad32SlotSnapshot ordinary_before = slot_snapshot(2); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{51, 61}); + bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{71, 81}); + process_rumble_timer(&g_rumble_timer); + require(left.last_low == 51 && right.last_low == 51 && + left.last_high == 61 && right.last_high == 61 && + ordinary.last_low == 71 && ordinary.last_high == 81, + "pair feedback must fan out without reaching an ordinary player"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{}); + process_rumble_timer(&g_rumble_timer); + require(left.last_rumble_duration_ms == 0 && + right.last_rumble_duration_ms == 0 && ordinary.last_low == 71, + "pair stop must stop both physical motors only"); + bluepad32_input_backend_queue_profile_feedback( + 0, merged.connection_generation, 2, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + process_rumble_timer(&g_rumble_timer); + require(left.player_leds == 3 && right.player_leds == 3 && + left.last_low == UINT8_MAX && right.last_low == UINT8_MAX, + "profile feedback must light and rumble both halves"); + now_ms += 300; + process_rumble_timer(&g_rumble_timer); + require(left.player_leds == 1 && right.player_leds == 1, + "profile completion must restore both halves' player indication"); + + uni_hid_device_t& lost = right_first ? left : right; + uni_hid_device_t& survivor = right_first ? right : left; + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101}); + platform_on_device_disconnected(&lost); + const Bluepad32SlotSnapshot detached = slot_snapshot(0); + require(detached.active && !slot_snapshot(1).active && + detached.connection_generation != merged.connection_generation && + controller_identity_equal(detached.identity, identity_for_device(&survivor)) && + detached.state.extra_buttons == survivor.switch2_extra_buttons && + detached.state.motion_sample_count == 0 && + !detached.state.dpad_up && !detached.state.button_east && + (right_first ? detached.state.button_south : detached.state.button_west), + "either half detach must immediately publish only the rotated survivor and own profile"); + require_active_profile(detached.identity, right_first ? 5 : 2, + right_first ? UINT8_MAX : 37); + require(!bluepad32_input_backend_identify(merged.identity), + "an offline pair bank must not identify its surviving solo member"); + bluepad32_input_backend_playtest_snapshot(0, &playtest); + require(playtest.controller_layout == + (right_first ? Bluepad32ControllerLayout::kJoyCon2RightSolo + : Bluepad32ControllerLayout::kJoyCon2LeftSolo), + "playtest must stop presenting a pair immediately after companion loss"); + require(survivor.last_rumble_duration_ms == 0 && + slot_snapshot(2).connection_generation == ordinary_before.connection_generation && + slot_snapshot(2).state.button_north, + "detach must cancel survivor feedback without changing unrelated player state"); + const int survivor_calls = survivor.rumble_calls; + platform_on_controller_data(&lost, right_first ? &left_data : &right_data); + require(platform_on_device_ready(&lost) == UNI_ERROR_NO_SLOTS, + "late ready for a detached half must not resurrect its old generation"); + bluepad32_input_backend_queue_profile_feedback( + 0, merged.connection_generation, 8, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + process_rumble_timer(&g_rumble_timer); + require(survivor.rumble_calls == survivor_calls && + slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons, + "late detached input and generation-bound feedback must be ignored"); + uni_hid_device_t replacement = switch2_device(lost.idx, lost.product_id); + memcpy(replacement.conn.btaddr, lost.conn.btaddr, sizeof(bd_addr_t)); + replacement.switch2_identity_address_type = lost.switch2_identity_address_type; + left_profile.weak_rumble_scale = 61; + require(runtime_profile_storage.set(left_identity, 2, left_profile) == + ProfileStorageResult::kOk, + "changing the source solo must remain independent while detached"); + ready_switch2(replacement); + platform_on_device_disconnected(&lost); + require(slot_snapshot(0).active && !slot_snapshot(1).active && + slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons && + slot_snapshot(0).connection_generation != detached.connection_generation, + "replacement must re-pair without stale presses or a late old disconnect"); + require(controller_identity_equal(slot_snapshot(0).identity, merged.identity), + "rejoining the same typed physical members must restore the same pair owner"); + require_active_profile(slot_snapshot(0).identity, 7, 95); + const uint32_t clear_token = bluepad32_input_backend_clear_pairings(); + process_rumble_timer(&g_rumble_timer); + Bluepad32PairingSnapshot cleared{}; + bluepad32_input_backend_pairing_snapshot(&cleared); + require(bluepad32_input_backend_clear_pairings_completed(cleared, clear_token) && + device_disconnect_calls == 3 && + !slot_snapshot(0).active && !slot_snapshot(2).active && + !switch_pico_switch2_pairing_allowed(), + "clear must disconnect every physical half and ordinary device, not just outputs"); +} + +void test_switch2_multiple_pairs() { + start_pairing_backend(); + uni_hid_device_t right0 = switch2_device(0, UNI_SW2_JOYCON_R_PID); + uni_hid_device_t right1 = switch2_device(1, UNI_SW2_JOYCON_R_PID); + uni_hid_device_t left0 = switch2_device(2, UNI_SW2_JOYCON_L_PID); + uni_hid_device_t left1 = switch2_device(3, UNI_SW2_JOYCON_L_PID); + ready_switch2(right0); + ready_switch2(right1); + ready_switch2(left0); + ready_switch2(left1); + require(slot_snapshot(0).active && slot_snapshot(1).active && + !slot_snapshot(2).active && !slot_snapshot(3).active && + g_connection_policy_state == ConnectionPolicyState::Paused && + !scanning_enabled && !incoming_connections, + "two deterministic pairs must consume four physical resources but only two outputs"); + require_pair_owner(slot_snapshot(0).identity, left0, right0); + require_pair_owner(slot_snapshot(1).identity, left1, right1); + uni_controller_t data{}; + data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + data.gamepad.buttons = BUTTON_A; + right0.switch2_extra_buttons = UNI_SW2_BUTTON_C; + platform_on_controller_data(&right0, &data); + data.gamepad.buttons = BUTTON_Y; + right1.switch2_extra_buttons = UNI_SW2_BUTTON_GR; + platform_on_controller_data(&right1, &data); + require(slot_snapshot(0).state.button_south && !slot_snapshot(0).state.button_north && + slot_snapshot(0).state.extra_buttons == UNI_SW2_BUTTON_C && + slot_snapshot(1).state.button_north && !slot_snapshot(1).state.button_south && + slot_snapshot(1).state.extra_buttons == UNI_SW2_BUTTON_GR, + "two pairs must not cross-contaminate normal or extra source input"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{11, 21}); + bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{31, 41}); + process_rumble_timer(&g_rumble_timer); + require(left0.last_low == 11 && right0.last_low == 11 && + left1.last_low == 31 && right1.last_low == 31 && + left0.player_leds == 1 && right0.player_leds == 1 && + left1.player_leds == 2 && right1.player_leds == 2, + "multiple pairs must retain isolated rumble and player lighting"); + const Bluepad32SlotSnapshot other_pair = slot_snapshot(1); + platform_on_device_disconnected(&right0); + uni_hid_device_t ordinary = device(0); + platform_on_device_connected(&ordinary); + require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS && + slot_snapshot(0).active && slot_snapshot(2).active && + !slot_snapshot(3).active && + controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left0)), + "reusing freed physical index must allocate a free output, not evict the surviving half"); + data.gamepad.buttons = BUTTON_B; + platform_on_controller_data(&ordinary, &data); + bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{91, 101}); + process_rumble_timer(&g_rumble_timer); + require(slot_snapshot(2).state.button_east && !slot_snapshot(0).state.button_east && + ordinary.last_low == 91 && left1.last_low == 31 && right1.last_low == 31 && + slot_snapshot(1).connection_generation == other_pair.connection_generation && + slot_snapshot(1).state.button_north, + "remapped ordinary physical index must isolate input and feedback from both pairs"); +} + +void test_switch2_pair_admission_failure(bool right_first) { + start_pairing_backend(); + initialize_runtime_profile_storage(); + auto left = switch2_device(right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID); + auto& first = right_first ? right : left; + auto& second = right_first ? left : right; + ready_switch2(first); + const ControllerIdentity solo_identity = identity_for_device(&first); + require(runtime_profile_storage.activate(solo_identity, 3) == ProfileStorageResult::kOk, + "the first solo must have an existing independent active profile"); + uni_controller_t data{}; + data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + data.gamepad.buttons = BUTTON_TRIGGER_L | BUTTON_TRIGGER_R; + platform_on_controller_data(&first, &data); + const auto solo = slot_snapshot(0); + require(bluepad32_input_backend_capture_start( + 0, solo.connection_generation, CaptureOptions{}), + "the first solo must remain recordable during pair admission"); + bluepad32_input_backend_queue_profile_feedback( + 0, solo.connection_generation, 2, ControllerProfileConfirmationPolicy::kRumble); + const int initial_rumble_calls = first.rumble_calls; + platform_on_device_connected(&second); + second.switch2_identity_valid = false; + require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED && + pair_observation_count == 0 && + first.rumble_calls == initial_rumble_calls, + "an unstable member must reject pairing before storage or solo output changes"); + second.switch2_identity_valid = true; + first.switch2_identity_valid = false; + require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED && + pair_observation_count == 0 && + controller_identity_equal(slot_snapshot(0).identity, solo_identity), + "an unresolved first-ready member must not merge using its stale solo owner"); + first.switch2_identity_valid = true; + require(runtime_profile_storage.ensure_identity(identity_for_device(&second)) == + ProfileStorageResult::kOk, + "preexisting member rows must allow exercising failure of the actual pair seed"); + before_pair_seed = [](const ControllerIdentity&) { + const auto first_solo = slot_snapshot(0); + Bluepad32CaptureSnapshot capture{}; + require(first_solo.active && + first_solo.identity.transport == ControllerTransport::kBle && + !slot_snapshot(1).active && + bluepad32_input_backend_capture_page(0, 0, &capture) && + capture.state == CaptureState::kRecording, + "persistent pair setup must precede any visible solo topology change"); + }; + fail_profile_program = true; + require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED && + pair_observation_count == 1, + "pair seed I/O failure must explicitly reject the second ready callback"); + const auto rejected = slot_snapshot(0); + require(rejected.active && !slot_snapshot(1).active && + rejected.connection_generation == solo.connection_generation && + controller_identity_equal(rejected.identity, solo_identity) && + rejected.state.left_trigger == solo.state.left_trigger && + first.rumble_calls == initial_rumble_calls && + first.player_leds == 1 && second.player_leds == 0, + "seed failure must leave the first solo's owner, input, generation and outputs intact"); + ControllerIdentity pair_identity{}; + require(controller_identity_make_joycon_pair( + identity_for_device(&left), identity_for_device(&right), &pair_identity) && + runtime_profile_storage.find(pair_identity) == nullptr, + "a failed pair seed must not expose a partial persistent owner"); + process_rumble_timer(&g_rumble_timer); + require(first.last_low == UINT8_MAX && second.rumble_calls == 0, + "a rejected merge must retain the first solo's queued generation-bound feedback"); + fail_profile_program = false; + require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED, + "a retry before catalog replay must not expose a failed seed"); + require(runtime_profile_storage.initialize(runtime_profile_io()), + "storage replay must recover intact member banks after admission failure"); + require_active_profile(solo_identity, 3, UINT8_MAX); + require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, + "a replayed catalog must allow the pending physical member to merge on retry"); + before_pair_seed = nullptr; + require_pair_owner(slot_snapshot(0).identity, left, right); + require_active_profile(slot_snapshot(0).identity, right_first ? 0 : 3, UINT8_MAX); +} + +void test_switch2_pair_member_replacement() { + start_pairing_backend(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + right.conn.btaddr[0] = 0xc2; + ready_switch2(left); + ready_switch2(right); + const ControllerIdentity original = slot_snapshot(0).identity; + require(runtime_profile_storage.activate(original, 6) == ProfileStorageResult::kOk, + "the original physical pair must have a distinct saved selection"); + platform_on_device_disconnected(&right); + auto replacement = switch2_device(1, UNI_SW2_JOYCON_R_PID); + replacement.conn.btaddr[5] = 0x35; + ready_switch2(replacement); + const ControllerIdentity replaced = slot_snapshot(0).identity; + require_pair_owner(replaced, left, replacement); + require(!controller_identity_equal(original, replaced) && + !bluepad32_input_backend_identify(original), + "swapping only the right member must select a different pair bank and owner"); + require_active_profile(replaced, 0, UINT8_MAX); + require(runtime_profile_storage.activate(replaced, 4) == ProfileStorageResult::kOk, + "the replacement pair bank must be independently selectable"); + platform_on_device_disconnected(&replacement); + ready_switch2(right); + require(controller_identity_equal(slot_snapshot(0).identity, original), + "restoring the original right member must restore the original pair key"); + require_active_profile(slot_snapshot(0).identity, 6, UINT8_MAX); + require_active_profile(replaced, 4, UINT8_MAX); + platform_on_device_disconnected(&right); + auto typed_right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + memcpy(typed_right.conn.btaddr, right.conn.btaddr, sizeof(bd_addr_t)); + typed_right.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM; + ready_switch2(typed_right); + const ControllerIdentity retyped = slot_snapshot(0).identity; + require_pair_owner(retyped, left, typed_right); + require(!controller_identity_equal(retyped, original), + "a member's address type must participate in the pair key even with the same MAC"); + require_active_profile(retyped, 0, UINT8_MAX); + require_active_profile(original, 6, UINT8_MAX); +} + +void test_switch2_admission() { + start_backend(); + require(!switch_pico_switch2_pairing_allowed(), + "idle scanning must not grant fresh proprietary pairing"); + bluepad32_input_backend_open_pairing_window(); + require(!switch_pico_switch2_pairing_allowed(), + "pending Core0 request must not grant pairing before policy consumes it"); + process_rumble_timer(&g_rumble_timer); + require(switch_pico_switch2_pairing_allowed(), + "consumed explicit pairing window must permit proprietary pairing"); + now_ms = g_pairing_window_deadline_ms; + require(!switch_pico_switch2_pairing_allowed(), + "fresh pairing must close at its deadline even before timer processing"); + uni_hid_device_t pro = switch2_device(0, UNI_SW2_PRO_PID); + uni_hid_device_t ordinary = device(1, true, UNI_BT_CONN_PROTOCOL_BLE); + register_lookup_device(&pro); + register_lookup_device(&ordinary); + __wrap_sm_request_pairing(pro.conn.handle); + require(device_disconnect_calls == 1 && last_disconnected_device == &pro && + ordinary_smp_requests == 0 && delete_key_calls == 0, + "Switch2 GATT auth failure must disconnect without SMP or bond deletion"); + __wrap_sm_request_pairing(ordinary.conn.handle); + __wrap_sm_request_pairing(0x99); + require(ordinary_smp_requests == 2 && device_disconnect_calls == 1, + "ordinary and unknown handles must preserve standard SMP behavior"); + require(identity_for_device(&pro).stable && + controller_identity_is_global(identity_for_device(&ordinary)), + "only parser-validated proprietary public identity may bypass SMP resolution"); + pro.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM; + pro.conn.btaddr[0] = 0xc1; + require(identity_for_device(&pro).stable && + identity_for_device(&pro).address_type == BD_ADDR_TYPE_LE_RANDOM, + "parser-validated static random identity must retain its address type"); + pro.switch2_identity_valid = false; + pro.conn.btaddr[0] = 0x41; + require(controller_identity_is_global(identity_for_device(&pro)), + "unvalidated proprietary RPA must remain on the global profile"); + pro.switch2_identity_valid = true; + pro.conn.btaddr[0] = 0xc1; + ready_switch2(pro); + uni_controller_t input{}; + input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + input.gamepad.buttons = BUTTON_B; + input.gamepad.axis_x = 511; + input.gamepad.axis_ry = -512; + pro.switch2_extra_buttons = + UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_GL | UNI_SW2_BUTTON_GR; + platform_on_controller_data(&pro, &input); + require(slot_snapshot(0).state.button_east && + slot_snapshot(0).state.left_stick_x == INT16_MAX && + slot_snapshot(0).state.right_stick_y == INT16_MIN && + slot_snapshot(0).state.extra_buttons == 7, + "Pro2 must preserve vertical normal controls and ingest remappable extras"); +} + +void test_switch2_radio_policy(bool individual) { + start_backend(); + if (individual) set_runtime_joycon_mode(JoyConMode::kIndividual); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID); + auto classic = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto other_ble = device(3, true, UNI_BT_CONN_PROTOCOL_BLE); + ready_switch2(left); + require(negotiated_intervals[left.conn.handle] == 6, + "a single Switch2 link must retain fast scheduling"); + ready_switch2(right); + require(incoming_connections && + negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6, + "paired and individual Joy-Cons must retain fast links before Classic arrival"); + platform_on_device_connected(&other_ble); + require(platform_on_device_ready(&other_ble) == UNI_ERROR_SUCCESS, + "unrelated BLE controller must join"); + platform_on_device_connected(&classic); + require(negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6 && + negotiated_intervals[other_ble.conn.handle] == 6, + "Classic setup must not slow Joy-Cons or retime unrelated BLE"); + require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS, + "Classic controller must complete setup alongside the pair"); + const auto pair = slot_snapshot(0); + platform_on_device_disconnected(&classic); + require(negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6 && + slot_snapshot(0).connection_generation == pair.connection_generation, + "Classic departure must preserve fast Joy-Con links without rebinding the pair"); + platform_on_device_connected(&classic); + require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS, + "Classic reconnect must complete"); + platform_on_device_disconnected(&left); + require(negotiated_intervals[right.conn.handle] == 6, + "a single surviving Switch2 link must return to fast scheduling even with Classic"); + left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + ready_switch2(left); + require(negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6, + "physical index and handle reuse must retain fast Joy-Con links"); + negotiated_intervals[left.conn.handle] = 24; + negotiated_intervals[right.conn.handle] = 24; + now_ms += 1000; + process_configuration_timer(&g_configuration_timer); + require(negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 6, + "peer-negotiated slow Joy-Con links must reconcile to the fast default"); + platform_on_device_disconnected(&right); + right = switch2_device(1, UNI_SW2_PRO_PID); + ready_switch2(right); + require(negotiated_intervals[left.conn.handle] == 6 && + negotiated_intervals[right.conn.handle] == 24, + "Joy-Con preference must not remove the existing Switch2 Pro coexistence policy"); +} + +void test_switch2_radio_settling() { + start_backend(); + auto classic = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto left = switch2_device(0, UNI_SW2_PRO_PID); + auto right = switch2_device(1, UNI_SW2_PRO_PID); + platform_on_device_connected(&classic); + require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS, + "Classic-first connection must become ready"); + ready_switch2(left); + defer_interval_updates = true; + now_ms = UINT32_MAX - 10; + platform_on_device_connected(&right); + require(interval_requests[right.conn.handle] == 0, + "pending Switch2 setup must retain ownership of its initial interval"); + require(platform_on_device_ready(&right) == UNI_ERROR_SUCCESS, + "second Switch2 connection must become ready"); + platform_on_device_disconnected(&right); + // The surviving controller completes the old update after losing its mate. + for (auto* half : {&left, &right}) { + negotiated_intervals[half->conn.handle] = pending_intervals[half->conn.handle]; + } + now_ms += 50; + process_configuration_timer(&g_configuration_timer); + negotiated_intervals[left.conn.handle] = pending_intervals[left.conn.handle]; + require(negotiated_intervals[left.conn.handle] == 6, + "late coexistence update must not strand a solo at slow intervals across clock wrap"); + process_configuration_timer(&g_configuration_timer); + defer_interval_updates = false; + reject_interval_updates = true; + ready_switch2(right); + const unsigned attempts = interval_requests[left.conn.handle]; + now_ms += 999; + process_configuration_timer(&g_configuration_timer); + require(interval_requests[left.conn.handle] == attempts, + "rejected negotiation must not flood the HCI command queue"); + reject_interval_updates = false; + ++now_ms; + process_configuration_timer(&g_configuration_timer); + require(negotiated_intervals[left.conn.handle] == 24 && + negotiated_intervals[right.conn.handle] == 24, + "transiently rejected negotiation must recover without reconnect or pairing"); +} + +void test_switch2_mate_reconnect() { + start_backend(); + auto right = switch2_device(0, UNI_SW2_JOYCON_R_PID); + auto left = switch2_device(1, UNI_SW2_JOYCON_L_PID); + left.conn.btaddr[0] = 0xc1; + left.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM; + remember_switch2(right); + remember_switch2(left); + register_lookup_device(&right); + register_lookup_device(&left); + ready_switch2(right); + require(scanning_enabled && background_scan_parameters && + !classic_scanning_enabled && incoming_connections && + !bondable && accepted_stk_methods == 0 && + !switch_pico_switch2_pairing_allowed(), + "first remembered half must seek its mate with low-duty BLE and authentication closed"); + dispatch_pairing_event(HCI_EVENT_USER_CONFIRMATION_REQUEST); + dispatch_pairing_event(HCI_EVENT_USER_PASSKEY_REQUEST); + require(confirmation_accepts == 0 && passkey_accepts == 0 && + confirmation_rejections == 1 && passkey_rejections == 1, + "mate scanning must not authorize new Classic authentication"); + + bd_addr_t missing = {1, 2, 3, 4, 5, 6}; + require(platform_on_device_discovered(missing, "Joy-Con 2 (L)", 0, 0) == + UNI_ERROR_IGNORE_DEVICE, + "a device name without a prepared parser candidate cannot authorize passive discovery"); + const auto reject_left = [&]() { + require(platform_on_device_discovered(left.conn.btaddr, "Joy-Con 2 (L)", 0, 0) == + UNI_ERROR_IGNORE_DEVICE && + scanning_enabled && !classic_scanning_enabled && + !switch_pico_switch2_pairing_allowed(), + "rejected mate candidates must leave the bounded passive policy unchanged"); + }; + ++left.conn.btaddr[5]; + reject_left(); + --left.conn.btaddr[5]; + left.product_id = UNI_SW2_JOYCON_R_PID; + reject_left(); + left.product_id = UNI_SW2_PRO_PID; + reject_left(); + left.product_id = UNI_SW2_JOYCON_L_PID; + left.conn.protocol = UNI_BT_CONN_PROTOCOL_BR_EDR; + reject_left(); + left.conn.protocol = UNI_BT_CONN_PROTOCOL_BLE; + ++left.vendor_id; + reject_left(); + --left.vendor_id; + left.switch2_identity_valid = false; + reject_left(); + left.switch2_identity_valid = true; + left.switch2_identity_address_type = BD_ADDR_TYPE_LE_PUBLIC; + reject_left(); + left.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM; + require(platform_on_device_discovered(left.conn.btaddr, nullptr, 0, 0) == + UNI_ERROR_SUCCESS, + "remembered opposite static-random half must be admitted outside the pairing window"); + + // The parser stops GAP scan before it has a backend slot to reserve. + uni_bt_le_scan_stop(); + platform_on_device_connected(&left); + require(!scanning_enabled && !classic_scanning_enabled, + "remembered mate setup must pause scan even when GAP already stopped it"); + platform_on_device_disconnected(&left); + require(scanning_enabled && background_scan_parameters && !classic_scanning_enabled, + "failed setup must resume remembered mate scanning"); + uni_bt_le_scan_stop(); + platform_on_device_connected(&left); + require(!scanning_enabled && !classic_scanning_enabled && incoming_connections, + "mate setup must pause background scanning while reserving physical capacity"); + require(platform_on_device_ready(&left) == UNI_ERROR_SUCCESS && + slot_snapshot(0).active && !slot_snapshot(1).active && + !scanning_enabled && !classic_scanning_enabled, + "ready remembered mate must merge without a pairing window and stop background scanning"); + require_pair_owner(slot_snapshot(0).identity, left, right); + auto ordinary = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + platform_on_device_connected(&ordinary); + require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS && + !scanning_enabled && !classic_scanning_enabled, + "a complete pair plus incoming Classic controller must not keep LE scanning"); + platform_on_device_disconnected(&left); + require(scanning_enabled && background_scan_parameters && + !classic_scanning_enabled && slot_snapshot(2).active, + "losing a half must resume its mate scan without disturbing an ordinary controller"); +} + +void test_switch2_mate_pending() { + start_backend(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + auto right = switch2_device(3, UNI_SW2_JOYCON_R_PID); + remember_switch2(left); + remember_switch2(right); + register_lookup_device(&right); + ready_switch2(left); + auto first = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto second = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto third = device(3, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + platform_on_device_connected(&first); + platform_on_device_connected(&second); + require(!scanning_enabled && !classic_scanning_enabled && incoming_connections && + platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) == + UNI_ERROR_IGNORE_DEVICE, + "any pending setup must pause scanning and reject otherwise valid mates"); + require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS && + !scanning_enabled, + "one resolved setup must not resume mate scanning while another is pending"); + platform_on_device_disconnected(&second); + require(scanning_enabled && background_scan_parameters && + !classic_scanning_enabled && + platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) == + UNI_ERROR_SUCCESS, + "last pending setup failure must resume passive mate discovery and admission"); + platform_on_device_connected(&second); + require(!scanning_enabled && + platform_on_device_ready(&second) == UNI_ERROR_SUCCESS && + scanning_enabled && !classic_scanning_enabled, + "last pending setup becoming ready must resume the unmatched half scan"); + platform_on_device_connected(&third); + require(platform_on_device_ready(&third) == UNI_ERROR_SUCCESS && + !scanning_enabled && !classic_scanning_enabled && !incoming_connections && + platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) == + UNI_ERROR_IGNORE_DEVICE, + "four physical controllers must stop mate scanning even with an unmatched half"); + platform_on_device_disconnected(&third); + require(scanning_enabled && background_scan_parameters && incoming_connections, + "free physical capacity must restore the waiting half scan"); + + switch2_clear_succeeds = false; + bluepad32_input_backend_clear_pairings(); + process_rumble_timer(&g_rumble_timer); + require(!scanning_enabled && !classic_scanning_enabled && !incoming_connections, + "failed trust deletion must explicitly stop a direct background LE scan"); + platform_on_device_disconnected(&right); + platform_on_device_disconnected(&left); + bluepad32_input_backend_open_pairing_window(); + process_rumble_timer(&g_rumble_timer); + require(!scanning_enabled && !classic_scanning_enabled && !incoming_connections && + !switch_pico_switch2_pairing_allowed() && + platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) == + UNI_ERROR_IGNORE_DEVICE, + "late disconnects and pairing requests must not clear the failed-closed latch"); +} + +void test_switch2_mate_pairing_window() { + start_backend(); + auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID); + ready_switch2(left); + require(scanning_enabled && background_scan_parameters && !classic_scanning_enabled, + "solo half must enter background discovery before opening pairing"); + bluepad32_input_backend_open_pairing_window(); + process_rumble_timer(&g_rumble_timer); + require(scanning_enabled && !background_scan_parameters && classic_scanning_enabled && + bondable && accepted_stk_methods == kAllBlePairingMethods, + "explicit pairing must restore full discovery timing and the existing authentication window"); + now_ms = g_pairing_window_deadline_ms; + process_rumble_timer(&g_rumble_timer); + require(scanning_enabled && background_scan_parameters && !classic_scanning_enabled && + !bondable && accepted_stk_methods == 0 && + !switch_pico_switch2_pairing_allowed(), + "pairing expiry must restore low-duty mate discovery without extending authentication"); + platform_on_device_disconnected(&left); + require(scanning_enabled && !background_scan_parameters && classic_scanning_enabled && + !bondable && accepted_stk_methods == 0, + "last controller loss must restore idle discovery defaults without opening pairing"); +} + +void test_switch2_pairing_inventory() { + start_pairing_backend(); + switch2_pairing_count = 2; + switch2_pairing_types[0] = BD_ADDR_TYPE_LE_PUBLIC; + switch2_pairing_types[1] = BD_ADDR_TYPE_LE_RANDOM; + switch2_pairings[0][5] = 1; + switch2_pairings[1][0] = 0xc1; + switch2_pairings[1][5] = 2; + bluepad32_input_backend_request_pairing_snapshot(); + process_rumble_timer(&g_rumble_timer); + Bluepad32PairingSnapshot snapshot{}; + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.record_count == 2 && !snapshot.overflow && + snapshot.records[0].transport == Bluepad32PairingTransport::kBle && + snapshot.records[0].address_type == BD_ADDR_TYPE_LE_PUBLIC && + snapshot.records[0].address[5] == 1 && + snapshot.records[1].address_type == BD_ADDR_TYPE_LE_RANDOM && + snapshot.records[1].address[0] == 0xc1, + "pairing inventory must include proprietary trust records with real BLE address types"); + switch2_pairing_count = UNI_SWITCH2_PAIRING_CAPACITY; + classic_bond_count = 1; + bluepad32_input_backend_request_pairing_snapshot(); + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.record_count == BLUEPAD32_PAIRING_RECORD_CAPACITY && + snapshot.overflow, + "combined ordinary and proprietary inventory must preserve bounded overflow reporting"); + const uint32_t successful_token = bluepad32_input_backend_clear_pairings(); + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.record_count == 0 && !snapshot.overflow && + switch2_pairing_count == 0 && + bluepad32_input_backend_clear_pairings_completed(snapshot, successful_token), + "clear completion must follow deletion of proprietary and ordinary pairing records"); + switch2_pairing_count = 1; + switch2_clear_succeeds = false; + const uint32_t failed_token = bluepad32_input_backend_clear_pairings(); + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.status == Bluepad32PairingSnapshotStatus::kFailed && + snapshot.completed_clear_pairings_token == successful_token && + !bluepad32_input_backend_clear_pairings_completed(snapshot, failed_token) && + !incoming_connections && !scanning_enabled && + !switch_pico_switch2_pairing_allowed(), + "failed trust deletion must never acknowledge clear or admit reconnects"); + bluepad32_input_backend_open_pairing_window(); + bluepad32_input_backend_request_pairing_snapshot(); + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.status == Bluepad32PairingSnapshotStatus::kFailed && + snapshot.completed_clear_pairings_token == successful_token && + !switch_pico_switch2_pairing_allowed() && + g_connection_policy_state == ConnectionPolicyState::FailedClosed, + "inventory refresh and pairing requests must not erase failed-clear state"); + switch2_clear_succeeds = true; + const uint32_t retry_token = bluepad32_input_backend_clear_pairings(); + require(retry_token != failed_token, "explicit retry must receive a fresh clear token"); + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.status == Bluepad32PairingSnapshotStatus::kReady && + snapshot.record_count == 0 && + bluepad32_input_backend_clear_pairings_completed(snapshot, retry_token) && + incoming_connections && scanning_enabled && + !switch_pico_switch2_pairing_allowed(), + "successful explicit retry must restore normal reconnect policy, not fresh pairing"); +} + void test_ready_order(bool reverse) { start_pairing_backend(); uni_hid_device_t devices[kSlotCount] = { @@ -812,23 +3394,21 @@ void test_ready_order(bool reverse) { } if (position + 1 < kSlotCount) { - require(scan_stops == 0, - "scanning must continue while any slot remains free"); + require(scanning_enabled && classic_scanning_enabled, + "pairing-window discovery must continue while a physical slot remains free"); require(incoming_connections, "incoming connections must remain enabled before all slots are ready"); } } - require(scan_stops == 1, - "scanning must stop exactly when all four slots are ready"); + require(!scanning_enabled && !classic_scanning_enabled, + "discovery must stop when all four physical slots are full"); require(!incoming_connections, "incoming connections must be disabled only when all slots are full"); for (int slot = 0; slot < kSlotCount; ++slot) { - const int starts_before_disconnect = scan_starts; platform_on_device_disconnected(&devices[slot]); - require(scan_starts == starts_before_disconnect + 1 && - scanning_enabled && incoming_connections, + require(scanning_enabled && classic_scanning_enabled && incoming_connections, "disconnecting any slot must resume connection policy"); for (int candidate = 0; candidate < kSlotCount; ++candidate) { @@ -908,6 +3488,29 @@ void test_rejections() { playtest.battery == 201 && (playtest.capabilities & 0x08u) != 0, "playtest snapshot did not expose input capabilities"); + struct LayoutCase { + uni_controller_subtype_t subtype; + 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_NUNCHUK, Bluepad32ControllerLayout::kWiiNunchuk}, + {CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL, Bluepad32ControllerLayout::kWiiNunchuk}, + {CONTROLLER_SUBTYPE_WII_CLASSIC, Bluepad32ControllerLayout::kUnspecified}, + {CONTROLLER_SUBTYPE_WIIUPRO, Bluepad32ControllerLayout::kUnspecified}, + {CONTROLLER_SUBTYPE_WII_BALANCE_BOARD, Bluepad32ControllerLayout::kUnspecified}, + {CONTROLLER_SUBTYPE_WIIMOTE_UDRAW_TABLET, Bluepad32ControllerLayout::kUnspecified}, + {CONTROLLER_SUBTYPE_NONE, Bluepad32ControllerLayout::kUnspecified}, + }; + for (const LayoutCase& expected : layouts) { + slot_zero.controller_subtype = expected.subtype; + bluepad32_input_backend_playtest_snapshot(0, &playtest); + require(playtest.controller_layout == expected.layout && + playtest.state.motion_sample_count == 3, + "Wii layout must follow the live subtype, not motion or previous extension"); + } bluepad32_input_backend_report_sent(0); require(read_controller_state(0, &snapshot) && snapshot.motion_sample_count == 3, @@ -950,12 +3553,6 @@ void test_independent_lifecycle() { require(g_slots[0].device == &aborted && !g_slots[0].active, "connected device must remain identifiable while becoming ready"); - const int starts_before_aborted_disconnect = scan_starts; - const int classic_starts_before_aborted_disconnect = - classic_scan_starts; - const int stops_before_aborted_disconnect = scan_stops; - const int classic_stops_before_aborted_disconnect = - classic_scan_stops; platform_on_device_disconnected(&aborted); require(g_slots[0].device == nullptr && !g_slots[0].active, "pre-ready disconnect must clear its pending slot identity"); @@ -963,13 +3560,7 @@ void test_independent_lifecycle() { "pre-ready disconnect must invalidate its connection generation"); require(g_connection_status == ConnectionStatus::Scanning && scanning_enabled && classic_scanning_enabled && - incoming_connections && - scan_starts == starts_before_aborted_disconnect + 1 && - classic_scan_starts == - classic_starts_before_aborted_disconnect + 1 && - scan_stops == stops_before_aborted_disconnect + 1 && - classic_scan_stops == - classic_stops_before_aborted_disconnect + 1, + incoming_connections, "pre-ready disconnect must restart Classic and BLE scans"); uni_hid_device_t devices[kSlotCount] = { @@ -1032,7 +3623,6 @@ void test_independent_lifecycle() { const uint32_t first_pending_generation = g_slots[0].connection_generation; - const int starts_before_first_pending_disconnect = scan_starts; platform_on_device_disconnected(&devices[0]); require(g_slots[0].device == nullptr && !g_slots[0].active, "pre-ready disconnect must clear only its own pending identity"); @@ -1046,8 +3636,7 @@ void test_independent_lifecycle() { "pending disconnect beside peers must invalidate its generation"); require(g_connection_status == ConnectionStatus::Connecting && scanning_enabled && classic_scanning_enabled && - incoming_connections && - scan_starts == starts_before_first_pending_disconnect + 1, + incoming_connections, "open pairing slot must preserve pending peers and resume scanning"); for (int slot = 1; slot < kSlotCount; ++slot) { @@ -1207,10 +3796,8 @@ void test_independent_lifecycle() { bluepad32_input_backend_queue_rumble(3, ControllerRumbleOutput{55, 66}); const uint32_t disconnected_generation = baseline_connection_generations[3]; - const int starts_before_slot_three_disconnect = scan_starts; platform_on_device_disconnected(&devices[3]); - require(scan_starts == starts_before_slot_three_disconnect + 1 && - scanning_enabled && incoming_connections, + require(scanning_enabled && classic_scanning_enabled && incoming_connections, "slot 3 disconnect must resume scanning and incoming connections"); require(!read_controller_state(3, &states[3]) && !states[3].button_north && states[3].left_stick_x == 0, @@ -1344,10 +3931,8 @@ void test_independent_lifecycle() { uni_hid_device_t replacements[kSlotCount] = { device(0), device(1), device(2), device(3)}; for (int slot = 0; slot < 3; ++slot) { - const int starts_before_disconnect = scan_starts; platform_on_device_disconnected(&devices[slot]); - require(scan_starts == starts_before_disconnect + 1 && - scanning_enabled && incoming_connections, + require(scanning_enabled && classic_scanning_enabled && incoming_connections, "disconnecting slots 0-2 must resume connection policy"); require(!read_controller_state(slot, &states[slot]) && states[slot].left_stick_x == 0, @@ -2551,14 +5136,14 @@ void test_wake_identity_gates_connections() { require(switch2_wake_initializations == 1 && g_connection_policy_state == ConnectionPolicyState::Uninitialized && - scan_starts == 0 && classic_scan_starts == 0 && + !scanning_enabled && !classic_scanning_enabled && !incoming_connections, "controller discovery started before wake identity was ready"); switch2_connections_ready = true; process_rumble_timer(&g_rumble_timer); require(g_connection_policy_state == ConnectionPolicyState::Open && - scan_starts == 1 && classic_scan_starts == 1 && + scanning_enabled && classic_scanning_enabled && incoming_connections, "controller discovery did not start after wake identity setup"); } @@ -2837,7 +5422,89 @@ int main(int argc, char** argv) { return 0; } #endif - if (scenario == "ready-forward") { + if (scenario == "switch2-hd-pro") { + test_switch2_hd_pro(); + } else if (scenario == "switch2-gesture-timing") { + test_switch2_gesture_timing(); + } else if (scenario == "switch2-gesture-slot-left-first") { + test_switch2_gesture_slot_order(true, 0); + } else if (scenario == "switch2-gesture-slot-right-first") { + test_switch2_gesture_slot_order(false, 0); + } else if (scenario == "switch2-gesture-slot-occupied") { + test_switch2_gesture_slot_order(true, 1); + } else if (scenario == "switch2-gesture-stale") { + test_switch2_gesture_stale(); + } else if (scenario == "switch2-gesture-clock-wrap") { + test_switch2_gesture_clock_wrap(); + } else if (scenario == "switch2-gesture-masking-epochs") { + test_switch2_gesture_masking_epochs(); + } else if (scenario == "switch2-gesture-override-lifetime") { + test_switch2_gesture_override_lifetime(); + } else if (scenario == "switch2-gesture-two-pairs") { + test_switch2_gesture_two_pairs(); + } else if (scenario == "switch2-gesture-ambiguous") { + test_switch2_gesture_ambiguous(); + } else if (scenario == "switch2-gesture-seed-failure") { + test_switch2_gesture_seed_failure(); + } else if (scenario == "switch2-gesture-device-scope") { + test_switch2_gesture_device_scope(); + } else if (scenario == "switch2-hd-solo-left") { + test_switch2_hd_solo(false); + } else if (scenario == "switch2-hd-solo-right") { + test_switch2_hd_solo(true); + } else if (scenario == "switch2-hd-pair") { + test_switch2_hd_pair(); + } else if (scenario == "switch2-hd-overflow") { + test_switch2_hd_overflow(); + } else if (scenario == "switch2-hd-epochs") { + test_switch2_hd_epochs(); + } else if (scenario == "switch2-hd-feedback") { + test_switch2_hd_feedback(); + } else if (scenario == "switch2-individual-core-start") { + test_switch2_individual_core_start(); + } else if (scenario == "switch2-individual-forward") { + test_switch2_individual_boot(false); + } else if (scenario == "switch2-individual-reverse") { + test_switch2_individual_boot(true); + } else if (scenario == "switch2-mode-forward") { + test_switch2_mode_roundtrip(false); + } else if (scenario == "switch2-mode-reverse") { + test_switch2_mode_roundtrip(true); + } else if (scenario == "switch2-mode-two-pairs") { + test_switch2_mode_two_pairs(); + } else if (scenario == "switch2-mode-seed-failure") { + test_switch2_live_pair_failure(false); + } else if (scenario == "switch2-mode-identity-failure") { + test_switch2_live_pair_failure(true); + } else if (scenario == "switch2-forward") { + test_switch2_pair_lifecycle(false); + } else if (scenario == "switch2-reverse") { + test_switch2_pair_lifecycle(true); + } else if (scenario == "switch2-multiple-pairs") { + test_switch2_multiple_pairs(); + } else if (scenario == "switch2-pair-failure-left") { + test_switch2_pair_admission_failure(false); + } else if (scenario == "switch2-pair-failure-right") { + test_switch2_pair_admission_failure(true); + } else if (scenario == "switch2-pair-replacement") { + test_switch2_pair_member_replacement(); + } else if (scenario == "switch2-admission") { + test_switch2_admission(); + } else if (scenario == "switch2-radio-policy") { + test_switch2_radio_policy(false); + } else if (scenario == "switch2-radio-individual") { + test_switch2_radio_policy(true); + } else if (scenario == "switch2-radio-settling") { + test_switch2_radio_settling(); + } else if (scenario == "switch2-mate-reconnect") { + test_switch2_mate_reconnect(); + } else if (scenario == "switch2-mate-pending") { + test_switch2_mate_pending(); + } else if (scenario == "switch2-mate-pairing-window") { + test_switch2_mate_pairing_window(); + } else if (scenario == "switch2-pairing-inventory") { + test_switch2_pairing_inventory(); + } else if (scenario == "ready-forward") { test_ready_order(false); } else if (scenario == "ready-reverse") { test_ready_order(true); diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index d64b47f..eda3d99 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -34,6 +34,7 @@ struct btstack_link_key_iterator_t { enum { ERROR_CODE_SUCCESS = 0, + HCI_CON_HANDLE_INVALID = 0xffff, HCI_EVENT_PACKET = 4, HCI_EVENT_USER_CONFIRMATION_REQUEST = 0x33, HCI_EVENT_USER_PASSKEY_REQUEST = 0x34, @@ -57,6 +58,7 @@ enum uni_error_t { UNI_ERROR_IGNORE_DEVICE = 1, UNI_ERROR_INVALID_CONTROLLER = 2, UNI_ERROR_NO_SLOTS = 3, + UNI_ERROR_INIT_FAILED = 4, }; enum { @@ -81,6 +83,20 @@ enum { MISC_BUTTON_CAPTURE = 1 << 3, }; +typedef enum { + CONTROLLER_SUBTYPE_NONE = 0, + CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL, + CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL, + CONTROLLER_SUBTYPE_WIIMOTE_ACCEL, + CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK, + CONTROLLER_SUBTYPE_UNUSED_00, + CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL, + CONTROLLER_SUBTYPE_WII_CLASSIC, + CONTROLLER_SUBTYPE_WIIUPRO, + CONTROLLER_SUBTYPE_WII_BALANCE_BOARD, + CONTROLLER_SUBTYPE_WIIMOTE_UDRAW_TABLET, +} uni_controller_subtype_t; + struct uni_gamepad_t { uint32_t dpad; uint32_t buttons; @@ -147,6 +163,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_subtype_t controller_subtype; uni_bt_conn_t conn; int idx; bool gamepad; @@ -163,6 +180,12 @@ struct uni_hid_device_s { int player_led_calls; uint8_t player_leds; uni_circular_buffer_t outgoing_buffer; + uint8_t switch2_extra_buttons; + bool switch2_identity_valid; + uint8_t switch2_identity_address_type; + bool switch2_host_blocked; + unsigned switch2_host_calls; + unsigned switch2_haptics_resets; }; void uni_hid_parser_xboxone_play_dual_rumble( @@ -192,6 +215,7 @@ int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device); void uni_hid_device_disconnect(uni_hid_device_t* device); uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle( hci_con_handle_t handle); +uni_hid_device_t* uni_hid_device_get_instance_for_address(const bd_addr_t address); void uni_bt_allow_incoming_connections(bool enabled); void uni_bt_start_scanning_and_autoconnect_unsafe(); void uni_bt_stop_scanning_unsafe(); @@ -199,9 +223,14 @@ void uni_bt_bredr_scan_start(); void uni_bt_bredr_scan_stop(); void uni_bt_le_scan_start(); void uni_bt_le_scan_stop(); +void uni_bt_le_set_background_scan(bool enabled); void uni_bt_del_keys_unsafe(); gap_connection_type_t gap_get_connection_type( hci_con_handle_t connection_handle); +uint16_t gap_le_connection_interval(hci_con_handle_t handle); +int gap_update_connection_parameters(hci_con_handle_t handle, uint16_t minimum, + uint16_t maximum, uint16_t latency, + uint16_t supervision_timeout); int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator); int gap_link_key_iterator_get_next( btstack_link_key_iterator_t* iterator, bd_addr_t address, diff --git a/tests/configuration_service_test.cpp b/tests/configuration_service_test.cpp index 569537f..e6440d6 100644 --- a/tests/configuration_service_test.cpp +++ b/tests/configuration_service_test.cpp @@ -133,6 +133,7 @@ void test_service_lifecycle_and_mutations(bool v2) { require(snapshot.state == ConfigurationServiceState::kReady && snapshot.configuration.pairing_window_seconds == 90 && snapshot.configuration.requested_mode == original_mode && + snapshot.configuration.joycon_mode == JoyConMode::kPaired && snapshot.configuration.native_switch_controller_count == 0, "Core 0 did not preserve legacy settings without approval"); require(g_flash.program_count == programs_after_seed && @@ -160,6 +161,7 @@ void test_service_lifecycle_and_mutations(bool v2) { configuration_service_snapshot(&snapshot); require(snapshot.state == ConfigurationServiceState::kReady && snapshot.configuration.requested_mode == original_mode && + snapshot.configuration.joycon_mode == JoyConMode::kPaired && snapshot.configuration.native_switch_controller_count == 0 && snapshot.transaction.status == ConfigurationTransactionStatus::kIdle, @@ -172,7 +174,7 @@ void test_service_lifecycle_and_mutations(bool v2) { ADAPTER_CONFIGURATION_SCHEMA_VERSION && after_migration.snapshot().payload_size == ADAPTER_CONFIGURATION_ENCODED_SIZE, - "power cycle did not observe the migrated v3 record"); + "power cycle did not observe the migrated v4 record"); AdapterConfiguration migrated{}; require(adapter_configuration_decode( after_migration.snapshot().schema_version, @@ -180,8 +182,9 @@ void test_service_lifecycle_and_mutations(bool v2) { after_migration.snapshot().payload_size, &migrated) && migrated.pairing_window_seconds == 90 && migrated.requested_mode == original_mode && + migrated.joycon_mode == JoyConMode::kPaired && migrated.native_switch_controller_count == 0, - "migrated v3 bytes did not preserve legacy configuration"); + "migrated v4 bytes did not preserve legacy configuration"); require(configuration_service_set_mode( 10, AdapterRequestedMode::kSwitch, implemented) == @@ -476,6 +479,17 @@ void test_service_lifecycle_and_mutations(bool v2) { } void test_abandoned_host_receive_does_not_block_recovery() { + AdapterConfiguration original{}; + original.pairing_window_seconds = 90; + original.requested_mode = AdapterRequestedMode::kSwitch; + original.joycon_mode = JoyConMode::kIndividual; + uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; + ConfigurationStorage seed; + require(adapter_configuration_encode(original, payload, sizeof(payload)) && + seed.initialize(fake_io()) && + seed.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload, + sizeof(payload)) == ConfigurationStorageResult::kOk, + "recovery configuration fixture did not persist"); configuration_service_prepare(); configuration_service_initialize_pre_usb(); configuration_service_initialize_on_storage_core(); @@ -484,7 +498,7 @@ void test_abandoned_host_receive_does_not_block_recovery() { configuration_service_snapshot(&snapshot); AdapterConfiguration abandoned = snapshot.configuration; abandoned.pairing_window_seconds = 180; - uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; + abandoned.joycon_mode = JoyConMode::kPaired; require(adapter_configuration_encode(abandoned, payload, sizeof(payload)), "abandoned recovery-race configuration did not encode"); @@ -513,6 +527,11 @@ void test_abandoned_host_receive_does_not_block_recovery() { require_mode_status(kRecoveryAuto, ConfigurationTransactionStatus::kCommitted, "recovery Auto did not commit after abandoned receive"); + configuration_service_snapshot(&snapshot); + require(snapshot.configuration.requested_mode == AdapterRequestedMode::kAuto && + snapshot.configuration.joycon_mode == JoyConMode::kIndividual && + snapshot.configuration.pairing_window_seconds == 90, + "recovery Auto replaced durable settings with defaults or abandoned data"); require(configuration_service_mode_transaction_reboot_ready( kRecoveryAuto), "recovery Auto did not become latest reboot authority"); @@ -535,7 +554,7 @@ void queue_configuration(uint32_t transaction_id, "approval configuration did not reach pending commit"); } -void test_native_switch_approval_preservation_and_revocation() { +void test_native_switch_approval_preservation_and_revocation(bool migrate_v3) { ControllerIdentity pro{}; pro.stable = true; pro.transport = ControllerTransport::kClassic; @@ -548,6 +567,8 @@ void test_native_switch_approval_preservation_and_revocation() { left.product_id = 0x2006; AdapterConfiguration configuration{}; configuration.pairing_window_seconds = 90; + configuration.joycon_mode = + migrate_v3 ? JoyConMode::kPaired : JoyConMode::kIndividual; configuration.native_switch_controller_count = 2; configuration.native_switch_controllers[0] = pro; configuration.native_switch_controllers[1] = left; @@ -555,28 +576,59 @@ void test_native_switch_approval_preservation_and_revocation() { ConfigurationStorage seed; require(adapter_configuration_encode(configuration, payload, sizeof(payload)) && seed.initialize(fake_io()) && - seed.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload, - sizeof(payload)) == ConfigurationStorageResult::kOk, + seed.commit(migrate_v3 ? ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION + : ADAPTER_CONFIGURATION_SCHEMA_VERSION, + payload, sizeof(payload)) == + ConfigurationStorageResult::kOk, "approved controller configuration did not persist"); configuration_service_prepare(); configuration_service_initialize_pre_usb(); ConfigurationServiceSnapshot snapshot{}; configuration_service_snapshot(&snapshot); - require(adapter_configuration_native_switch_approved( - snapshot.configuration, pro) && + require(snapshot.configuration.joycon_mode == configuration.joycon_mode && + adapter_configuration_native_switch_approved( + snapshot.configuration, pro) && adapter_configuration_native_switch_approved( snapshot.configuration, left), "pre-USB snapshot did not expose persisted approvals"); configuration_service_initialize_on_storage_core(); + if (migrate_v3) { + configuration_service_task_on_storage_core(0); + ConfigurationStorage migrated; + AdapterConfiguration decoded{}; + require(migrated.initialize(fake_io()) && + migrated.snapshot().schema_version == + ADAPTER_CONFIGURATION_SCHEMA_VERSION && + adapter_configuration_decode( + migrated.snapshot().schema_version, + migrated.snapshot().payload, + migrated.snapshot().payload_size, &decoded) && + decoded.joycon_mode == JoyConMode::kPaired && + decoded.pairing_window_seconds == 90 && + adapter_configuration_native_switch_approved(decoded, pro) && + adapter_configuration_native_switch_approved(decoded, left), + "v3 service migration changed settings or approvals"); + configuration.joycon_mode = JoyConMode::kIndividual; + queue_configuration(100, configuration); + configuration_service_snapshot(&snapshot); + require(snapshot.configuration.joycon_mode == JoyConMode::kPaired, + "player mode changed before durable commit"); + configuration_service_task_on_storage_core(1000); + configuration_service_snapshot(&snapshot); + require(snapshot.configuration.joycon_mode == JoyConMode::kIndividual, + "committed Individual mode was not published"); + } + const uint32_t start_ms = migrate_v3 ? 2000 : 0; const AdapterModeAvailability implemented{true, true, true, true}; require(configuration_service_set_mode( 1, AdapterRequestedMode::kSwitch, implemented) == ConfigurationTransactionStatus::kPending, "approved configuration blocked host mode selection"); - configuration_service_task_on_storage_core(0); + configuration_service_task_on_storage_core(start_ms); configuration_service_snapshot(&snapshot); require(snapshot.configuration.requested_mode == AdapterRequestedMode::kSwitch && + snapshot.configuration.joycon_mode == JoyConMode::kIndividual && adapter_configuration_native_switch_approved( snapshot.configuration, pro) && adapter_configuration_native_switch_approved( @@ -586,9 +638,10 @@ void test_native_switch_approval_preservation_and_revocation() { 0x80000002u, AdapterRequestedMode::kXInput, implemented) == ConfigurationTransactionStatus::kPending, "approved configuration blocked internal mode selection"); - configuration_service_task_on_storage_core(1000); + configuration_service_task_on_storage_core(start_ms + 1000); configuration_service_snapshot(&snapshot); require(snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput && + snapshot.configuration.joycon_mode == JoyConMode::kIndividual && adapter_configuration_native_switch_approved( snapshot.configuration, pro) && adapter_configuration_native_switch_approved( @@ -598,10 +651,11 @@ void test_native_switch_approval_preservation_and_revocation() { configuration = snapshot.configuration; configuration.pairing_window_seconds = 120; queue_configuration(3, configuration); - configuration_service_task_on_storage_core(2000); + configuration_service_task_on_storage_core(start_ms + 2000); configuration_service_snapshot(&snapshot); require(snapshot.configuration.pairing_window_seconds == 120 && snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput && + snapshot.configuration.joycon_mode == JoyConMode::kIndividual && adapter_configuration_native_switch_approved( snapshot.configuration, pro) && adapter_configuration_native_switch_approved( @@ -619,13 +673,14 @@ void test_native_switch_approval_preservation_and_revocation() { snapshot.configuration, pro), "approval was revoked before durable commit"); g_flash.fail_program = true; - configuration_service_task_on_storage_core(3000); + configuration_service_task_on_storage_core(start_ms + 3000); g_flash.fail_program = false; configuration_service_snapshot(&snapshot); require(snapshot.transaction.status == ConfigurationTransactionStatus::kStorageError && snapshot.generation == approved_generation && snapshot.payload_crc == approved_crc && + snapshot.configuration.joycon_mode == JoyConMode::kIndividual && adapter_configuration_native_switch_approved( snapshot.configuration, pro), "failed revocation changed the published durable configuration"); @@ -636,17 +691,19 @@ void test_native_switch_approval_preservation_and_revocation() { after_failure.snapshot().schema_version, after_failure.snapshot().payload, after_failure.snapshot().payload_size, &recovered) && + recovered.joycon_mode == JoyConMode::kIndividual && adapter_configuration_native_switch_approved(recovered, pro) && adapter_configuration_native_switch_approved(recovered, left), "interrupted revocation destroyed persisted approvals"); queue_configuration(5, configuration); - configuration_service_task_on_storage_core(3000); + configuration_service_task_on_storage_core(start_ms + 3000); configuration_service_snapshot(&snapshot); require(snapshot.transaction.status == ConfigurationTransactionStatus::kCommitted && snapshot.configuration.pairing_window_seconds == 120 && snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput && + snapshot.configuration.joycon_mode == JoyConMode::kIndividual && !adapter_configuration_native_switch_approved( snapshot.configuration, pro) && adapter_configuration_native_switch_approved( @@ -658,6 +715,7 @@ void test_native_switch_approval_preservation_and_revocation() { after_revocation.snapshot().schema_version, after_revocation.snapshot().payload, after_revocation.snapshot().payload_size, &recovered) && + recovered.joycon_mode == JoyConMode::kIndividual && !adapter_configuration_native_switch_approved(recovered, pro) && adapter_configuration_native_switch_approved(recovered, left), "controller-specific revocation did not survive power cycle"); @@ -669,9 +727,10 @@ void test_native_switch_approval_preservation_and_revocation() { require(adapter_configuration_native_switch_approved( snapshot.configuration, left), "reset erased approval before commit"); - configuration_service_task_on_storage_core(4000); + configuration_service_task_on_storage_core(start_ms + 4000); configuration_service_snapshot(&snapshot); require(snapshot.transaction.status == ConfigurationTransactionStatus::kCommitted && + snapshot.configuration.joycon_mode == JoyConMode::kPaired && !adapter_configuration_native_switch_approved( snapshot.configuration, left), "configuration reset did not revoke persisted approval"); @@ -685,7 +744,9 @@ int main(int argc, char** argv) { } else if (strcmp(argv[1], "v2-migration") == 0) { test_service_lifecycle_and_mutations(true); } else if (strcmp(argv[1], "native-approvals") == 0) { - test_native_switch_approval_preservation_and_revocation(); + test_native_switch_approval_preservation_and_revocation(false); + } else if (strcmp(argv[1], "v3-migration") == 0) { + test_native_switch_approval_preservation_and_revocation(true); } else { require(strcmp(argv[1], "abandoned-receive") == 0, "unknown service test scenario"); diff --git a/tests/configuration_storage_test.cpp b/tests/configuration_storage_test.cpp index a245221..2704bc3 100644 --- a/tests/configuration_storage_test.cpp +++ b/tests/configuration_storage_test.cpp @@ -90,22 +90,23 @@ void test_schema_encoding() { AdapterConfiguration configuration{}; configuration.pairing_window_seconds = 90; configuration.requested_mode = AdapterRequestedMode::kXInput; + configuration.joycon_mode = JoyConMode::kIndividual; uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; require(adapter_configuration_encode(configuration, payload, sizeof(payload)), - "valid v3 configuration did not encode"); + "valid v4 configuration did not encode"); const uint8_t expected[ADAPTER_CONFIGURATION_ENCODED_SIZE] = { 90, 0, static_cast(AdapterRequestedMode::kXInput), 0, - 0, + static_cast(JoyConMode::kIndividual), 0, 0, 0, }; require(memcmp(payload, expected, sizeof(expected)) == 0, - "v3 configuration bytes are not canonical"); + "v4 configuration bytes are not canonical"); AdapterConfiguration decoded{}; require(adapter_configuration_decode( @@ -113,8 +114,9 @@ void test_schema_encoding() { sizeof(payload), &decoded) && decoded.pairing_window_seconds == 90 && decoded.requested_mode == AdapterRequestedMode::kXInput && + decoded.joycon_mode == JoyConMode::kIndividual && decoded.native_switch_controller_count == 0, - "v3 configuration did not round trip"); + "v4 configuration did not round trip"); const AdapterRequestedMode valid_modes[] = { AdapterRequestedMode::kAuto, @@ -130,7 +132,8 @@ void test_schema_encoding() { adapter_configuration_decode( ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload, sizeof(payload), &decoded) && - decoded.requested_mode == mode, + decoded.requested_mode == mode && + decoded.joycon_mode == JoyConMode::kIndividual, "valid requested mode did not round trip"); } @@ -156,6 +159,7 @@ void test_schema_encoding() { sizeof(legacy), &decoded) && decoded.pairing_window_seconds == 120 && decoded.requested_mode == AdapterRequestedMode::kAuto && + decoded.joycon_mode == JoyConMode::kPaired && decoded.native_switch_controller_count == 0, "v1 configuration did not migrate without approvals"); uint8_t malformed_legacy[sizeof(legacy)]; @@ -178,6 +182,7 @@ void test_schema_encoding() { &decoded) && decoded.pairing_window_seconds == 120 && decoded.requested_mode == AdapterRequestedMode::kMac && + decoded.joycon_mode == JoyConMode::kPaired && decoded.native_switch_controller_count == 0, "v2 migration changed settings or granted native rumble"); for (size_t index = 3; index < sizeof(v2); ++index) { @@ -195,13 +200,16 @@ void test_schema_encoding() { "v2 record with wrong size was accepted"); for (size_t index = 3; index < sizeof(payload); ++index) { + if (index == 4) { + continue; + } uint8_t malformed[sizeof(payload)]; memcpy(malformed, payload, sizeof(payload)); malformed[index] = 1; require(!adapter_configuration_decode( ADAPTER_CONFIGURATION_SCHEMA_VERSION, malformed, sizeof(malformed), &decoded), - "v3 nonzero reserved or unused byte was accepted"); + "v4 nonzero reserved or unused byte was accepted"); } uint8_t invalid_mode[sizeof(payload)]; memcpy(invalid_mode, payload, sizeof(payload)); @@ -210,16 +218,33 @@ void test_schema_encoding() { ADAPTER_CONFIGURATION_SCHEMA_VERSION, invalid_mode, sizeof(invalid_mode), &decoded), "out-of-range requested mode was accepted"); + const uint8_t invalid_joycon_modes[] = {2, 0xff}; + for (uint8_t mode : invalid_joycon_modes) { + memcpy(invalid_mode, payload, sizeof(payload)); + invalid_mode[4] = mode; + require(!adapter_configuration_decode(invalid_mode, sizeof(invalid_mode), + &decoded), + "out-of-range Joy-Con2 mode was accepted"); + configuration.joycon_mode = static_cast(mode); + require(!adapter_configuration_encode(configuration, payload, + sizeof(payload)), + "out-of-range Joy-Con2 mode encoded"); + } + configuration.joycon_mode = JoyConMode::kPaired; + require(adapter_configuration_encode(configuration, payload, sizeof(payload)) && + adapter_configuration_decode(payload, sizeof(payload), &decoded) && + decoded.joycon_mode == JoyConMode::kPaired, + "Paired mode did not round trip"); require(!adapter_configuration_decode( ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload, sizeof(payload) - 1, &decoded), - "short v3 record was accepted"); + "short v4 record was accepted"); uint8_t oversized[ADAPTER_CONFIGURATION_ENCODED_SIZE + 1]{}; memcpy(oversized, payload, sizeof(payload)); require(!adapter_configuration_decode( ADAPTER_CONFIGURATION_SCHEMA_VERSION, oversized, sizeof(oversized), &decoded), - "oversized v3 record was accepted"); + "oversized v4 record was accepted"); configuration.pairing_window_seconds = 9; require(!adapter_configuration_encode(configuration, payload, @@ -234,7 +259,7 @@ void test_schema_encoding() { configuration.requested_mode = AdapterRequestedMode::kAuto; require(!adapter_configuration_encode(configuration, oversized, sizeof(oversized)), - "v3 encoder accepted a noncanonical output size"); + "v4 encoder accepted a noncanonical output size"); } void test_native_switch_approval_identity_and_canonical_encoding() { @@ -262,6 +287,25 @@ void test_native_switch_approval_identity_and_canonical_encoding() { decoded, nintendo_identity(3, 0x2006)), "approval did not remain specific to the complete controller identity"); + decoded.joycon_mode = JoyConMode::kIndividual; + require(adapter_configuration_decode( + ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION, payload, + sizeof(payload), &decoded) && + decoded.joycon_mode == JoyConMode::kPaired && + adapter_configuration_native_switch_approved(decoded, pro) && + adapter_configuration_native_switch_approved(decoded, left) && + adapter_configuration_native_switch_approved(decoded, right), + "v3 migration erased approvals or retained a stale player mode"); + for (size_t index = 4; index < ADAPTER_CONFIGURATION_HEADER_SIZE; ++index) { + uint8_t malformed[sizeof(payload)]; + memcpy(malformed, payload, sizeof(payload)); + malformed[index] = 1; + require(!adapter_configuration_decode( + ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION, malformed, + sizeof(malformed), &decoded), + "v3 reserved header byte was accepted"); + } + configuration.native_switch_controllers[0] = right; configuration.native_switch_controllers[1] = pro; configuration.native_switch_controllers[2] = left; @@ -368,16 +412,27 @@ void test_legacy_migration_power_loss() { const uint16_t versions[] = { ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION, ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION, + ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION, }; for (uint16_t version : versions) { - const bool v2 = version == ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION; - const uint8_t legacy[] = { - 90, 0, static_cast(v2 ? AdapterRequestedMode::kXInput - : AdapterRequestedMode::kAuto), - 0, 0, 0, 0, 0, - }; - const size_t legacy_size = v2 ? ADAPTER_CONFIGURATION_V2_ENCODED_SIZE - : ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE; + const bool v1 = version == ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION; + const bool v3 = version == ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION; + AdapterConfiguration source{}; + source.pairing_window_seconds = 90; + source.requested_mode = v1 ? AdapterRequestedMode::kAuto + : AdapterRequestedMode::kXInput; + if (v3) { + source.native_switch_controller_count = 2; + source.native_switch_controllers[0] = nintendo_identity(1); + source.native_switch_controllers[1] = nintendo_identity(2, 0x2006); + } + uint8_t legacy[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; + require(adapter_configuration_encode(source, legacy, sizeof(legacy)), + "legacy migration fixture did not encode"); + const size_t legacy_size = + v3 ? ADAPTER_CONFIGURATION_ENCODED_SIZE + : (v1 ? ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE + : ADAPTER_CONFIGURATION_V2_ENCODED_SIZE); FakeFlash flash; ConfigurationStorage store; require(store.initialize(fake_io(&flash)) && @@ -390,7 +445,7 @@ void test_legacy_migration_power_loss() { &migrated) && adapter_configuration_encode(migrated, payload, sizeof(payload)), - "legacy settings did not convert to schema3"); + "legacy settings did not convert to schema4"); flash.fail_after_programs = flash.successful_programs; require(store.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload, sizeof(payload)) == @@ -418,8 +473,15 @@ void test_legacy_migration_power_loss() { rebooted.snapshot().payload_size, &decoded) && decoded.pairing_window_seconds == 90 && decoded.requested_mode == migrated.requested_mode && - decoded.native_switch_controller_count == 0, - "migration retry changed settings or granted rumble approval"); + decoded.joycon_mode == JoyConMode::kPaired && + decoded.native_switch_controller_count == + source.native_switch_controller_count && + (!v3 || + (adapter_configuration_native_switch_approved( + decoded, source.native_switch_controllers[0]) && + adapter_configuration_native_switch_approved( + decoded, source.native_switch_controllers[1]))), + "migration retry changed settings or controller approvals"); } } @@ -553,18 +615,33 @@ void test_transaction_validation() { transaction.begin( 14, ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION, ADAPTER_CONFIGURATION_V2_ENCODED_SIZE, 0) == + ConfigurationTransactionStatus::kUnsupportedSchema && + transaction.begin( + 15, ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION, + ADAPTER_CONFIGURATION_ENCODED_SIZE, 0) == ConfigurationTransactionStatus::kUnsupportedSchema, - "legacy host writes could silently erase stored approvals"); + "legacy host writes could silently erase stored settings"); payload[3] = ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY + 1; require(transaction.begin( - 15, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload), + 16, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload), configuration_crc32(payload, sizeof(payload))) == ConfigurationTransactionStatus::kReceiving && - transaction.append(15, 0, payload, sizeof(payload)) == + transaction.append(16, 0, payload, sizeof(payload)) == ConfigurationTransactionStatus::kReceiving && - transaction.finish(15) == + transaction.finish(16) == ConfigurationTransactionStatus::kMalformed, "valid CRC allowed a malformed approval list to reach storage"); + payload[3] = 0; + payload[4] = 2; + require(transaction.begin( + 17, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload), + configuration_crc32(payload, sizeof(payload))) == + ConfigurationTransactionStatus::kReceiving && + transaction.append(17, 0, payload, sizeof(payload)) == + ConfigurationTransactionStatus::kReceiving && + transaction.finish(17) == + ConfigurationTransactionStatus::kMalformed, + "valid CRC allowed an invalid Joy-Con2 mode to reach storage"); } } // namespace diff --git a/tests/controller_profile_legacy_fixtures.h b/tests/controller_profile_legacy_fixtures.h index 3cc9cd7..83318ea 100644 --- a/tests/controller_profile_legacy_fixtures.h +++ b/tests/controller_profile_legacy_fixtures.h @@ -61,3 +61,32 @@ constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_LEGACY_ENCODE 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, }; + +// Schema6 fixture with analog tuning, all macro playback modes, shortcuts, +// Shift, and sparse Turbo overrides. Independent of the current encoder. +constexpr uint8_t kLegacySchema6Profile[384] = { + 0x06, 0x00, 0x80, 0x01, 0x11, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0x0c, 0x0d, 0x0e, 0xff, 0x7b, 0x00, 0x38, 0xfe, 0xe8, 0x03, 0x30, 0x75, 0x80, 0x01, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xeb, 0xfc, 0x41, 0x01, 0xd0, 0x07, 0x18, 0x79, 0xc0, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xe8, 0x03, 0x60, 0xea, 0x80, 0x01, 0x34, 0x12, 0x00, 0x00, 0xd0, 0x07, + 0xe8, 0xfd, 0xc0, 0x00, 0xcd, 0xab, 0x10, 0x00, 0x7b, 0xd2, 0x01, 0x21, 0x41, 0x00, 0x80, 0x00, + 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, + 0x01, 0x00, 0x41, 0x00, 0x01, 0x11, 0x02, 0x00, 0x46, 0x11, 0x01, 0x11, 0x04, 0x00, 0x24, 0x22, + 0x01, 0x11, 0x08, 0x00, 0x7c, 0x33, 0x01, 0x11, 0x1f, 0x19, 0x00, 0x01, 0x00, 0xfe, 0xff, 0x03, + 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1a, 0x00, 0x02, 0x00, 0xfe, 0xff, + 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1b, 0x00, 0x04, 0x00, 0xfe, + 0xff, 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1c, 0x00, 0x08, 0x00, + 0xfe, 0xff, 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x10, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02, 0x11, 0xff, 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x1e, 0x63, 0xff, 0x04, 0x80, + 0x01, 0x01, 0x01, 0x17, 0x25, 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0x01, 0xfe, 0x02, 0xfd, 0x03, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; diff --git a/tests/controller_profile_runtime_test.cpp b/tests/controller_profile_runtime_test.cpp index f105b94..ba3597d 100644 --- a/tests/controller_profile_runtime_test.cpp +++ b/tests/controller_profile_runtime_test.cpp @@ -1160,6 +1160,61 @@ void test_shortcut_selector_rollover_without_modifier_release() { "swapping shortcut selectors while holding the modifier lost the new action"); } +void test_extra_hotkeys_consume_mappings_and_rearm() { + prepare_profiles(); + auto& profile = rows[0].profiles[0]; + profile.switching_chord = (1u << 18) | (1u << 24); + profile.motion_toggle_chord = (1u << 19) | (1u << 16); + profile.shortcuts.modifier = 20; + profile.shortcuts.selectors[7] = 0; + profile.extra_button_map[0] = 3; + profile.extra_button_map[1] = 1; + profile.extra_button_map[2] = 2; + profile.extra_button_map[6] = 17; + Bluepad32SlotSnapshot snapshot = make_snapshot(0, 9); + (void)runtime_transform(0, snapshot, 0); + snapshot.state.extra_buttons = 1; + auto output = runtime_transform(0, snapshot, 1); + require(activation_attempt_count == 0 && output.state.button_north, + "partial extra switching chord was swallowed or activated"); + snapshot.state.extra_buttons = 0x41; + output = runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 1 && + activation_attempts[0].profile_index == 1 && + controller_profile_extract_button_mask(output.state) == 0 && + output.state.right_trigger == 0 && output.state.extra_buttons == 0, + "extra switching chord did not select the next profile and consume mappings"); + (void)runtime_transform(0, snapshot, 3); + require(activation_attempt_count == 1, "held extra switching chord retriggered"); + snapshot.state = controller_neutral_state(); + (void)runtime_transform(0, snapshot, 4); + snapshot.state.extra_buttons = 2; + snapshot.state.left_trigger = UINT16_MAX; + output = runtime_transform(0, snapshot, 5); + require(motion_toggle_count == 1 && last_motion_toggle_slot == 0 && + last_motion_toggle_connection_generation == 9 && + !output.state.button_east && output.state.left_trigger == 0, + "extra/trigger motion chord lost its logical slot or leaked mapped inputs"); + (void)runtime_transform(0, snapshot, 6); + require(motion_toggle_count == 1, "held extra motion chord retriggered"); + snapshot.state = controller_neutral_state(); + (void)runtime_transform(0, snapshot, 7); + apply_button_mask(1, &snapshot); + snapshot.state.extra_buttons = 4; + output = runtime_transform(0, snapshot, 8); + require(activation_attempt_count == 2 && + activation_attempts[1].profile_index == 7 && + controller_profile_extract_button_mask(output.state) == 0, + "extra shortcut modifier failed to select profile eight or leaked inputs"); + apply_button_mask(0, &snapshot); + (void)runtime_transform(0, snapshot, 9); + snapshot.state.extra_buttons = 0x41; + (void)runtime_transform(0, snapshot, 10); + require(activation_attempt_count == 3 && + activation_attempts[2].profile_index == 1, + "released extra switching chord did not rearm"); +} + } // namespace bool bluepad32_input_backend_toggle_motion( @@ -1232,5 +1287,6 @@ int main() { test_shift_slot_and_context_resets(); test_held_synthetic_sources_and_disconnect_rearming(); test_shortcut_selector_rollover_without_modifier_release(); + test_extra_hotkeys_consume_mappings_and_rearm(); return 0; } diff --git a/tests/controller_profile_test.cpp b/tests/controller_profile_test.cpp index ee0a187..90897f6 100644 --- a/tests/controller_profile_test.cpp +++ b/tests/controller_profile_test.cpp @@ -39,15 +39,93 @@ bool read_encoded_database(void*, size_t offset, uint8_t* output, return true; } +void test_pair_identity_wire_and_member_validation() { + ControllerIdentity left = identity(1); + left.transport = ControllerTransport::kBle; + left.product_id = 0x2067; + const uint8_t left_address[6] = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15}; + memcpy(left.address, left_address, sizeof(left_address)); + ControllerIdentity right = left; + right.product_id = 0x2066; + right.address_type = 1; + right.address[0] = 0xc0; + ControllerIdentity pair{}; + uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{}; + const uint8_t expected[14] = { + 5, 3, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, + 0xc0, 0x11, 0x12, 0x13, 0x14, 0x15}; + require(controller_identity_make_joycon_pair(left, right, &pair) && + controller_identity_encode(pair, encoded, sizeof(encoded)) && + memcmp(encoded, expected, sizeof(expected)) == 0, + "pair identity did not encode both typed members"); + ControllerIdentity decoded{}; + ControllerIdentity decoded_left{}; + ControllerIdentity decoded_right{}; + require(controller_identity_decode(encoded, sizeof(encoded), &decoded) && + controller_identity_equal(pair, decoded) && + controller_identity_joycon_pair_members( + decoded, &decoded_left, &decoded_right) && + controller_identity_equal(left, decoded_left) && + controller_identity_equal(right, decoded_right), + "pair identity round trip lost a member or its model"); + decoded.partner_address[5] ^= 1; + require(!controller_identity_equal(pair, decoded), + "pair equality ignored the right address"); + for (uint8_t flags = 0; flags < 16; ++flags) { + if (flags == 1 || flags == 5) { + continue; + } + memcpy(encoded, expected, sizeof(encoded)); + encoded[0] = flags; + require(!controller_identity_decode(encoded, sizeof(encoded), &decoded), + "pair decoder accepted unstable, reserved, or nonstatic flags"); + } + require(!controller_identity_make_joycon_pair(right, left, &decoded), + "pair helper accepted reversed models"); + right.product_id = 0x2069; + require(!controller_identity_make_joycon_pair(left, right, &decoded), + "pair helper accepted a non-Joy-Con member"); + right.product_id = 0x2066; + right.address_type = 2; + require(!controller_identity_make_joycon_pair(left, right, &decoded), + "pair helper accepted a noncanonical identity address type"); + right.address_type = left.address_type; + memcpy(right.address, left.address, sizeof(right.address)); + require(!controller_identity_make_joycon_pair(left, right, &decoded), + "pair helper accepted one physical typed address twice"); + left.address[0] = right.address[0] = 0xc0; + right.address_type = 1; + require(controller_identity_make_joycon_pair(left, right, &decoded), + "pair helper conflated public and static-random address namespaces"); + left.partner_address[0] = 1; + require(!controller_identity_encode(left, encoded, sizeof(encoded)) && + !controller_identity_make_joycon_pair(left, right, &decoded), + "ordinary member accepted stray partner fields"); + left.partner_address[0] = 0; + const uint8_t ordinary[14] = { + 1, 2, 0, 0, 0xc0, 0x11, 0x12, 0x13, 0x14, 0x15, + 0x7e, 0x05, 0x67, 0x20}; + require(controller_identity_encode(left, encoded, sizeof(encoded)) && + memcmp(encoded, ordinary, sizeof(encoded)) == 0 && + controller_identity_decode(encoded, sizeof(encoded), &decoded) && + controller_identity_equal(left, decoded), + "ordinary identity wire encoding changed"); + const uint8_t global[14]{}; + require(controller_identity_encode(controller_identity_global(), encoded, + sizeof(encoded)) && + memcmp(encoded, global, sizeof(encoded)) == 0, + "global identity wire encoding changed"); +} + void test_profile_wire_schema() { const ControllerProfile profile = controller_profile_default(controller_identity_global(), 0); uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; require(controller_profile_encode(profile, encoded, sizeof(encoded)), "default profile did not encode"); - require(encoded[0] == 6 && encoded[1] == 0 && + require(encoded[0] == 7 && encoded[1] == 0 && encoded[2] == 0x80 && encoded[3] == 1, - "profile header is not little-endian v6/384"); + "profile header is not little-endian v7/384"); for (uint8_t index = 0; index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { require(encoded[4 + index] == index, @@ -135,7 +213,7 @@ void test_profile_wire_schema() { "nonzero reserved profile byte was accepted"); ControllerProfile invalid = profile; - invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT; + invalid.button_map[0] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; require(!controller_profile_validate(invalid), "invalid direct output was accepted"); invalid = profile; @@ -225,7 +303,7 @@ void test_legacy_profile_migration() { CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) && encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL, - "migrated default profile did not encode as v6"); + "migrated default profile did not encode as the current schema"); require(controller_profile_decode( kLegacyNarrowRawRangeProfile, @@ -300,7 +378,7 @@ void test_legacy_profile_migration() { static_cast( ControllerProfileLogicalButton::kCapture) && migrated.motion_toggle_chord == 0, - "v2 profile controls did not migrate to v6"); + "v2 profile controls did not migrate to the current schema"); previous_encoded[80] = 2; previous_encoded[100] = 0; previous_encoded[101] = kControllerProfileOverrideButtons; @@ -508,12 +586,110 @@ void test_legacy_database_strides() { } } +void test_schema6_migration_preserves_every_setting() { + ControllerProfile profile{}; + uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_decode(kLegacySchema6Profile, + sizeof(kLegacySchema6Profile), &profile) && + controller_profile_encode(profile, upgraded, sizeof(upgraded)), + "schema6 profile could not upgrade"); + require(memcmp(upgraded + 2, kLegacySchema6Profile + 2, 342) == 0, + "schema6 migration changed an existing encoded setting"); + for (uint8_t index = 0; index < CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT; ++index) { + require(profile.extra_button_map[index] == CONTROLLER_PROFILE_NO_BUTTON && + profile.shift.extra_button_map[index] == CONTROLLER_PROFILE_NO_BUTTON, + "schema6 padding became an extra-button mapping"); + } + ControllerProfile reloaded{}; + uint8_t round_trip[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_decode(upgraded, sizeof(upgraded), &reloaded) && + controller_profile_encode(reloaded, round_trip, sizeof(round_trip)) && + memcmp(upgraded, round_trip, sizeof(upgraded)) == 0, + "upgraded schema6 profile was not stable after reload"); + + const uint16_t extra_field_offsets[] = {344, 351, 358, 362, 363}; + for (uint16_t offset : extra_field_offsets) { + uint8_t invalid[sizeof(kLegacySchema6Profile)]{}; + memcpy(invalid, kLegacySchema6Profile, sizeof(invalid)); + invalid[offset] = 1; + require(!controller_profile_decode(invalid, sizeof(invalid), &reloaded), + "schema6 interpreted an extra-control field in reserved padding"); + } + const uint16_t source_offsets[] = {256, 266}; + for (uint16_t offset : source_offsets) { + uint8_t invalid[sizeof(kLegacySchema6Profile)]{}; + memcpy(invalid, kLegacySchema6Profile, sizeof(invalid)); + invalid[offset] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; + require(!controller_profile_decode(invalid, sizeof(invalid), &reloaded), + "schema6 admitted an extra-control modifier"); + } + uint8_t invalid[sizeof(kLegacySchema6Profile)]{}; + memcpy(invalid, kLegacySchema6Profile, sizeof(invalid)); + invalid[98] = (CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL << 2) | 1; + require(!controller_profile_decode(invalid, sizeof(invalid), &reloaded), + "schema6 admitted an extra-control macro cancellation"); +} + +void test_extra_control_schema_round_trip_and_output_limits() { + ControllerProfile profile{}; + require(controller_profile_decode(kLegacySchema6Profile, + sizeof(kLegacySchema6Profile), &profile), + "extra-control fixture did not decode"); + const uint8_t extra_map[] = {0, 16, 17, 12, 13, 14, 15}; + const uint8_t shifted_map[] = {15, 14, 13, 12, 3, 2, 0xff}; + memcpy(profile.extra_button_map, extra_map, sizeof(extra_map)); + memcpy(profile.shift.extra_button_map, shifted_map, sizeof(shifted_map)); + profile.shortcuts.modifier = 18; + profile.shift.modifier = 24; + profile.switching_chord |= 0x55u << 18; + profile.motion_toggle_chord |= 0x2au << 18; + for (uint8_t index = 0; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) { + profile.macros[index].trigger_mask |= (1u << index) << 18; + profile.macros[index].cancel_control = 24 - index; + } + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + ControllerProfile decoded{}; + require(controller_profile_encode(profile, encoded, sizeof(encoded)) && + controller_profile_decode(encoded, sizeof(encoded), &decoded), + "extra-control profile did not round-trip"); + require(memcmp(encoded + 344, extra_map, sizeof(extra_map)) == 0 && + memcmp(encoded + 351, shifted_map, sizeof(shifted_map)) == 0 && + encoded[358] == 1 && encoded[359] == 2 && + encoded[360] == 4 && encoded[361] == 8 && + encoded[362] == 0x55 && encoded[363] == 0x2a, + "extra-control fields do not use the schema7 extension layout"); + uint8_t round_trip[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(decoded, round_trip, sizeof(round_trip)) && + memcmp(encoded, round_trip, sizeof(encoded)) == 0, + "extra controls lost masks, mappings, or modifiers on reload"); + for (uint16_t offset = 358; offset < 364; ++offset) { + encoded[offset] |= 0x80; + require(!controller_profile_decode(encoded, sizeof(encoded), &decoded), + "out-of-range extra-control mask was accepted"); + encoded[offset] &= 0x7f; + } + profile.extra_button_map[0] = 18; + require(!controller_profile_validate(profile), + "extra input was accepted as a console output destination"); + profile.extra_button_map[0] = 0; + profile.triggers[0].output = 18; + require(!controller_profile_validate(profile), + "analog trigger was allowed to route into a source-only control"); + profile.triggers[0].output = 0; + profile.shift.extra_button_map[0] = 16; + require(!controller_profile_validate(profile), + "Shift extra mapping admitted an analog destination"); +} + } // namespace int main() { + test_pair_identity_wire_and_member_validation(); test_profile_wire_schema(); test_legacy_profile_migration(); test_database_round_trip_and_capacity(); test_set_b_sparse_extension_and_migration(); test_legacy_database_strides(); + test_schema6_migration_preserves_every_setting(); + test_extra_control_schema_round_trip_and_output_limits(); return 0; } diff --git a/tests/controller_profile_transform_test.cpp b/tests/controller_profile_transform_test.cpp index 5f012e5..7607070 100644 --- a/tests/controller_profile_transform_test.cpp +++ b/tests/controller_profile_transform_test.cpp @@ -36,6 +36,7 @@ bool states_equal(const ControllerState& left, const ControllerState& right) { left.button_capture == right.button_capture && left.button_left_stick == right.button_left_stick && left.button_right_stick == right.button_right_stick && + left.extra_buttons == right.extra_buttons && left.left_trigger == right.left_trigger && left.right_trigger == right.right_trigger && left.left_stick_x == right.left_stick_x && @@ -109,7 +110,7 @@ void test_button_masks_and_direct_mapping() { "disabled button mapping still produced output"); ControllerProfile invalid = default_profile(); - invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT; + invalid.button_map[0] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; require(!controller_profile_validate(invalid), "logical output 18 was accepted"); invalid.button_map[0] = 0xfe; @@ -432,6 +433,48 @@ void test_rumble_scaling_and_confirmation_policy() { "confirmation policy was not exposed unchanged"); } +void test_extra_sources_route_without_creating_output_channels() { + ControllerProfile profile = default_profile(); + ControllerState input{}; + input.extra_buttons = 0x7f; + require(states_equal( + controller_profile_transform(input, profile).state, + controller_neutral_state()), + "unmapped extra inputs leaked into console output"); + require(controller_profile_extract_control_mask(input, profile) == + (0x7fu << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL), + "extra inputs were omitted or overlapped the trigger controls"); + profile.extra_button_map[0] = 0; + profile.extra_button_map[1] = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + profile.extra_button_map[2] = CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL; + profile.extra_button_map[3] = 12; + profile.extra_button_map[4] = 13; + profile.extra_button_map[5] = 14; + profile.extra_button_map[6] = 15; + input.button_south = true; + input.left_trigger = 12345; + auto output = controller_profile_transform(input, profile); + require(controller_profile_extract_button_mask(output.state) == 0xf001 && + output.state.left_trigger == UINT16_MAX && + output.state.right_trigger == UINT16_MAX && + output.state.extra_buttons == 0, + "extra mappings lost rail buttons, trigger output, or shared contributors"); + controller_profile_remove_control_mask( + (1u << 18) | (1u << 20) | (1u << 24) | (1u << 16), &input); + output = controller_profile_transform(input, profile); + require(controller_profile_extract_button_mask(output.state) == 0x7001 && + output.state.left_trigger == UINT16_MAX && + output.state.right_trigger == 0 && + input.extra_buttons == 0x3a && input.left_trigger == 0, + "consumed extra sources leaked, removed a shared button, or lost another trigger source"); + input.extra_buttons = 0x80; + input.button_south = false; + require(controller_profile_extract_control_mask(input, profile) == 0 && + states_equal(controller_profile_transform(input, profile).state, + controller_neutral_state()), + "reserved extra bit became a control or output"); +} + } // namespace int main() { @@ -440,6 +483,7 @@ int main() { test_stick_curves_and_monotonicity(); test_trigger_boundaries_curves_and_thresholds(); test_trigger_and_button_cross_mapping(); + test_extra_sources_route_without_creating_output_channels(); test_default_whole_state_equivalence(); test_rumble_scaling_and_confirmation_policy(); return 0; diff --git a/tests/controller_synthetic_input_test.cpp b/tests/controller_synthetic_input_test.cpp index 352c6ee..81b6bae 100644 --- a/tests/controller_synthetic_input_test.cpp +++ b/tests/controller_synthetic_input_test.cpp @@ -738,6 +738,81 @@ void test_macro_playback_modes_and_bounded_cycle_skips() { "configured cancel did not kill looping macro output"); } +void test_extra_shift_and_macro_sources_are_consumed() { + ControllerProfile profile = + controller_profile_default(controller_identity_global(), 0); + profile.shift.mode = ControllerProfileShiftMode::kHold; + profile.shift.modifier = 18; + profile.extra_button_map[0] = 3; + profile.shift.extra_button_map[0] = 3; + profile.extra_button_map[1] = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + profile.shift.extra_button_map[1] = 1; + ControllerSyntheticInputContext context{}; + ControllerState input{}; + input.extra_buttons = 3; + auto output = controller_synthetic_input_apply(&context, input, profile, 0); + require(output.state.button_east && !output.state.button_north && + output.state.left_trigger == 0 && output.state.extra_buttons == 0, + "extra Shift modifier leaked or failed to select the extra mapping layer"); + input.extra_buttons = 2; + output = controller_synthetic_input_apply(&context, input, profile, 1); + require(!output.state.button_east && output.state.left_trigger == UINT16_MAX, + "extra hold-Shift release did not restore the base mapping"); + profile.shift.mode = ControllerProfileShiftMode::kToggle; + context = {}; + input.extra_buttons = 3; + (void)controller_synthetic_input_apply(&context, input, profile, 2); + input.extra_buttons = 2; + output = controller_synthetic_input_apply(&context, input, profile, 3); + require(output.state.button_east && output.state.left_trigger == 0, + "extra toggle-Shift did not latch after modifier release"); + input.extra_buttons = 3; + output = controller_synthetic_input_apply(&context, input, profile, 4); + require(!output.state.button_east && !output.state.button_north && + output.state.left_trigger == UINT16_MAX, + "inactive toggle layer leaked its extra modifier"); + + profile.shift.mode = ControllerProfileShiftMode::kOff; + profile.macros[0] = {(1u << 19) | (1u << 24), 20, 0, 1, + ControllerProfileMacroMode::kWhileHeld, 1}; + profile.macro_step_count = 1; + profile.macro_steps[0] = {kControllerProfileOverrideRightTrigger, 100, + 0, 0, 0, 0, 0, 0, 45000}; + for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) { + profile.macros[index].first_step = 1; + } + profile.extra_button_map[2] = 3; + profile.extra_button_map[6] = 0; + context = {}; + input.extra_buttons = 2; + output = controller_synthetic_input_apply(&context, input, profile, 5); + require(output.state.left_trigger == UINT16_MAX && output.state.right_trigger == 0, + "partial extra macro chord activated"); + input.extra_buttons = 0x42; + output = controller_synthetic_input_apply(&context, input, profile, 6); + require(output.state.left_trigger == 0 && output.state.right_trigger == 45000 && + !output.state.button_south && output.state.extra_buttons == 0, + "extra macro trigger chord leaked its mapped outputs"); + input.extra_buttons = 0x46; + output = controller_synthetic_input_apply(&context, input, profile, 7); + require(output.state.right_trigger == 0 && !output.state.button_north, + "extra macro cancellation did not win or leaked its mapped output"); + input.extra_buttons = 0x42; + output = controller_synthetic_input_apply(&context, input, profile, 8); + require(output.state.right_trigger == 0, + "cancel release retriggered an already-held extra macro chord"); + input.extra_buttons = 0; + (void)controller_synthetic_input_apply(&context, input, profile, 9); + input.extra_buttons = 0x42; + output = controller_synthetic_input_apply(&context, input, profile, 10); + require(output.state.right_trigger == 45000, + "extra macro trigger did not rearm after release"); + input.extra_buttons = 2; + output = controller_synthetic_input_apply(&context, input, profile, 11); + require(output.state.right_trigger == 0, + "while-held macro survived release of an extra trigger source"); +} + } // namespace int main() { @@ -753,5 +828,6 @@ int main() { test_parameterized_turbo_and_finite_burst(); test_shift_maps_consumption_and_physical_bindings(); test_macro_playback_modes_and_bounded_cycle_skips(); + test_extra_shift_and_macro_sources_are_consumed(); return 0; } diff --git a/tests/profile_playtest_test.js b/tests/profile_playtest_test.js index dd7bc49..e861f36 100644 --- a/tests/profile_playtest_test.js +++ b/tests/profile_playtest_test.js @@ -63,3 +63,53 @@ assert.ok(triggerPercent(65000) > 99, "nonzero trigger input must produce a visible bar"); assert.equal(triggerPercent(0), 0); assert.equal(triggerPercent(65535), 100); + +const { transformMappings } = globalThis.ProfilePlaytestMath; +const buttons = [ + "south", "east", "west", "north", "left_shoulder", "right_shoulder", + "select", "start", "system", "capture", "left_stick", "right_stick", + "dpad_up", "dpad_down", "dpad_left", "dpad_right", +]; +const extras = ["c", "gl", "gr", "left_sl", "left_sr", "right_sl", "right_sr"]; +const profile = { + button_map: Object.fromEntries(buttons.map((button) => [button, button])), + extra_button_map: Object.fromEntries(extras.map((button) => [button, null])), + shift: { + mode: "off", modifier: null, + button_map: Object.fromEntries(buttons.map((button) => [button, button])), + extra_button_map: Object.fromEntries(extras.map((button) => [button, null])), + }, + triggers: { + left: { ...defaultTrigger, output: "left_trigger", digital_threshold: 22934 }, + right: { ...defaultTrigger, output: "right_trigger", digital_threshold: 22934 }, + }, +}; +const sample = { buttons: ["south"], extra_buttons: extras, triggers: { left: 123, right: 50000 } }; +assert.deepEqual(transformMappings(sample, profile), { + buttons: ["south"], triggers: { left: 123, right: 50000 }, +}); +for (const [index, extra] of extras.entries()) { + profile.extra_button_map[extra] = buttons[index + 1]; +} +assert.deepEqual(transformMappings(sample, profile).buttons, buttons.slice(0, 8)); +profile.extra_button_map.c = "left_trigger"; +profile.extra_button_map.gl = "south"; +assert.deepEqual(transformMappings(sample, profile), { + buttons: ["south", ...buttons.slice(3, 8)], + triggers: { left: 65535, right: 50000 }, +}); +profile.shift.mode = "hold"; +profile.shift.modifier = "c"; +profile.shift.extra_button_map.gr = "system"; +assert.deepEqual(transformMappings(sample, profile, true), { + buttons: ["south", "system"], triggers: { left: 123, right: 50000 }, +}); +assert.equal(transformMappings(sample, profile, false).triggers.left, 123, + "the Shift modifier is consumed even on the base layer"); +assert.deepEqual(transformMappings({ ...sample, extra_buttons: [] }, profile), { + buttons: ["south"], triggers: { left: 123, right: 50000 }, +}); +profile.shift.mode = "off"; +profile.triggers.right.output = "north"; +assert.deepEqual(transformMappings({ ...sample, buttons: [], extra_buttons: [], triggers: { left: 0, right: 22933 } }, profile).buttons, []); +assert.deepEqual(transformMappings({ ...sample, buttons: [], extra_buttons: [], triggers: { left: 0, right: 22934 } }, profile).buttons, ["north"]); diff --git a/tests/profile_service_test.cpp b/tests/profile_service_test.cpp index 304b196..4174fa0 100644 --- a/tests/profile_service_test.cpp +++ b/tests/profile_service_test.cpp @@ -12,9 +12,13 @@ namespace { struct FakeFlash { uint8_t bytes[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE]; + bool fail_program_once = false; }; FakeFlash flash{}; +bool observe_pair_during_program = false; +ControllerIdentity pending_pair{}; +ControllerIdentity pending_left{}; void require(bool condition, const char *message) { if (!condition) { @@ -53,6 +57,20 @@ bool fake_program(void *context, uint8_t arena, size_t offset, offset + size > PROFILE_STORAGE_ARENA_SIZE) { return false; } + if (observe_pair_during_program) { + ProfileServiceActiveProfileSnapshot pair_snapshot{}; + ProfileServiceActiveProfileSnapshot left_snapshot{}; + profile_service_active_profile_snapshot(pending_pair, &pair_snapshot); + profile_service_active_profile_snapshot(pending_left, &left_snapshot); + require(!pair_snapshot.valid && left_snapshot.valid && + left_snapshot.profile_index == 7 && + left_snapshot.profile.weak_rumble_scale == 27, + "pair seed exposed incomplete profiles while writing flash"); + } + if (storage->fail_program_once) { + storage->fail_program_once = false; + return false; + } for (size_t index = 0; index < size; ++index) { storage->bytes[arena][offset + index] &= page[index]; } @@ -277,7 +295,7 @@ void test_profile_bounds_and_transaction_namespace() { "transaction namespaces were not enforced"); } -void test_schema6_validation_and_atomic_selection() { +void test_schema7_validation_and_atomic_selection() { const ControllerIdentity id = stable_identity(); require(profile_service_select(id, 6) == ConfigurationTransactionStatus::kCommitted, @@ -285,10 +303,13 @@ void test_schema6_validation_and_atomic_selection() { ProfileServiceSelectedSnapshot old_selection{}; profile_service_selected_snapshot(&old_selection); ControllerProfile updated = old_selection.profile; - updated.shortcuts.modifier = 5; + updated.shortcuts.modifier = 18; updated.shortcuts.selectors[6] = 14; updated.turbo_defaults = {30, 1, 255}; - updated.macros[0].trigger_mask = 1u << 10; + updated.extra_button_map[6] = 16; + updated.shift.extra_button_map[0] = 3; + updated.macros[0].trigger_mask = 1u << 24; + updated.macros[0].cancel_control = 19; updated.macros[0].step_count = 1; updated.macros[0].mode = ControllerProfileMacroMode::kRepeat; updated.macros[0].repeat_count = 255; @@ -299,25 +320,25 @@ void test_schema6_validation_and_atomic_selection() { uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; require(controller_profile_encode(updated, encoded, sizeof(encoded)), "extended service profile did not encode"); - require(profile_service_begin(20, id, 6, 5, 256, 0) == + require(profile_service_begin(20, id, 6, 6, 384, 0) == ConfigurationTransactionStatus::kUnsupportedSchema && - profile_service_begin(21, id, 6, 6, 256, 0) == + profile_service_begin(21, id, 6, 7, 256, 0) == ConfigurationTransactionStatus::kMalformed && - profile_service_begin(22, id, 6, 6, 385, 0) == + profile_service_begin(22, id, 6, 7, 385, 0) == ConfigurationTransactionStatus::kTooLarge, "service admitted old-schema or incorrectly-sized writes"); // A valid transport CRC cannot authorize an invalid extension. encoded[283] = 0; require(profile_service_begin( - 23, id, 6, 6, sizeof(encoded), + 23, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), profile_storage_crc32(encoded, sizeof(encoded))) == ConfigurationTransactionStatus::kReceiving && profile_service_append(23, 0, encoded, sizeof(encoded)) == ConfigurationTransactionStatus::kReceiving && profile_service_commit(23) == ConfigurationTransactionStatus::kMalformed, - "service admitted invalid schema6 turbo settings"); + "service admitted invalid turbo settings"); ProfileServiceSelectedSnapshot selected{}; profile_service_selected_snapshot(&selected); require(selected.valid && @@ -329,7 +350,7 @@ void test_schema6_validation_and_atomic_selection() { require(controller_profile_encode(updated, encoded, sizeof(encoded)), "valid replacement did not encode"); require(profile_service_begin( - 24, id, 6, 6, sizeof(encoded), + 24, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), profile_storage_crc32(encoded, sizeof(encoded))) == ConfigurationTransactionStatus::kReceiving && profile_service_append(24, 0, encoded, 256) == @@ -338,7 +359,7 @@ void test_schema6_validation_and_atomic_selection() { ConfigurationTransactionStatus::kReceiving && profile_service_commit(24) == ConfigurationTransactionStatus::kPending, - "service did not receive both parts of the schema6 payload"); + "service did not receive both parts of the profile payload"); profile_service_selected_snapshot(&selected); require(selected.metadata.generation == old_selection.metadata.generation && active_snapshot(id).profile.turbo_defaults.rate_hz == @@ -350,8 +371,12 @@ void test_schema6_validation_and_atomic_selection() { ConfigurationTransactionStatus::kCommitted && selected.valid && selected.profile.shortcuts.selectors[6] == 14 && selected.profile.macros[0].repeat_count == 255 && + selected.profile.extra_button_map[6] == 16 && + selected.profile.shift.extra_button_map[0] == 3 && + active_snapshot(id).profile.macros[0].trigger_mask == (1u << 24) && + active_snapshot(id).profile.macros[0].cancel_control == 19 && active_snapshot(id).profile.turbo_defaults.rate_hz == 30, - "committed schema6 profile did not atomically refresh snapshots"); + "committed schema7 profile did not atomically refresh snapshots"); } void test_catalog1_selected_and_active_snapshots_migrate() { @@ -419,6 +444,123 @@ void test_catalog1_selected_and_active_snapshots_migrate() { "old selection/activation snapshots lost migrated content or defaults"); } +void test_pair_publication_failure_recovery_and_independence() { + memset(flash.bytes, 0xff, sizeof(flash.bytes)); + ControllerIdentity left = stable_identity(); + left.transport = ControllerTransport::kBle; + left.vendor_id = 0x057e; + left.product_id = 0x2067; + ControllerIdentity right = left; + right.product_id = 0x2066; + right.address[5] = 8; + ControllerIdentity pair{}; + require(controller_identity_make_joycon_pair(left, right, &pair), + "service pair identity is invalid"); + ProfileStorage setup; + require(setup.initialize(fake_io()), "service pair fixture did not initialize"); + for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) { + ControllerProfile profile = controller_profile_default(left, slot); + profile.weak_rumble_scale = 20 + slot; + const char name[2] = {'L', static_cast('0' + slot)}; + require(setup.set(left, slot, profile) == ProfileStorageResult::kOk && + setup.set_profile_name(left, slot, name, sizeof(name)) == + ProfileStorageResult::kOk, + "service solo fixture did not persist"); + } + require(setup.activate(left, 7) == ProfileStorageResult::kOk && + setup.set_alias(left, "Solo", 4) == ProfileStorageResult::kOk, + "service solo activation did not persist"); + profile_service_initialize_on_storage_core(); + require(!active_snapshot(pair).valid && + !profile_service_observe_identity_on_storage_core(pair) && + profile_service_select(pair, 0) == ConfigurationTransactionStatus::kMalformed && + profile_service_activate(100, pair, 0) == + ConfigurationTransactionStatus::kMalformed && + profile_service_activate_internal(0x80000001u, pair, 0) == + ConfigurationTransactionStatus::kMalformed && + profile_service_reset(101, pair, CONTROLLER_PROFILE_ALL) == + ConfigurationTransactionStatus::kMalformed && + profile_service_set_metadata(102, pair, 7, "Premature", 9) == + ConfigurationTransactionStatus::kMalformed && + profile_service_begin(103, pair, 0, CONTROLLER_PROFILE_SCHEMA_VERSION, + CONTROLLER_PROFILE_ENCODED_SIZE, 0) == + ConfigurationTransactionStatus::kMalformed, + "service admitted an unseeded pair or supplied its fallback profile"); + ProfileServiceListSnapshot before{}; + profile_service_list_snapshot(&before); + pending_pair = pair; + pending_left = left; + observe_pair_during_program = true; + flash.fail_program_once = true; + require(!profile_service_observe_joycon_pair_on_storage_core(pair), + "failed seed published a ready pair"); + observe_pair_during_program = false; + ProfileServiceListSnapshot failed{}; + profile_service_list_snapshot(&failed); + const auto solo_after_failure = active_snapshot(left); + require(failed.count == before.count && + failed.metadata.state == ProfileServiceState::kStorageError && + failed.metadata.generation == before.metadata.generation && + !active_snapshot(pair).valid && solo_after_failure.valid && + solo_after_failure.profile_index == 7 && + solo_after_failure.profile.weak_rumble_scale == 27 && + !profile_service_observe_joycon_pair_on_storage_core(pair), + "rejected pair replaced the last committed solo publication"); + profile_service_initialize_on_storage_core(); + observe_pair_during_program = true; + require(profile_service_observe_joycon_pair_on_storage_core(pair), + "pair seed did not retry after recovery"); + observe_pair_during_program = false; + ProfileServiceListSnapshot seeded{}; + profile_service_list_snapshot(&seeded); + require(seeded.count == before.count + 2 && + seeded.metadata.state == ProfileServiceState::kReady && + active_snapshot(pair).valid && + active_snapshot(pair).profile_index == 7 && + active_snapshot(pair).profile.weak_rumble_scale == 27, + "seeded pair list and active profile were not published together"); + require(profile_service_select(pair, 7) == + ConfigurationTransactionStatus::kCommitted, + "seeded pair could not be selected"); + ProfileServiceMetadataSnapshot metadata{}; + profile_service_metadata_snapshot(&metadata); + require(metadata.valid && metadata.alias[0] == '\0' && + strcmp(metadata.profile_names[7], "L7") == 0, + "service did not expose independent seeded metadata"); + auto profile = controller_profile_default(pair, 7); + profile.weak_rumble_scale = 81; + write_profile(104, pair, 7, profile, 9000); + require(active_snapshot(pair).profile.weak_rumble_scale == 81 && + active_snapshot(left).profile.weak_rumble_scale == 27 && + profile_service_activate(105, pair, 3) == + ConfigurationTransactionStatus::kPending, + "pair edit changed the solo active profile"); + profile_service_task_on_storage_core(10000); + require(active_snapshot(pair).profile_index == 3 && + active_snapshot(left).profile_index == 7, + "pair activation changed the solo selection"); + require(profile_service_set_metadata(106, pair, 7, "Pair", 4) == + ConfigurationTransactionStatus::kPending, + "pair metadata edit did not queue"); + profile_service_task_on_storage_core(11000); + require(profile_service_observe_joycon_pair_on_storage_core(pair), + "existing pair reconnect failed"); + profile_service_metadata_snapshot(&metadata); + require(metadata.valid && strcmp(metadata.profile_names[7], "Pair") == 0 && + profile_service_select(left, 7) == + ConfigurationTransactionStatus::kCommitted, + "pair reconnect overwrote pair-owned metadata"); + profile_service_metadata_snapshot(&metadata); + require(metadata.valid && strcmp(metadata.profile_names[7], "L7") == 0 && + strcmp(metadata.alias, "Solo") == 0, + "pair metadata edit changed solo metadata"); + profile_service_initialize_on_storage_core(); + require(profile_service_observe_joycon_pair_on_storage_core(pair) && + active_snapshot(pair).profile_index == 3 && + active_snapshot(left).profile_index == 7, + "service reload reseeded the pair active selection"); +} + } // namespace ProfileStorageIo pico_profile_storage_io() { return fake_io(); } @@ -426,8 +568,9 @@ ProfileStorageIo pico_profile_storage_io() { return fake_io(); } int main() { test_eight_profile_transactions_and_active_cache(); test_profile_bounds_and_transaction_namespace(); - test_schema6_validation_and_atomic_selection(); + test_schema7_validation_and_atomic_selection(); test_catalog1_selected_and_active_snapshots_migrate(); + test_pair_publication_failure_recovery_and_independence(); std::cout << "profile service tests passed\n"; return 0; } diff --git a/tests/profile_storage_test.cpp b/tests/profile_storage_test.cpp index 4e01713..c8a6045 100644 --- a/tests/profile_storage_test.cpp +++ b/tests/profile_storage_test.cpp @@ -20,6 +20,7 @@ struct FakeFlash { uint8_t corrupt_arena = 0; size_t corrupt_offset = 0; bool corrupt_next_program = false; + int fail_read_after_programs = -1; }; FakeFlash flash{}; @@ -45,6 +46,10 @@ bool fake_read(void *context, uint8_t arena, size_t offset, uint8_t *output, size > PROFILE_STORAGE_ARENA_SIZE - offset) { return false; } + if (storage->fail_read_after_programs >= 0 && + storage->programs >= storage->fail_read_after_programs) { + return false; + } memcpy(output, &storage->bytes[arena][offset], size); return true; } @@ -116,6 +121,22 @@ ControllerIdentity identity(uint8_t suffix) { return result; } +ControllerIdentity joycon(uint8_t suffix, bool left) { + ControllerIdentity result = identity(suffix); + result.transport = ControllerTransport::kBle; + result.vendor_id = 0x057e; + result.product_id = left ? 0x2067 : 0x2066; + return result; +} + +ControllerIdentity joycon_pair(const ControllerIdentity &left, + const ControllerIdentity &right) { + ControllerIdentity pair{}; + require(controller_identity_make_joycon_pair(left, right, &pair), + "pair fixture identity is invalid"); + return pair; +} + void write_u16(uint8_t *output, uint16_t value) { output[0] = static_cast(value); output[1] = static_cast(value >> 8); @@ -259,7 +280,9 @@ void install_catalog_record(uint16_t version, size_t offset, uint8_t type, record[7] = slot; write_u32(record + 8, generation); write_u16(record + 12, static_cast(size)); - write_u16(record + 14, type == 1 ? (version == 1 ? 5 : 6) : 0); + write_u16(record + 14, type == 1 + ? static_cast(payload[0] | (payload[1] << 8)) + : 0); write_u32(record + 16, profile_storage_crc32(payload, size)); require(controller_identity_encode(identity_value, record + 20, CONTROLLER_IDENTITY_ENCODED_SIZE), @@ -277,13 +300,16 @@ void install_populated_catalog(uint16_t version, bool fill_arena = false) { index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) { const ControllerIdentity id = catalog_identity(index); for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) { - const ControllerProfile profile = catalog_profile(index, slot, version == 2); + const ControllerProfile profile = catalog_profile(index, slot, version != 1); uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; if (version == 1) { encode_schema5(profile, payload); } else { require(controller_profile_encode(profile, payload, sizeof(payload)), "catalog2 fixture profile did not encode"); + // Keep catalog2 fixtures on the deployed schema6 wire format. + write_u16(payload, CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION); + memset(payload + 344, 0, sizeof(payload) - 344); } install_catalog_record( version, offset, 1, id, slot, ++generation, payload, @@ -494,6 +520,8 @@ void test_compaction_preserves_latest_records() { storage.set_profile_name(global, 0, "Compacted", 9) == ProfileStorageResult::kOk, "compaction metadata did not append"); + profile.extra_button_map[0] = 16; + profile.shift.extra_button_map[6] = 15; for (uint16_t write = 1; write <= 260; ++write) { profile.weak_rumble_scale = static_cast(write); require(storage.set(global, 0, profile) == ProfileStorageResult::kOk, @@ -508,6 +536,8 @@ void test_compaction_preserves_latest_records() { reloaded.get(global, 0, &recovered) == ProfileStorageResult::kOk && recovered.weak_rumble_scale == static_cast(260) && + recovered.extra_button_map[0] == 16 && + recovered.shift.extra_button_map[6] == 15 && reloaded.get_alias(global, metadata, sizeof(metadata)) == ProfileStorageResult::kOk && strcmp(metadata, "Fallback") == 0 && @@ -549,14 +579,14 @@ void test_legacy_migration_is_atomic_and_complete() { void test_populated_catalog_publication_power_loss() { static FakeFlash baseline; - for (uint16_t version = 1; version <= 2; ++version) { + for (uint16_t version = 1; version <= 3; ++version) { erase_all(); install_populated_catalog(version, true); baseline = flash; ProfileStorage completed; require(completed.initialize(fake_io()), "populated catalog did not load"); - require_populated_catalog(completed, version == 2); - if (version == 2) { + require_populated_catalog(completed, version != 1); + if (version == 3) { require(completed.set_alias(catalog_identity(0), "New", 3) == ProfileStorageResult::kOk, "populated catalog could not compact all 187 live records"); @@ -569,7 +599,7 @@ void test_populated_catalog_publication_power_loss() { flash = baseline; flash.torn_page_bytes = torn_bytes; ProfileStorage interrupted; - if (version == 2) { + if (version == 3) { require(interrupted.initialize(fake_io()), "catalog2 power-loss baseline did not load"); } @@ -578,9 +608,9 @@ void test_populated_catalog_publication_power_loss() { } else { flash.fail_after_programs = cut; } - if (version == 1) { + if (version != 3) { require(!interrupted.initialize(fake_io()), - "incomplete catalog1 migration silently initialized empty"); + "incomplete old catalog migration silently initialized empty"); } else { require(interrupted.set_alias(catalog_identity(0), "New", 3) == ProfileStorageResult::kIoError, @@ -595,7 +625,7 @@ void test_populated_catalog_publication_power_loss() { ProfileStorage recovered; require(recovered.initialize(fake_io()), "catalog did not recover after an interrupted publication"); - require_populated_catalog(recovered, version == 2); + require_populated_catalog(recovered, version != 1); require(recovered.snapshot().generation >= 1248, "catalog publication regressed the stored generation"); } @@ -727,6 +757,262 @@ void test_legacy_high_generation_remains_mutable() { "high-generation update was lost after reload"); } +void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() { + erase_all(); + install_populated_catalog(3); + ProfileStorage storage; + require(storage.initialize(fake_io()), "schema6 catalog did not load"); + require_populated_catalog(storage, true); + require(flash.programs == 0 && flash.erases == 0, + "reading schema6 profiles rewrote the published flash arena"); + const auto id = catalog_identity(7); + ControllerProfile profile{}; + uint8_t before[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(storage.get(id, 7, &profile) == ProfileStorageResult::kOk && + controller_profile_encode(profile, before, sizeof(before)) && + storage.set(id, 7, profile) == ProfileStorageResult::kUnchanged && + flash.programs == 0 && flash.erases == 0, + "unchanged migrated profile caused an unnecessary flash write"); + profile.extra_button_map[0] = 16; + profile.extra_button_map[6] = 15; + profile.shift.extra_button_map[1] = 2; + require(storage.set(id, 7, profile) == ProfileStorageResult::kOk, + "schema6 profile could not add extra-control mappings"); + ProfileStorage reloaded; + ControllerProfile recovered{}; + uint8_t after[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(reloaded.initialize(fake_io()) && + reloaded.get(id, 7, &recovered) == ProfileStorageResult::kOk && + controller_profile_encode(recovered, after, sizeof(after)) && + memcmp(before + 2, after + 2, 342) == 0 && + recovered.extra_button_map[0] == 16 && + recovered.extra_button_map[6] == 15 && + recovered.shift.extra_button_map[1] == 2, + "editing migrated extras lost existing settings or the new mappings"); + char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + require(reloaded.find(id)->active_profile == 7 && + reloaded.get_alias(id, metadata, sizeof(metadata)) == + ProfileStorageResult::kOk && + strcmp(metadata, "H") == 0 && + reloaded.get_profile_name(id, 7, metadata, sizeof(metadata)) == + ProfileStorageResult::kOk && + strcmp(metadata, "H7") == 0, + "editing a schema6 profile lost its active index, alias, or name"); +} + +void require_pair_bank(const ProfileStorage &storage, + const ControllerIdentity &owner, uint8_t base, + uint8_t active, char name_prefix) { + const auto *entry = storage.find(owner); + require(entry != nullptr && entry->active_profile == active, + "pair bank active selection changed"); + for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) { + ControllerProfile actual{}; + char name[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + require(storage.get(owner, slot, &actual) == ProfileStorageResult::kOk && + actual.weak_rumble_scale == base + slot && + storage.get_profile_name(owner, slot, name, sizeof(name)) == + ProfileStorageResult::kOk && + name[0] == name_prefix && name[1] == '0' + slot && + name[2] == '\0', + "pair bank profile or name bled into another owner"); + } +} + +void write_pair_bank(ProfileStorage &storage, const ControllerIdentity &owner, + uint8_t base, char name_prefix) { + for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) { + ControllerProfile profile = controller_profile_default(owner, slot); + profile.weak_rumble_scale = base + slot; + const char name[2] = {name_prefix, static_cast('0' + slot)}; + require(storage.set(owner, slot, profile) == ProfileStorageResult::kOk && + storage.set_profile_name(owner, slot, name, sizeof(name)) == + ProfileStorageResult::kOk, + "pair bank edit failed"); + } +} + +void test_pair_seed_independence_reconnect_and_compaction() { + erase_all(); + const auto left = joycon(1, true); + const auto right = joycon(2, false); + const auto pair = joycon_pair(left, right); + ProfileStorage storage; + require(storage.initialize(fake_io()), "pair catalog did not initialize"); + write_pair_bank(storage, left, 20, 'L'); + write_pair_bank(storage, right, 40, 'R'); + require(storage.set_alias(left, "Solo", 4) == ProfileStorageResult::kOk && + storage.activate(left, 7) == ProfileStorageResult::kOk && + storage.activate(right, 3) == ProfileStorageResult::kOk, + "solo bank setup failed"); + const int programs = flash.programs; + const int erases = flash.erases; + require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kOk && + flash.programs == programs + 2 && flash.erases == erases, + "first pair did not seed with one atomic append"); + require_pair_bank(storage, pair, 20, 7, 'L'); + char alias[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + require(storage.get_alias(pair, alias, sizeof(alias)) == + ProfileStorageResult::kOk && alias[0] == '\0' && + storage.get_alias(left, alias, sizeof(alias)) == + ProfileStorageResult::kOk && strcmp(alias, "Solo") == 0, + "pair inherited or changed the solo alias"); + write_pair_bank(storage, left, 60, 'S'); + require(storage.activate(left, 1) == ProfileStorageResult::kOk, + "left selection edit failed"); + ProfileStorage replayed; + require(replayed.initialize(fake_io()), "pair seed did not replay"); + require_pair_bank(replayed, pair, 20, 7, 'L'); + require_pair_bank(replayed, left, 60, 1, 'S'); + require_pair_bank(replayed, right, 40, 3, 'R'); + write_pair_bank(replayed, pair, 80, 'P'); + require(replayed.activate(pair, 6) == ProfileStorageResult::kOk && + replayed.set_alias(pair, "Together", 8) == ProfileStorageResult::kOk, + "pair selection or alias edit failed"); + const uint32_t generation = replayed.snapshot().generation; + require(replayed.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged && + replayed.snapshot().generation == generation, + "pair reconnect reseeded or wrote the bank"); + const auto other_pair = joycon_pair(left, joycon(3, false)); + require(replayed.ensure_joycon_pair(other_pair) == ProfileStorageResult::kOk, + "different right member did not receive a separate bank"); + require_pair_bank(replayed, other_pair, 60, 1, 'S'); + require_pair_bank(replayed, pair, 80, 6, 'P'); + require_pair_bank(replayed, left, 60, 1, 'S'); + require_pair_bank(replayed, right, 40, 3, 'R'); + const uint8_t old_arena = replayed.snapshot().active_bank; + for (size_t write = 0; write <= PROFILE_STORAGE_RECORD_CAPACITY; ++write) { + require(replayed.activate(left, (write + 2) % CONTROLLER_PROFILE_COUNT) == + ProfileStorageResult::kOk, + "pair compaction trigger failed"); + } + require(replayed.snapshot().active_bank != old_arena, + "pair catalog never compacted"); + ProfileStorage compacted; + require(compacted.initialize(fake_io()) && + compacted.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged, + "materialized pair did not reconnect after compaction"); + require_pair_bank(compacted, pair, 80, 6, 'P'); + require_pair_bank(compacted, other_pair, 60, 1, 'S'); + require_pair_bank(compacted, right, 40, 3, 'R'); + require(compacted.get_alias(pair, alias, sizeof(alias)) == + ProfileStorageResult::kOk && strcmp(alias, "Together") == 0, + "compaction lost pair-owned alias"); + require(compacted.reset(pair, CONTROLLER_PROFILE_ALL) == + ProfileStorageResult::kOk, + "pair reset failed"); + require_pair_bank(compacted, other_pair, 60, 1, 'S'); + require_pair_bank(compacted, right, 40, 3, 'R'); +} + +void test_pair_capacity_and_unseeded_mutations() { + erase_all(); + const auto left = joycon(1, true); + const auto right = joycon(2, false); + const auto pair = joycon_pair(left, right); + ProfileStorage storage; + require(storage.initialize(fake_io()), "pair capacity catalog did not initialize"); + ControllerProfile profile = controller_profile_default(pair, 0); + profile.weak_rumble_scale = 42; + require(storage.ensure_identity(pair) == ProfileStorageResult::kInvalidArgument && + storage.set(pair, 0, profile) == ProfileStorageResult::kInvalidArgument && + storage.activate(pair, 7) == ProfileStorageResult::kInvalidArgument && + storage.set_alias(pair, "Pair", 4) == ProfileStorageResult::kInvalidArgument && + storage.set_profile_name(pair, 7, "Pair", 4) == + ProfileStorageResult::kInvalidArgument && + storage.reset(pair, CONTROLLER_PROFILE_ALL) == + ProfileStorageResult::kInvalidArgument && + storage.find(pair) == nullptr, + "mutation implicitly created an unseeded pair"); + for (uint8_t index = 1; index <= 14; ++index) { + require(storage.ensure_identity(identity(index)) == ProfileStorageResult::kOk, + "capacity setup failed"); + } + const int programs = flash.programs; + require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kFull && + storage.find(left) == nullptr && storage.find(right) == nullptr && + storage.find(pair) == nullptr && flash.programs == programs, + "capacity preflight changed owners before rejecting the pair"); + erase_all(); + require(storage.initialize(fake_io()), "exact-fit catalog did not initialize"); + for (uint8_t index = 1; index <= 13; ++index) { + require(storage.ensure_identity(identity(index)) == ProfileStorageResult::kOk, + "exact-fit setup failed"); + } + require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kOk && + storage.identity_count() == CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1 && + storage.find(left) != nullptr && storage.find(right) != nullptr && + storage.get(pair, 7, &profile) == ProfileStorageResult::kOk && + profile.weak_rumble_scale == + controller_profile_default(left, 7).weak_rumble_scale && + storage.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged, + "exact-capacity pair did not create both members and the default bank"); +} + +void test_pair_seed_interruption_and_ambiguous_readback() { + erase_all(); + const auto left = joycon(1, true); + const auto right = joycon(2, false); + const auto pair = joycon_pair(left, right); + ProfileStorage initial; + require(initial.initialize(fake_io()), "seed fault catalog did not initialize"); + write_pair_bank(initial, left, 20, 'L'); + require(initial.activate(left, 7) == ProfileStorageResult::kOk, + "seed fault active setup failed"); + static FakeFlash baseline; + baseline = flash; + // Right row and seed each use two pages. Exercise every cut, including a + // fully committed header whose program call nevertheless reports failure. + for (size_t torn_bytes : {size_t{0}, size_t{64}, size_t{256}}) { + for (int cut = 0; cut < 4; ++cut) { + flash = baseline; + ProfileStorage interrupted; + require(interrupted.initialize(fake_io()), "seed fault baseline did not load"); + flash.fail_after_programs = flash.programs + cut; + flash.torn_page_bytes = torn_bytes; + require(interrupted.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError && + interrupted.find(pair) == nullptr, + "interrupted seed exposed a partially initialized bank"); + flash.fail_after_programs = -1; + require(interrupted.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError && + interrupted.activate(left, 0) == ProfileStorageResult::kIoError, + "ambiguous seed allowed retry or source mutation before replay"); + flash.torn_page_bytes = 0; + ProfileStorage recovered; + require(recovered.initialize(fake_io()), "interrupted seed did not recover"); + const auto result = recovered.ensure_joycon_pair(pair); + require(result == ProfileStorageResult::kOk || + result == ProfileStorageResult::kUnchanged, + "seed retry failed after durable replay"); + require_pair_bank(recovered, pair, 20, 7, 'L'); + require_pair_bank(recovered, left, 20, 7, 'L'); + } + } + flash = baseline; + ProfileStorage ambiguous; + require(ambiguous.initialize(fake_io()) && + ambiguous.ensure_identity(right) == ProfileStorageResult::kOk, + "readback fault setup failed"); + flash.fail_read_after_programs = flash.programs + 2; + require(ambiguous.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError && + ambiguous.find(pair) == nullptr, + "unacknowledged seed was published in memory"); + ProfileStorage unreadable; + require(!unreadable.initialize(fake_io()), "unreadable seed replay failed open"); + flash.fail_read_after_programs = -1; + require(ambiguous.activate(left, 0) == ProfileStorageResult::kIoError, + "unacknowledged seed did not freeze source changes"); + ProfileStorage recovered; + require(recovered.initialize(fake_io()) && + recovered.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged, + "committed unacknowledged seed was reseeded"); + require_pair_bank(recovered, pair, 20, 7, 'L'); + write_pair_bank(recovered, left, 60, 'S'); + require(recovered.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged, + "recovered pair was reseeded after source edits"); + require_pair_bank(recovered, pair, 20, 7, 'L'); +} + } // namespace int main() { @@ -742,6 +1028,10 @@ int main() { test_retired_bank_migration_power_loss(); test_late_second_page_program_is_not_reused(); test_unreadable_legacy_data_is_not_erased(); + test_schema6_read_migration_is_lazy_and_edit_preserves_metadata(); + test_pair_seed_independence_reconnect_and_compaction(); + test_pair_capacity_and_unseeded_mutations(); + test_pair_seed_interruption_and_ambiguous_readback(); std::cout << "profile storage tests passed\n"; return 0; } diff --git a/tests/switch2_haptics_test.c b/tests/switch2_haptics_test.c new file mode 100644 index 0000000..a7e9972 --- /dev/null +++ b/tests/switch2_haptics_test.c @@ -0,0 +1,70 @@ +#include +#include +#include "parser/uni_switch2_haptics.h" + +static uint64_t unpack(const uint8_t sample[5]) { + uint64_t result = 0; + for (unsigned byte = 0; byte < 5; ++byte) result |= (uint64_t)sample[byte] << (byte * 8); + return result; +} + +int main(void) { + uni_switch2_haptics_frame_t frame; + uni_switch2_haptics_silence(&frame); + assert(uni_switch2_haptics_valid(&frame)); + assert(uni_switch2_haptics_is_stop(&frame)); + uint64_t sample = unpack(frame.sides[0].samples[0]); + assert((sample & 1023) == 385); // Measured160Hz anchor. + assert(((sample >> 20) & 1023) == 481); // Measured320Hz anchor. + assert(((sample >> 10) & 1023) == 0 && ((sample >> 30) & 1023) == 0); + + uni_switch2_haptics_encode_sample(frame.sides[0].samples[0], 96, 96, 32767, 16384); + sample = unpack(frame.sides[0].samples[0]); + assert((sample & 1023) == 481); + assert(((sample >> 20) & 1023) == 577); // Upper frequency bit is frequency, not tone mode. + assert(((sample >> 10) & 1023) == 453 && ((sample >> 30) & 1023) == 226); + assert(!uni_switch2_haptics_is_stop(&frame)); + + frame.sides[0].count = 3; + uni_switch2_haptics_encode_sample(frame.sides[0].samples[1], 32, 64, 8192, 0); + uni_switch2_haptics_encode_sample(frame.sides[0].samples[2], 64, 127, 0, 32767); + uint8_t block[16]; + assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 0xab)); + assert(block[0] == 0x7b); + assert((unpack(block + 1) & 1023) == 481); + assert((unpack(block + 6) & 1023) == 289); + assert(((unpack(block + 6) >> 10) & 1023) == 113); + assert(((unpack(block + 11) >> 20) & 1023) == 670); + assert(((unpack(block + 11) >> 10) & 1023) == 0); + assert(((unpack(block + 11) >> 30) & 1023) == 453); + + frame.sides[0].count = 2; + assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 15)); + assert(block[0] == 0x6f); + for (unsigned i = 11; i < 16; ++i) assert(block[i] == 0); + frame.sides[0].count = 1; + assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 16)); + assert(block[0] == 0x50); + for (unsigned i = 6; i < 16; ++i) assert(block[i] == 0); + + uni_switch2_haptics_encode_sample(frame.sides[0].samples[0], 0, 255, 65535, 65535); + sample = unpack(frame.sides[0].samples[0]); + assert((sample & 1023) == 196 && ((sample >> 20) & 1023) == 670); + assert(((sample >> 10) & 1023) == 453 && ((sample >> 30) & 1023) == 453); + assert(uni_switch2_haptics_valid(&frame)); + // A malformed internal native frame cannot bypass the amplitude envelope. + frame.sides[0].samples[0][2] |= 0x0f; + assert(!uni_switch2_haptics_valid(&frame)); + + uni_switch2_haptics_silence(&frame); + frame.sides[1].count = 0; + assert(uni_switch2_haptics_valid(&frame)); + assert(!uni_switch2_haptics_is_stop(&frame)); // A side-only mute must not clear its partner. + frame.sides[0].count = 0; + assert(!uni_switch2_haptics_valid(&frame)); + assert(!uni_switch2_haptics_write_block(block, &frame.sides[0], 0)); + frame.sides[0].count = 4; + assert(!uni_switch2_haptics_valid(&frame)); + assert(!uni_switch2_haptics_write_block(block, &frame.sides[0], 0)); + return 0; +} diff --git a/tests/switch2_pairing_native_stubs/btstack_tlv.h b/tests/switch2_pairing_native_stubs/btstack_tlv.h new file mode 100644 index 0000000..fd57a65 --- /dev/null +++ b/tests/switch2_pairing_native_stubs/btstack_tlv.h @@ -0,0 +1,8 @@ +#pragma once +#include +typedef struct { + int (*get_tag)(void*, uint32_t, uint8_t*, uint32_t); + int (*store_tag)(void*, uint32_t, const uint8_t*, uint32_t); + void (*delete_tag)(void*, uint32_t); +} btstack_tlv_t; +void btstack_tlv_get_instance(const btstack_tlv_t**, void**); diff --git a/tests/switch2_pairing_test.c b/tests/switch2_pairing_test.c new file mode 100644 index 0000000..04a028d --- /dev/null +++ b/tests/switch2_pairing_test.c @@ -0,0 +1,90 @@ +#include +#include +#include +#include +#include +#include "parser/uni_switch2_pairing.h" + +static struct { + uint32_t tag; + uint8_t data[256]; + uint32_t size; + bool fail_write; +} storage; + +static int get_tag(void* context, uint32_t tag, uint8_t* data, uint32_t capacity) { + (void)context; + if (tag != storage.tag) return 0; + uint32_t copied = storage.size < capacity ? storage.size : capacity; + memcpy(data, storage.data, copied); + return (int)storage.size; +} +static int store_tag(void* context, uint32_t tag, const uint8_t* data, uint32_t size) { + (void)context; + if (storage.fail_write) return -1; + assert(size <= sizeof(storage.data)); + storage.tag = tag; + memcpy(storage.data, data, size); + storage.size = size; + return 0; +} +static const btstack_tlv_t tlv = {get_tag, store_tag, NULL}; +void btstack_tlv_get_instance(const btstack_tlv_t** implementation, void** context) { + *implementation = &tlv; + *context = &storage; +} + +int main(void) { + uint8_t public_address[6] = {0x10, 2, 3, 4, 5, 6}; + uint8_t static_address[6] = {0xc1, 2, 3, 4, 5, 6}; + uint8_t private_address[6] = {0x41, 2, 3, 4, 5, 6}; + assert(!uni_switch2_pairing_known(0, public_address)); + assert(!uni_switch2_pairing_remember(1, private_address)); + assert(!uni_switch2_pairing_remember(2, public_address)); + assert(uni_switch2_pairing_remember(1, static_address)); + assert(uni_switch2_pairing_remember(0, public_address)); + assert(uni_switch2_pairing_known(0, public_address)); + assert(!uni_switch2_pairing_known(1, public_address)); + assert(uni_switch2_pairing_known(1, static_address)); + + // Duplicate authorization remains valid without requiring another flash write. + storage.fail_write = true; + assert(uni_switch2_pairing_remember(0, public_address)); + assert(!uni_switch2_pairing_clear()); + assert(uni_switch2_pairing_known(0, public_address)); + public_address[5]++; + assert(!uni_switch2_pairing_remember(0, public_address)); + assert(!uni_switch2_pairing_known(0, public_address)); + public_address[5]--; + storage.fail_write = false; + + uint8_t type, observed[6]; + assert(uni_switch2_pairing_get(0, &type, observed)); + assert(type == 0 && memcmp(observed, public_address, 6) == 0); + assert(uni_switch2_pairing_get(1, &type, observed)); + assert(type == 1 && memcmp(observed, static_address, 6) == 0); + assert(!uni_switch2_pairing_get(2, &type, observed)); + assert(uni_switch2_pairing_clear()); + assert(!uni_switch2_pairing_known(0, public_address)); + assert(!uni_switch2_pairing_known(1, static_address)); + + // Filling the bounded store never silently forgets an earlier controller. + for (uint8_t i = 0; i < UNI_SWITCH2_PAIRING_CAPACITY; ++i) { + public_address[5] = i; + assert(uni_switch2_pairing_remember(0, public_address)); + } + public_address[5] = UNI_SWITCH2_PAIRING_CAPACITY; + assert(!uni_switch2_pairing_remember(0, public_address)); + public_address[5] = 0; + assert(uni_switch2_pairing_known(0, public_address)); + + // Corrupt persisted data is not an authorization and can be explicitly cleared. + storage.data[1] = 255; + assert(!uni_switch2_pairing_known(0, public_address)); + assert(!uni_switch2_pairing_remember(0, public_address)); + assert(uni_switch2_pairing_clear()); + assert(uni_switch2_pairing_remember(0, public_address)); + storage.size++; + assert(!uni_switch2_pairing_known(0, public_address)); + return 0; +} diff --git a/tests/switch2_parser_native_stubs/protocol_fixture.h b/tests/switch2_parser_native_stubs/protocol_fixture.h new file mode 100644 index 0000000..e4f9d2d --- /dev/null +++ b/tests/switch2_parser_native_stubs/protocol_fixture.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include "uni_hid_device.h" + +// The parser uses the SDK's actual GATT types/accessors. Only the asynchronous +// radio, run loop, platform admission and persistence endpoints are simulated. +enum fixture_query { QUERY_NONE, QUERY_SERVICE, QUERY_CHARACTERISTICS, QUERY_DESCRIPTORS, QUERY_CCCD, QUERY_WRITE }; +#define FIXTURE_RUMBLE_HISTORY 128 +struct fixture_peer { + uni_hid_device_t device; + bool used, link_alive; + enum fixture_query query; + btstack_packet_handler_t callback; + uint16_t descriptor_value, cccd_handle; + uint8_t command[32]; + uint16_t command_length; + const uint8_t* pending_write; + uint16_t pending_length; + uint8_t pending_snapshot[33]; + uint8_t rumble[33]; + uint16_t rumble_length; + unsigned commands, rumbles; + uint8_t rumble_history[FIXTURE_RUMBLE_HISTORY][33]; + uint32_t rumble_times[FIXTURE_RUMBLE_HISTORY]; +}; diff --git a/tests/switch2_parser_native_test.c b/tests/switch2_parser_native_test.c new file mode 100644 index 0000000..764fffe --- /dev/null +++ b/tests/switch2_parser_native_test.c @@ -0,0 +1,1252 @@ +#include +#include +#include + +#include "protocol_fixture.h" +#include "parser/uni_hid_parser_switch2.h" +#include "parser/uni_switch2_pairing.h" +#include "sdkconfig.h" + +#define PEERS CONFIG_BLUEPAD32_MAX_DEVICES +#define SERVICE_START 0x100 +#define INPUT_HANDLE 0x104 +#define RESPONSE_HANDLE 0x114 +#define COMMAND_HANDLE 0x124 +#define RUMBLE_HANDLE 0x134 + +static struct fixture_peer peers[PEERS]; +static btstack_timer_source_t* timers[16]; +static unsigned timer_count, connected, ready, disconnected, emitted, remembered, listeners; +static unsigned connected_events, disconnected_events; +static unsigned scan_stops, discovery_calls; +static uint16_t expected_discovery_pid; +static uint8_t expected_discovery_address_type; +static uint32_t now_ms; +static bool pairing_allowed, trusted, storage_ok, request_writes, admit, reject_connected; +static uint8_t next_write_error; +static uint8_t next_connect_error; +static bool scan_enabled, scan_running; +static const bd_addr_t host_address = {0x10, 0x21, 0x32, 0x43, 0x54, 0x65}; +static const bd_addr_t controller_address = {0xc0, 0x22, 0x33, 0x44, 0x55, 0x66}; +static const uint8_t service_uuid[16] = {0xab,0x7d,0xe9,0xbe,0x89,0xfe,0x49,0xad,0x82,0x8f,0x11,0x8f,0x09,0xdf,0x7f,0xd0}; +static const uint8_t input_uuid[16] = {0xab,0x7d,0xe9,0xbe,0x89,0xfe,0x49,0xad,0x82,0x8f,0x11,0x8f,0x09,0xdf,0x7f,0xd2}; +static const uint8_t response_uuid[16] = {0xc7,0x65,0xa9,0x61,0xd9,0xd8,0x4d,0x36,0xa2,0x0a,0x53,0x15,0xb1,0x11,0x83,0x6a}; +static const uint8_t command_uuid[16] = {0x64,0x9d,0x4a,0xc9,0x8e,0xb7,0x4e,0x6c,0xaf,0x44,0x1e,0xa5,0x4f,0xe5,0xf0,0x05}; +static const uint8_t rumble_uuids[3][16] = { + {0xcc,0x48,0x3f,0x51,0x92,0x58,0x42,0x7d,0xa9,0x39,0x63,0x0c,0x31,0xf7,0x2b,0x05}, + {0x28,0x93,0x26,0xcb,0xa4,0x71,0x48,0x5d,0xa8,0xf4,0x24,0x0c,0x14,0xf1,0x82,0x41}, + {0xfa,0x19,0xb0,0xfb,0xcd,0x1f,0x46,0xa7,0x84,0xa1,0xbb,0xb0,0x9e,0x00,0xc1,0x49}, +}; + +static struct fixture_peer* peer_for_handle(hci_con_handle_t handle) { + for (unsigned i = 0; i < PEERS; ++i) + if (peers[i].used && peers[i].device.conn.handle == handle) + return &peers[i]; + return NULL; +} + +static void advance(uint32_t milliseconds) { + uint32_t target = now_ms + milliseconds; + for (;;) { + int earliest = -1; + for (unsigned i = 0; i < timer_count; ++i) { + if ((int32_t)(timers[i]->timeout - target) <= 0 && + (earliest < 0 || (int32_t)(timers[i]->timeout - timers[earliest]->timeout) < 0)) + earliest = (int)i; + } + if (earliest < 0) + break; + btstack_timer_source_t* timer = timers[earliest]; + now_ms = timer->timeout; + btstack_run_loop_remove_timer(timer); + timer->process(timer); + } + now_ms = target; +} + +void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t ms) { timer->timeout = now_ms + ms; } +void btstack_run_loop_add_timer(btstack_timer_source_t* timer) { + for (unsigned i = 0; i < timer_count; ++i) + assert(timers[i] != timer); + assert(timer_count < 16); + timers[timer_count++] = timer; +} +int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) { + for (unsigned i = 0; i < timer_count; ++i) { + if (timers[i] == timer) { + timers[i] = timers[--timer_count]; + return true; + } + } + return false; +} +void btstack_run_loop_set_timer_context(btstack_timer_source_t* timer, void* context) { timer->context = context; } +void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)) { timer->process = handler; } +void* btstack_run_loop_get_timer_context(btstack_timer_source_t* timer) { return timer->context; } +uint32_t btstack_run_loop_get_time_ms(void) { return now_ms; } +void uni_log(const char* format, ...) { (void)format; } +bool switch_pico_switch2_pairing_allowed(void) { return pairing_allowed; } +bool uni_switch2_pairing_known(uint8_t type, const uint8_t address[6]) { (void)type; (void)address; return trusted; } +bool uni_switch2_pairing_remember(uint8_t type, const uint8_t address[6]) { + assert(type <= 1 && memcmp(address, controller_address, 6) == 0); + ++remembered; + return storage_ok; +} +void gap_local_bd_addr(bd_addr_t address) { memcpy(address, host_address, 6); } +void gap_stop_scan(void) { ++scan_stops; scan_running = false; } +void uni_bt_le_resume_scanning_if_enabled(void) { if (scan_enabled) scan_running = true; } +uint8_t gap_connect(const bd_addr_t address, bd_addr_type_t type) { + (void)address; (void)type; ++connected; + uint8_t status = next_connect_error; + next_connect_error = 0; + return status; +} +int gap_update_connection_parameters(hci_con_handle_t handle, uint16_t min, uint16_t max, uint16_t latency, uint16_t timeout) { + (void)handle; (void)min; (void)max; (void)latency; (void)timeout; return 0; +} +gap_connection_type_t gap_get_connection_type(hci_con_handle_t handle) { + for (unsigned i = 0; i < PEERS; ++i) + if (peers[i].link_alive && peers[i].device.conn.handle == handle) + return GAP_CONNECTION_LE; + return GAP_CONNECTION_INVALID; +} +uni_hid_device_t* uni_hid_device_create(bd_addr_t address) { + for (unsigned i = 0; i < PEERS; ++i) { + if (!peers[i].used) { + memset(&peers[i], 0, sizeof(peers[i])); + peers[i].used = true; + peers[i].device.conn.handle = UNI_BT_CONN_HANDLE_INVALID; + memcpy(peers[i].device.conn.btaddr, address, 6); + return &peers[i].device; + } + } + return NULL; +} +uni_hid_device_t* uni_hid_device_get_instance_for_address(bd_addr_t address) { + for (unsigned i = 0; i < PEERS; ++i) + if (peers[i].used && memcmp(peers[i].device.conn.btaddr, address, 6) == 0) + return &peers[i].device; + return NULL; +} +uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle(hci_con_handle_t handle) { + struct fixture_peer* peer = peer_for_handle(handle); + return peer ? &peer->device : NULL; +} +uni_error_t uni_hid_device_on_device_discovered(bd_addr_t address, const char* name, uint16_t cod, uint8_t rssi) { + ++discovery_calls; + uni_hid_device_t* d = uni_hid_device_get_instance_for_address(address); + uint8_t type = 0xff; + assert(d && uni_hid_parser_switch2_is_ble_device(d)); + assert(uni_hid_parser_switch2_identity_address_type(d, &type)); + (void)name; + assert(d->cod == cod && d->conn.rssi == rssi); + if (expected_discovery_pid) { + assert(d->product_id == expected_discovery_pid); + assert(type == expected_discovery_address_type); + } + return admit ? UNI_ERROR_SUCCESS : (uni_error_t)1; +} +void uni_hid_device_set_vendor_id(uni_hid_device_t* d, uint16_t value) { d->vendor_id = value; } +void uni_hid_device_set_product_id(uni_hid_device_t* d, uint16_t value) { d->product_id = value; } +void uni_hid_device_set_cod(uni_hid_device_t* d, uint32_t value) { d->cod = value; } +void uni_hid_device_set_name(uni_hid_device_t* d, const char* value) { (void)d; (void)value; } +void uni_hid_device_guess_controller_type_from_pid_vid(uni_hid_device_t* d) { (void)d; } +void uni_bt_conn_set_protocol(uni_bt_conn_t* conn, uni_bt_conn_protocol_t value) { conn->protocol = value; } +void uni_bt_conn_set_state(uni_bt_conn_t* conn, uni_bt_conn_state_t value) { conn->state = value; } +void uni_hid_device_connect(uni_hid_device_t* d) { + assert(!d->conn.connected); + d->conn.connected = true; + ++connected_events; + if (reject_connected) { + uni_hid_device_disconnect(d); + uni_hid_device_delete(d); + } +} +void uni_hid_device_set_ready(uni_hid_device_t* d) { + d->conn.state = UNI_BT_CONN_STATE_DEVICE_PENDING_READY; + uni_hid_parser_switch2_setup(d); +} +bool uni_hid_device_set_ready_complete(uni_hid_device_t* d) { + assert(d->conn.connected && connected_events == 1); + ++ready; + d->conn.state = UNI_BT_CONN_STATE_DEVICE_READY; + return true; +} +void uni_hid_device_disconnect(uni_hid_device_t* d) { + ++disconnected; + if (d->conn.connected) + ++disconnected_events; + d->conn.connected = false; + uni_hid_parser_switch2_teardown(d); + struct fixture_peer* peer = peer_for_handle(d->conn.handle); + if (peer) + peer->link_alive = false; +} +void uni_hid_device_delete(uni_hid_device_t* d) { + uni_hid_parser_switch2_teardown(d); + for (unsigned i = 0; i < PEERS; ++i) + if (&peers[i].device == d) + peers[i].used = false; +} +void uni_hid_device_process_controller(uni_hid_device_t* d) { (void)d; ++emitted; } + +static uint8_t begin_query(btstack_packet_handler_t callback, hci_con_handle_t handle, enum fixture_query query) { + struct fixture_peer* peer = peer_for_handle(handle); + assert(peer && peer->query == QUERY_NONE); + peer->callback = callback; + peer->query = query; + return 0; +} +uint8_t gatt_client_discover_primary_services_by_uuid128(btstack_packet_handler_t callback, hci_con_handle_t handle, const uint8_t* uuid) { + assert(memcmp(uuid, service_uuid, 16) == 0); + return begin_query(callback, handle, QUERY_SERVICE); +} +uint8_t gatt_client_discover_characteristics_for_service(btstack_packet_handler_t callback, hci_con_handle_t handle, gatt_client_service_t* service) { + assert(service->start_group_handle == SERVICE_START); + return begin_query(callback, handle, QUERY_CHARACTERISTICS); +} +uint8_t gatt_client_discover_characteristic_descriptors(btstack_packet_handler_t callback, hci_con_handle_t handle, gatt_client_characteristic_t* ch) { + peer_for_handle(handle)->descriptor_value = ch->value_handle; + return begin_query(callback, handle, QUERY_DESCRIPTORS); +} +uint8_t gatt_client_write_characteristic_descriptor_using_descriptor_handle(btstack_packet_handler_t callback, hci_con_handle_t handle, + uint16_t descriptor, uint16_t length, uint8_t* value) { + struct fixture_peer* peer = peer_for_handle(handle); + peer->cccd_handle = descriptor; + peer->pending_write = value; + peer->pending_length = length; + return begin_query(callback, handle, QUERY_CCCD); +} +void gatt_client_listen_for_characteristic_value_updates(gatt_client_notification_t* registration, btstack_packet_handler_t callback, + hci_con_handle_t handle, gatt_client_characteristic_t* ch) { + registration->callback = callback; + registration->con_handle = handle; + registration->attribute_handle = ch->value_handle; + ++listeners; +} +void gatt_client_stop_listening_for_characteristic_value_updates(gatt_client_notification_t* registration) { (void)registration; assert(listeners); --listeners; } +static uint8_t capture_write(hci_con_handle_t handle, uint16_t value_handle, uint16_t length, uint8_t* value) { + if (next_write_error) { + uint8_t error = next_write_error; + next_write_error = 0; + return error; + } + struct fixture_peer* peer = peer_for_handle(handle); + assert(peer); + if (value_handle == COMMAND_HANDLE) { + assert(length <= sizeof(peer->command)); + memcpy(peer->command, value, length); + peer->command_length = length; + ++peer->commands; + } else { + assert(value_handle == RUMBLE_HANDLE && length <= sizeof(peer->rumble)); + memcpy(peer->rumble, value, length); + peer->rumble_length = length; + unsigned slot = peer->rumbles % FIXTURE_RUMBLE_HISTORY; + memcpy(peer->rumble_history[slot], value, length); + peer->rumble_times[slot] = now_ms; + ++peer->rumbles; + } + return 0; +} +uint8_t gatt_client_write_value_of_characteristic_without_response(hci_con_handle_t handle, uint16_t value_handle, uint16_t length, uint8_t* value) { + return capture_write(handle, value_handle, length, value); +} +uint8_t gatt_client_write_value_of_characteristic(btstack_packet_handler_t callback, hci_con_handle_t handle, + uint16_t value_handle, uint16_t length, uint8_t* value) { + uint8_t status = capture_write(handle, value_handle, length, value); + if (status) + return status; + struct fixture_peer* peer = peer_for_handle(handle); + peer->pending_write = value; + peer->pending_length = length; + memcpy(peer->pending_snapshot, value, length); + return begin_query(callback, handle, QUERY_WRITE); +} + +// Serialization here models BTstack-generated events; production accessors and +// types come directly from the SDK rather than a parallel mock btstack.h ABI. +void gatt_client_deserialize_service(const uint8_t* data, int offset, gatt_client_service_t* service) { + service->start_group_handle = little_endian_read_16(data, offset); + service->end_group_handle = little_endian_read_16(data, offset + 2); + reverse_128(data + offset + 4, service->uuid128); + service->uuid16 = 0; +} +void gatt_client_deserialize_characteristic(const uint8_t* data, int offset, gatt_client_characteristic_t* ch) { + ch->start_handle = little_endian_read_16(data, offset); + ch->value_handle = little_endian_read_16(data, offset + 2); + ch->end_handle = little_endian_read_16(data, offset + 4); + ch->properties = little_endian_read_16(data, offset + 6); + reverse_128(data + offset + 8, ch->uuid128); + ch->uuid16 = 0; +} +void gatt_client_deserialize_characteristic_descriptor(const uint8_t* data, int offset, gatt_client_characteristic_descriptor_t* descriptor) { + descriptor->handle = little_endian_read_16(data, offset); + reverse_128(data + offset + 2, descriptor->uuid128); + descriptor->uuid16 = (uint16_t)big_endian_read_32(descriptor->uuid128, 0); +} + +static void event(struct fixture_peer* peer, uint8_t* data, uint16_t size) { + data[1] = (uint8_t)(size - 2); + little_endian_store_16(data, 2, peer->device.conn.handle); + peer->callback(HCI_EVENT_PACKET, 0, data, size); +} +static void query_done(struct fixture_peer* peer, uint8_t status) { + uint8_t data[9] = {GATT_EVENT_QUERY_COMPLETE}; + if (peer->query == QUERY_CCCD) + assert(peer->pending_length == 2 && peer->pending_write[0] == 1 && peer->pending_write[1] == 0); + if (peer->query == QUERY_WRITE) + assert(memcmp(peer->pending_write, peer->pending_snapshot, peer->pending_length) == 0); + peer->query = QUERY_NONE; + data[8] = status; + event(peer, data, sizeof(data)); +} +static void characteristic(struct fixture_peer* peer, uint16_t handle, const uint8_t* uuid, uint16_t properties) { + uint8_t data[32] = {GATT_EVENT_CHARACTERISTIC_QUERY_RESULT}; + little_endian_store_16(data, 8, handle - 1); + little_endian_store_16(data, 10, handle); + little_endian_store_16(data, 12, handle + 3); + little_endian_store_16(data, 14, properties); + reverse_128(uuid, data + 16); + event(peer, data, sizeof(data)); +} +static void descriptor(struct fixture_peer* peer, uint16_t handle) { + const uint8_t cccd_uuid[16] = {0,0,0x29,0x02,0,0,0x10,0,0x80,0,0,0x80,0x5f,0x9b,0x34,0xfb}; + uint8_t data[26] = {GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT}; + little_endian_store_16(data, 8, handle); + reverse_128(cccd_uuid, data + 10); + event(peer, data, sizeof(data)); +} +static void discover(struct fixture_peer* peer) { + uint8_t service[28] = {GATT_EVENT_SERVICE_QUERY_RESULT}; + little_endian_store_16(service, 8, SERVICE_START); + little_endian_store_16(service, 10, SERVICE_START + 0x60); + reverse_128(service_uuid, service + 12); + event(peer, service, sizeof(service)); + query_done(peer, 0); + characteristic(peer, INPUT_HANDLE, input_uuid, ATT_PROPERTY_NOTIFY); + characteristic(peer, RESPONSE_HANDLE, response_uuid, ATT_PROPERTY_NOTIFY); + characteristic(peer, COMMAND_HANDLE, command_uuid, request_writes ? ATT_PROPERTY_WRITE : ATT_PROPERTY_WRITE_WITHOUT_RESPONSE); + unsigned kind = peer->device.product_id == UNI_SW2_PRO_PID ? 0 : peer->device.product_id == UNI_SW2_JOYCON_L_PID ? 1 : 2; + characteristic(peer, RUMBLE_HANDLE, rumble_uuids[kind], request_writes ? ATT_PROPERTY_WRITE : ATT_PROPERTY_WRITE_WITHOUT_RESPONSE); + query_done(peer, 0); +} +static void subscribe_response(struct fixture_peer* peer) { + // Deliberately not value_handle+1: parser must discover, not guess CCCDs. + descriptor(peer, RESPONSE_HANDLE + 2); + query_done(peer, 0); + descriptor(peer, INPUT_HANDLE + 2); + query_done(peer, 0); + assert(peer->cccd_handle == RESPONSE_HANDLE + 2); + query_done(peer, 0); +} +static void notify(struct fixture_peer* peer, uint16_t handle, const uint8_t* value, uint16_t length) { + uint8_t data[112] = {GATT_EVENT_NOTIFICATION}; + assert(length <= sizeof(data) - 12); + little_endian_store_16(data, 8, handle); + little_endian_store_16(data, 10, length); + memcpy(data + 12, value, length); + event(peer, data, 12 + length); +} +static void packed(uint8_t* data, uint16_t x, uint16_t y) { + data[0] = (uint8_t)x; + data[1] = (uint8_t)((x >> 8) | (y << 4)); + data[2] = (uint8_t)(y >> 4); +} +static unsigned response_data(struct fixture_peer* peer, uint8_t* out, bool erased) { + memset(out, 0, 96); + out[0] = peer->command[0]; + out[1] = out[2] = 1; + out[3] = peer->command[3]; + out[5] = 0x78; + if (out[0] == 2) { + uint8_t length = peer->command[8]; + uint32_t address = little_endian_read_32(peer->command, 12); + out[8] = length; + little_endian_store_32(out, 12, address); + if (address == 0x13000) { + little_endian_store_16(out, 16 + 18, UNI_SW2_NINTENDO_VID); + little_endian_store_16(out, 16 + 20, peer->device.product_id); + } else if (address != 0x13044) { + if (erased) + memset(out + 16, 0xff, length); + else { + packed(out + 16, 1900, 2100); + packed(out + 19, 1200, 1400); + packed(out + 22, 1500, 1700); + } + } + return 16 + length; + } + if (out[0] == 0x15) { + out[8] = 1; + return out[3] == 1 ? 17 : out[3] == 3 ? 9 : 25; + } + return 8; +} +static void acknowledge(struct fixture_peer* peer, bool erased) { + uint8_t response[96]; + unsigned length = response_data(peer, response, erased); + if (peer->query == QUERY_WRITE) + query_done(peer, 0); + notify(peer, RESPONSE_HANDLE, response, length); +} +static void finish_setup(struct fixture_peer* peer) { + for (unsigned limit = 0; limit < 24 && !ready && !disconnected; ++limit) { + if (peer->query == QUERY_CCCD) { + assert(peer->cccd_handle == INPUT_HANDLE + 2); + query_done(peer, 0); + } else { + acknowledge(peer, false); + } + } + assert(ready == 1 && disconnected == 0); +} +static size_t advertisement(uint8_t* packet, uint16_t pid, bool fresh) { + memset(packet, 0, 64); + packet[0] = GAP_EVENT_ADVERTISING_REPORT; + packet[2] = 0; + packet[3] = BD_ADDR_TYPE_LE_PUBLIC; + reverse_bytes(controller_address, packet + 4, 6); + packet[10] = (uint8_t)-35; + packet[11] = 20; + packet[12] = 19; + packet[13] = 0xff; + uint8_t* mfg = packet + 14; + little_endian_store_16(mfg, 0, 0x0553); + little_endian_store_16(mfg, 5, UNI_SW2_NINTENDO_VID); + little_endian_store_16(mfg, 7, pid); + if (!fresh) + reverse_bytes(host_address, mfg + 12, 6); + packet[1] = 30; + return 32; +} +static struct fixture_peer* connect_peer(uint16_t pid, bool fresh) { + uint8_t packet[64]; + size_t size = advertisement(packet, pid, fresh); + assert(uni_bt_le_switch2_handle_advertisement(packet, size)); + assert(connected == 1); + struct fixture_peer* peer = &peers[0]; + peer->device.conn.handle = 0; // Handle zero is valid, including on retirement. + peer->link_alive = true; + uni_hid_parser_switch2_on_le_connected(&peer->device); + return peer; +} +static void reset(void) { + for (unsigned i = 0; i < PEERS; ++i) { + if (peers[i].used) + uni_hid_parser_switch2_teardown(&peers[i].device); + } + assert(timer_count == 0 && listeners == 0); + memset(peers, 0, sizeof(peers)); + connected = ready = disconnected = emitted = remembered = 0; + connected_events = disconnected_events = 0; + scan_stops = discovery_calls = 0; + expected_discovery_pid = 0; + expected_discovery_address_type = BD_ADDR_TYPE_LE_PUBLIC; + now_ms = 0; + next_write_error = 0; + next_connect_error = 0; + scan_enabled = scan_running = true; + pairing_allowed = trusted = storage_ok = admit = true; + request_writes = false; + reject_connected = false; +} + +static void test_connected_callback_rejection(void) { + reset(); + reject_connected = true; + struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, false); + assert(connected_events == 1 && disconnected_events == 1 && ready == 0); + assert(peer->query == QUERY_NONE && timer_count == 0 && listeners == 0); + advance(3000); + assert(ready == 0 && peer->commands == 0); +} + +static void test_advertisement_bounds_and_admission(void) { + reset(); + uint8_t packet[64]; + size_t size = advertisement(packet, UNI_SW2_PRO_PID, true); + for (size_t n = 0; n < size; ++n) + assert(!uni_bt_le_switch2_handle_advertisement(packet, (uint16_t)n)); + packet[12] = 20; + assert(!uni_bt_le_switch2_handle_advertisement(packet, size)); + packet[12] = 19; + pairing_allowed = false; + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0); + advertisement(packet, UNI_SW2_PRO_PID, false); + trusted = false; + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0); + trusted = true; + memcpy(packet + 26, host_address, 6); // Wrong byte order must not reconnect. + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0); + advertisement(packet, UNI_SW2_PRO_PID, false); + packet[3] = BD_ADDR_TYPE_LE_RANDOM; + packet[9] = 0x40; // Resolving private address, not static identity. + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0); + assert(discovery_calls == 0 && scan_stops == 0); + advertisement(packet, UNI_SW2_PRO_PID, false); + admit = false; + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0); + admit = true; + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 1); + uint8_t type = 0xff; + assert(uni_hid_parser_switch2_identity_address_type(&peers[0].device, &type) && type == BD_ADDR_TYPE_LE_PUBLIC); +} + +static void test_discovery_metadata_and_rejection_isolation(void) { + reset(); + expected_discovery_pid = UNI_SW2_JOYCON_L_PID; + struct fixture_peer* mate = connect_peer(UNI_SW2_JOYCON_L_PID, false); + discover(mate); + subscribe_response(mate); + finish_setup(mate); + assert(ready == 1 && discovery_calls == 1); + const unsigned mate_timers = timer_count; + const unsigned mate_listeners = listeners; + + uint8_t packet[64]; + size_t size = advertisement(packet, UNI_SW2_JOYCON_R_PID, false); + packet[3] = BD_ADDR_TYPE_LE_RANDOM; + packet[4] ^= 1; // A different static identity from the active mate. + bd_addr_t candidate_address; + gap_event_advertising_report_get_address(packet, candidate_address); + expected_discovery_pid = UNI_SW2_JOYCON_R_PID; + expected_discovery_address_type = BD_ADDR_TYPE_LE_RANDOM; + admit = false; + // More rejections than slots must not exhaust either device or parser storage. + for (unsigned i = 0; i <= CONFIG_BLUEPAD32_MAX_DEVICES; ++i) { + assert(uni_bt_le_switch2_handle_advertisement(packet, size)); + assert(discovery_calls == i + 2); + assert(!uni_hid_device_get_instance_for_address(candidate_address)); + for (unsigned slot = 1; slot < PEERS; ++slot) { + uint8_t type; + assert(!peers[slot].used); + assert(!uni_hid_parser_switch2_identity_address_type(&peers[slot].device, &type)); + } + assert(connected == 1 && scan_stops == 1 && disconnected == 0); + assert(timer_count == mate_timers && listeners == mate_listeners); + assert(mate->used && mate->link_alive && mate->device.conn.state == UNI_BT_CONN_STATE_DEVICE_READY); + } + + // A rejected candidate must remain discoverable once policy permits it. + admit = true; + assert(uni_bt_le_switch2_handle_advertisement(packet, size)); + assert(uni_hid_device_get_instance_for_address(candidate_address)); + assert(connected == 2 && scan_stops == 2 && disconnected == 0); + unsigned admitted_calls = discovery_calls; + assert(uni_bt_le_switch2_handle_advertisement(packet, size)); + assert(discovery_calls == admitted_calls && connected == 2 && scan_stops == 2); + assert(mate->used && mate->link_alive); + + reset(); + size = advertisement(packet, UNI_SW2_JOYCON_R_PID, false); + admit = false; + assert(uni_bt_le_switch2_handle_advertisement(packet, size)); + advance(3000); + assert(discovery_calls == 1 && connected == 0 && scan_stops == 0 && disconnected == 0); + assert(timer_count == 0 && listeners == 0); + for (unsigned slot = 0; slot < PEERS; ++slot) + assert(!peers[slot].used); +} + +static void test_missing_descriptor_and_setup_timeout(void) { + reset(); + struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, false); + discover(peer); + descriptor(peer, RESPONSE_HANDLE); // CCCD outside characteristic's descriptor range. + assert(disconnected == 1 && ready == 0 && timer_count == 0 && listeners == 0); + reset(); + peer = connect_peer(UNI_SW2_PRO_PID, false); + discover(peer); + subscribe_response(peer); + uint8_t response[96]; + unsigned length = response_data(peer, response, false); + response[3] ^= 1; // Matching command but wrong subcommand. + notify(peer, RESPONSE_HANDLE, response, length); + response[3] ^= 1; + response[12] ^= 1; // Matching command/subcommand but stale flash address. + notify(peer, RESPONSE_HANDLE, response, length); + uint8_t truncated[12] = {GATT_EVENT_NOTIFICATION}; + little_endian_store_16(truncated, 8, RESPONSE_HANDLE); + little_endian_store_16(truncated, 10, 0xffff); + event(peer, truncated, sizeof(truncated)); + assert(peer->commands == 1 && ready == 0); + advance(2000); + assert(disconnected == 1 && ready == 0 && timer_count == 0 && listeners == 0); + advance(5000); + assert(peer->commands == 1); +} + +static void test_pairing_gate_and_write_ack_order(void) { + reset(); + request_writes = true; + struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, true); + discover(peer); + subscribe_response(peer); + uint8_t response[96]; + unsigned length = response_data(peer, response, false); + notify(peer, RESPONSE_HANDLE, response, length); // App ACK arrives before ATT write result. + assert(peer->commands == 1 && !remembered && !ready); + query_done(peer, 0); + assert(peer->command[0] == 0x15 && peer->command[3] == 1); + for (unsigned i = 0; i < 6; ++i) + assert(peer->command[10 + i] == host_address[5 - i] && peer->command[16 + i] == host_address[5 - i]); + pairing_allowed = false; + acknowledge(peer, false); + assert(disconnected == 1 && remembered == 0 && ready == 0); + assert(connected_events == 1 && disconnected_events == 1); + reset(); + peer = connect_peer(UNI_SW2_PRO_PID, true); + discover(peer); + subscribe_response(peer); + storage_ok = false; + for (unsigned i = 0; i < 5; ++i) + acknowledge(peer, false); // Info and four app pairing commands. + assert(remembered == 1 && disconnected == 1 && ready == 0 && timer_count == 0); + reset(); + peer = connect_peer(UNI_SW2_PRO_PID, false); + discover(peer); + subscribe_response(peer); + length = response_data(peer, response, false); + response[5] = 0x81; + notify(peer, RESPONSE_HANDLE, response, length); + assert(disconnected == 1 && ready == 0); + reset(); + request_writes = true; + peer = connect_peer(UNI_SW2_PRO_PID, false); + discover(peer); + subscribe_response(peer); + length = response_data(peer, response, false); + notify(peer, RESPONSE_HANDLE, response, length); + query_done(peer, ATT_ERROR_INSUFFICIENT_AUTHENTICATION); + assert(disconnected == 1 && ready == 0); +} + +static void test_calibration_physical_inputs_and_sensor_units(void) { + reset(); + struct fixture_peer* peer = connect_peer(UNI_SW2_JOYCON_R_PID, false); + discover(peer); + subscribe_response(peer); + acknowledge(peer, false); // Info. + acknowledge(peer, true); // Missing user calibration falls back to factory. + assert(little_endian_read_32(peer->command, 12) == 0x130a8); + finish_setup(peer); + uint8_t report[63] = {0}; + little_endian_store_32(report, 4, 0x4000 | 0x20 | 0x10 | 0x04); + packed(report + 10, 4095, 4095); // Nonexistent left stick must stay neutral. + packed(report + 13, 3100, 400); // Calibrated +X/-Y full travel. + notify(peer, INPUT_HANDLE, report, sizeof(report)); + uni_gamepad_t* gp = &peer->device.controller.gamepad; + assert(gp->axis_x == 0 && gp->axis_y == 0 && gp->axis_rx == 511 && gp->axis_ry == 511); + assert(gp->buttons == BUTTON_A && gp->misc_buttons == 0); + assert(uni_hid_parser_switch2_extra_buttons(&peer->device) == (UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_RIGHT_SL | UNI_SW2_BUTTON_RIGHT_SR)); + unsigned previous = emitted; + notify(peer, INPUT_HANDLE, report, 62); + assert(emitted == previous && gp->axis_rx == 511); + little_endian_store_16(report, 48, 4096); + little_endian_store_16(report, 50, 8192); + little_endian_store_16(report, 52, (uint16_t)-4096); + little_endian_store_16(report, 54, 32767); + little_endian_store_16(report, 56, 0); + little_endian_store_16(report, 58, 0); + for (unsigned sample = 0; sample < 46; ++sample) { + little_endian_store_32(report, 42, 0xffff0000u + sample * 10000u); + notify(peer, INPUT_HANDLE, report, sizeof(report)); + advance(10); + } + assert(gp->accel[0] == 8192 && gp->accel[1] == -8192 && gp->accel[2] == -16384); + assert(gp->gyro[0] >= 2041740 && gp->gyro[0] <= 2041750); + notify(peer, INPUT_HANDLE, report, sizeof(report)); // Repeated sensor sample must not become zero. + assert(gp->gyro[0] >= 2041740); + reset(); + peer = connect_peer(UNI_SW2_JOYCON_L_PID, false); + discover(peer); + subscribe_response(peer); + finish_setup(peer); + memset(report, 0, sizeof(report)); + little_endian_store_32(report, 4, 0x300000); + packed(report + 10, 1900, 2100); + notify(peer, INPUT_HANDLE, report, sizeof(report)); + assert(peer->device.controller.gamepad.misc_buttons == 0); // Rail buttons are NOT Home/Capture. + assert(uni_hid_parser_switch2_extra_buttons(&peer->device) == (UNI_SW2_BUTTON_LEFT_SL | UNI_SW2_BUTTON_LEFT_SR)); +} + +static uint64_t rumble_frame(const struct fixture_peer* peer) { + uint64_t value = 0; + for (unsigned i = 0; i < 5; ++i) + value |= (uint64_t)peer->rumble[2 + i] << (8 * i); + return value; +} +static bool rumble_active(const struct fixture_peer* peer) { + uint64_t frame = rumble_frame(peer); + return ((frame >> 10) & 1023) != 0 || ((frame >> 30) & 1023) != 0; +} +static void test_rumble_delay_expiry_retry_and_teardown(void) { + reset(); + struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, true); + discover(peer); + subscribe_response(peer); + finish_setup(peer); + assert(remembered == 1); + uni_hid_parser_switch2_set_player_leds(&peer->device, 0x0a); + assert(peer->command[8] == 0x0a); + acknowledge(peer, false); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 20, 50, 40, 80); + advance(19); + assert(peer->rumble_length == 33 && !rumble_active(peer)); + advance(1); + assert(rumble_active(peer)); + assert(((rumble_frame(peer) >> 10) & 1023) == 320 && ((rumble_frame(peer) >> 30) & 1023) == 160); + uint8_t successful_id = peer->rumble[1]; + next_write_error = BTSTACK_ACL_BUFFERS_FULL; + advance(13); + assert(peer->rumble[1] == successful_id); + advance(13); + assert(peer->rumble[1] == (uint8_t)(0x50 | ((successful_id + 1) & 15)) && rumble_active(peer)); + advance(24); + assert(!rumble_active(peer)); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 12, 34); + advance(70000); // Held local feedback does not expire at65.535 seconds. + assert(((rumble_frame(peer) >> 10) & 1023) == 136 && ((rumble_frame(peer) >> 30) & 1023) == 48); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 100, 255, 255); + advance(1); + assert(rumble_active(peer)); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0); + advance(1); + assert(!rumble_active(peer)); + unsigned writes = peer->rumbles; + uni_hid_device_disconnect(&peer->device); + assert(disconnected_events == 1); + uni_hid_parser_switch2_teardown(&peer->device); + assert(timer_count == 0 && listeners == 0); + advance(5000); + assert(peer->rumbles == writes); + assert(!uni_hid_parser_switch2_identity_address_type(&peer->device, &(uint8_t){0})); +} + +static void test_write_request_buffers_and_failed_completion(void) { + reset(); + request_writes = true; + struct fixture_peer* peer = connect_peer(UNI_SW2_JOYCON_L_PID, false); + discover(peer); + subscribe_response(peer); + finish_setup(peer); + advance(1); + assert(peer->rumble_length == 17 && peer->query == QUERY_WRITE); + uint8_t pending[33]; + uint16_t length = peer->pending_length; + const uint8_t* pending_buffer = peer->pending_write; + memcpy(pending, pending_buffer, length); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 20, 30); + uni_hid_parser_switch2_set_player_leds(&peer->device, 0x05); + advance(13); + assert(memcmp(pending, pending_buffer, length) == 0); // ATT still borrows this buffer. + query_done(peer, 0); + assert(peer->command[0] == 9 && peer->command[8] == 0x05); + acknowledge(peer, false); + advance(13); + assert(rumble_active(peer) && peer->rumble[1] == 0x51); + query_done(peer, ATT_ERROR_UNLIKELY_ERROR); + assert(disconnected_events == 1 && timer_count == 0 && listeners == 0); + unsigned writes = peer->rumbles; + advance(3000); + assert(peer->rumbles == writes); +} + +static struct fixture_peer* ready_peer(uint16_t pid, bool requests) { + reset(); + request_writes = requests; + struct fixture_peer* peer = connect_peer(pid, false); + discover(peer); + subscribe_response(peer); + finish_setup(peer); + return peer; +} + +static uni_switch2_haptics_frame_t native_frame(unsigned left, unsigned right, unsigned seed) { + uni_switch2_haptics_frame_t frame = {0}; + frame.sides[0].count = left; + frame.sides[1].count = right; + for (unsigned side = 0; side < 2; ++side) { + for (unsigned i = 0; i < frame.sides[side].count; ++i) { + uni_switch2_haptics_encode_sample(frame.sides[side].samples[i], + 10 + seed + 5 * i + side, 80 + seed + i + side, + 1000 + seed * 100 + i * 200 + side * 3000, + 5000 + seed * 100 + i * 700 + side * 500); + } + } + return frame; +} + +static uni_switch2_haptics_side_t final_hold(const uni_switch2_haptics_side_t* source) { + uni_switch2_haptics_side_t side = {.count = 1}; + memcpy(side.samples[0], source->samples[source->count - 1], 5); + return side; +} + +static void assert_block(const struct fixture_peer* peer, unsigned side, + const uni_switch2_haptics_side_t* expected) { + assert(peer->rumble_length >= 17 + side * 16); + const uint8_t* block = peer->rumble + 1 + side * 16; + assert((block[0] & 0xf0) == (0x40 | (expected->count << 4))); + assert(memcmp(block + 1, expected->samples, 5 * expected->count) == 0); + for (unsigned i = 1 + 5 * expected->count; i < 16; ++i) + assert(block[i] == 0); +} + +static void assert_silent(const struct fixture_peer* peer, unsigned side) { + const uint8_t* block = peer->rumble + 1 + side * 16; + assert((block[0] & 0xf0) == 0x50); + assert(!(block[2] & 0xfc) && !(block[3] & 0x0f) && !(block[4] & 0xc0) && !block[5]); + for (unsigned i = 6; i < 16; ++i) + assert(block[i] == 0); +} + +static void test_native_fifo_stereo_counts_and_retry(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false); + uni_switch2_haptics_frame_t a = native_frame(3, 2, 1); + uni_switch2_haptics_frame_t b = native_frame(2, 0, 2); + uni_switch2_haptics_frame_t c = native_frame(1, 3, 3); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms)); + next_write_error = BTSTACK_ACL_BUFFERS_FULL; + advance(1); + assert(peer->rumbles == 0); + advance(13); + assert(peer->rumbles == 1 && peer->rumble_length == 33); + assert(peer->rumble[1] == 0x70 && peer->rumble[17] == 0x60); + assert_block(peer, 0, &a.sides[0]); + assert_block(peer, 1, &a.sides[1]); + // C arrives while A plays, with enough source lifetime for all three frames. + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &c, now_ms)); + advance(15); + assert(peer->rumbles == 1); // Three samples cannot be interrupted at13ms. + advance(1); + assert(peer->rumbles == 2 && peer->rumble[1] == 0x61); + assert_block(peer, 0, &b.sides[0]); + uni_switch2_haptics_side_t right = final_hold(&a.sides[1]); + assert_block(peer, 1, &right); + advance(10); + assert(peer->rumbles == 2); + advance(1); + assert(peer->rumbles == 3 && peer->rumble[1] == 0x52 && peer->rumble[17] == 0x72); + assert_block(peer, 0, &c.sides[0]); + assert_block(peer, 1, &c.sides[1]); + assert(peer->rumble_times[1] - peer->rumble_times[0] == 16); + assert(peer->rumble_times[2] - peer->rumble_times[1] == 11); + assert(uni_hid_parser_switch2_haptics_dropped() == drops); + advance(23); // C expires at receipt14 +50, not transmission41 +50. + assert_silent(peer, 0); + assert_silent(peer, 1); +} + +static void test_native_hold_and_absent_side_watchdogs(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false); + uni_switch2_haptics_frame_t a = native_frame(3, 2, 4); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + assert_block(peer, 0, &a.sides[0]); + advance(15); + assert(peer->rumbles == 1); + advance(1); + uni_switch2_haptics_side_t left = final_hold(&a.sides[0]); + uni_switch2_haptics_side_t right = final_hold(&a.sides[1]); + assert(peer->rumbles == 2); + assert_block(peer, 0, &left); + assert_block(peer, 1, &right); + advance(3); // t20; only the left actuator receives a fresh source update. + uni_switch2_haptics_frame_t b = native_frame(1, 0, 5); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms)); + advance(3); // Last count1 hold guarded until t23. + assert_block(peer, 0, &b.sides[0]); + assert_block(peer, 1, &right); + advance(27); + assert_block(peer, 0, &b.sides[0]); + assert_silent(peer, 1); // Absent side must not gain a new50ms watchdog. + advance(20); + assert_silent(peer, 0); + assert_silent(peer, 1); +} + +static void test_feedback_advances_host_and_resumes_only_final_samples(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + uni_switch2_haptics_frame_t a = native_frame(2, 3, 6); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 40, 20, 30); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + assert(((rumble_frame(peer) >> 10) & 1023) == 120); + assert((peer->rumble[1] & 0xf0) == 0x50); + advance(9); + uni_switch2_haptics_frame_t b = native_frame(3, 0, 7); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms)); + advance(1); + assert(((rumble_frame(peer) >> 10) & 1023) == 120); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0); + advance(1); + uni_switch2_haptics_side_t left = final_hold(&b.sides[0]); + uni_switch2_haptics_side_t right = final_hold(&a.sides[1]); + assert_block(peer, 0, &left); + assert_block(peer, 1, &right); + for (unsigned i = 0; i < peer->rumbles; ++i) { + assert((peer->rumble_history[i][1] & 0xf0) == 0x50); + assert((peer->rumble_history[i][17] & 0xf0) == 0x50); + } + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 100, 50, 60); + advance(48); // Both original host watchdogs elapse under feedback. + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0); + advance(1); + assert_silent(peer, 0); + assert_silent(peer, 1); + assert(uni_hid_parser_switch2_haptics_dropped() == drops); +} + +static void test_full_fifo_stop_barrier_and_async_completion(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_R_PID, true); + uni_switch2_haptics_frame_t a = native_frame(3, 0, 8); + uni_switch2_haptics_frame_t b = native_frame(2, 0, 9); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + assert(peer->query == QUERY_WRITE && peer->rumble_length == 17); + const uint8_t* borrowed = peer->pending_write; + uint8_t snapshot[17]; + memcpy(snapshot, borrowed, sizeof(snapshot)); + for (unsigned i = 1; i < 16; ++i) + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + assert(uni_hid_parser_switch2_haptics_dropped() == drops); + uni_switch2_haptics_frame_t stop; + uni_switch2_haptics_silence(&stop); + stop.sides[1].count = 0; // Physical Joy-Con stop is not a logical stereo stop. + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stop, now_ms - 100)); + assert(uni_hid_parser_switch2_haptics_dropped() == drops + 16); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms)); + advance(1); + assert(memcmp(snapshot, borrowed, sizeof(snapshot)) == 0); + query_done(peer, 0); // Completion of old epoch must not consume b/the stop. + advance(1); + assert(peer->rumbles == 2 && peer->rumble[1] == 0x51); + assert_silent(peer, 0); + query_done(peer, 0); + advance(6); + assert(peer->rumbles == 3 && peer->rumble[1] == 0x62); + assert_block(peer, 0, &b.sides[0]); + query_done(peer, 0); + advance(11); + uni_switch2_haptics_side_t hold = final_hold(&b.sides[0]); + assert_block(peer, 0, &hold); + query_done(peer, 0); +} + +static void test_async_expiry_and_topology_reset_do_not_revive_state(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, true); + uni_switch2_haptics_frame_t a = native_frame(3, 2, 10); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + advance(49); // Write request is still borrowing the original batch. + assert(peer->rumbles == 1 && uni_hid_parser_switch2_haptics_dropped() == drops + 1); + query_done(peer, 0); + advance(1); + assert_silent(peer, 0); + assert_silent(peer, 1); + query_done(peer, 0); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 40, 50); + advance(6); + assert(rumble_active(peer) && peer->query == QUERY_WRITE); + uni_switch2_haptics_frame_t b = native_frame(2, 1, 11); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + uni_hid_parser_switch2_reset_haptics(&peer->device); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms)); + advance(1); + query_done(peer, 0); // Old local overlay must not clear the reset neutral. + advance(1); + assert_silent(peer, 0); + assert_silent(peer, 1); + query_done(peer, 0); + advance(6); + assert_block(peer, 0, &b.sides[0]); + assert_block(peer, 1, &b.sides[1]); + query_done(peer, 0); +} + +static void test_host_stop_does_not_cancel_local_feedback(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 100, 200); + uni_switch2_haptics_frame_t a = native_frame(3, 3, 12); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 0, 0, 0, now_ms)); + advance(1); + assert(((rumble_frame(peer) >> 10) & 1023) == 800); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0); + advance(1); + assert_silent(peer, 0); + assert_silent(peer, 1); +} + +static void test_conventional_host_lifetimes_and_invalid_native_input(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_L_PID, false); + assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 255, 200, UINT16_MAX, now_ms)); + advance(70000); + assert((rumble_frame(peer) & 1023) == 0xe1); + assert(((rumble_frame(peer) >> 20) & 1023) == 0x1e1); + assert(((rumble_frame(peer) >> 10) & 1023) == 800); + assert(((rumble_frame(peer) >> 30) & 1023) == 1020); + assert((peer->rumble[1] & 0xf0) == 0x50); + for (unsigned i = 7; i < 17; ++i) + assert(peer->rumble[i] == 0); + assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 1, 2, 20, now_ms - 10)); + advance(9); + assert(((rumble_frame(peer) >> 10) & 1023) == 8); + advance(1); + assert_silent(peer, 0); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + uni_switch2_haptics_frame_t invalid = native_frame(1, 0, 13); + invalid.sides[0].count = 4; + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &invalid, now_ms)); + uni_switch2_haptics_frame_t stale = native_frame(1, 0, 14); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stale, now_ms - 50)); + assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 10, 20, 100, now_ms - 50)); + assert(uni_hid_parser_switch2_haptics_dropped() == drops + 3); + advance(13); + assert_silent(peer, 0); +} + +static void test_native_watchdog_clock_wrap(void) { + reset(); + now_ms = UINT32_MAX - 20; + struct fixture_peer* peer = connect_peer(UNI_SW2_JOYCON_L_PID, false); + discover(peer); + subscribe_response(peer); + finish_setup(peer); + uni_switch2_haptics_frame_t frame = native_frame(1, 0, 15); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + advance(49); + assert_block(peer, 0, &frame.sides[0]); + advance(1); + assert_silent(peer, 0); +} + +static void test_identical_hold_coalescing_refreshes_borrowed_head(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_L_PID, true); + uni_switch2_haptics_frame_t frame = native_frame(1, 0, 16); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + advance(1); + const uint8_t* borrowed = peer->pending_write; + uint8_t snapshot[17]; + memcpy(snapshot, borrowed, sizeof(snapshot)); + advance(39); + for (unsigned i = 0; i < 32; ++i) + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + assert(memcmp(snapshot, borrowed, sizeof(snapshot)) == 0); + advance(10); // The original t0 hold would expire now without source refresh. + assert(uni_hid_parser_switch2_haptics_dropped() == drops); + query_done(peer, 0); + advance(39); + assert_block(peer, 0, &frame.sides[0]); + query_done(peer, 0); + advance(1); // Refreshed deadline remains t40+50, not ATT completion+50. + assert_silent(peer, 0); + assert(uni_hid_parser_switch2_haptics_dropped() == drops); + query_done(peer, 0); +} + +static void test_hold_coalescing_requires_identical_samples_and_side_masks(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, true); + uni_switch2_haptics_frame_t frame; + for (unsigned i = 0; i < 16; ++i) { + frame = native_frame(1, 1, i + 1); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + } + // An identical tail hold can refresh even at capacity. A partial-side update + // is a different command, as is a changed sample; neither may erase history. + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + frame.sides[1].count = 0; + assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + frame = native_frame(1, 1, 17); + assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + advance(1); + uni_switch2_haptics_frame_t first = native_frame(1, 1, 1); + assert_block(peer, 0, &first.sides[0]); + assert_block(peer, 1, &first.sides[1]); +} + +static void test_expired_history_does_not_starve_fresh_sequences(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false); + uni_switch2_haptics_frame_t a = native_frame(3, 3, 20); + uni_switch2_haptics_frame_t stale = native_frame(3, 3, 21); + uni_switch2_haptics_frame_t fresh = native_frame(3, 3, 22); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms)); + advance(1); + unsigned sent = peer->rumbles; + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stale, now_ms - 40)); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms)); + advance(10); + assert(peer->rumbles == sent); // Unplayed stale history must not interrupt A. + advance(6); + assert_block(peer, 0, &fresh.sides[0]); + assert_block(peer, 1, &fresh.sides[1]); + + peer = ready_peer(UNI_SW2_PRO_PID, false); + a = native_frame(1, 1, 23); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms - 43)); + advance(1); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms)); + advance(6); // A's source watchdog expires as its playback guard ends. + assert_block(peer, 0, &fresh.sides[0]); // No unnecessary silent/HOLD packet. + + peer = ready_peer(UNI_SW2_PRO_PID, false); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms - 40)); + advance(1); + assert_silent(peer, 0); // Nine ms cannot contain a complete three-frame batch. + assert(uni_hid_parser_switch2_haptics_dropped() == drops + 1); +} + +static void test_gatt_busy_retries_without_disconnect_or_sequence_loss(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false); + uni_switch2_haptics_frame_t frame = native_frame(3, 2, 24); + uint32_t drops = uni_hid_parser_switch2_haptics_dropped(); + next_write_error = GATT_CLIENT_BUSY; + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + advance(1); + assert(!disconnected && peer->rumbles == 0); + advance(13); + assert(!disconnected && peer->rumbles == 1); + assert((peer->rumble[1] & 15) == 0); + assert_block(peer, 0, &frame.sides[0]); + assert_block(peer, 1, &frame.sides[1]); + + // The SDK uses the same busy status on command writes such as player LEDs. + next_write_error = GATT_CLIENT_BUSY; + uni_hid_parser_switch2_set_player_leds(&peer->device, 3); + assert(!disconnected); + advance(13); + assert(peer->command[0] == 0x09 && peer->command[8] == 3); + acknowledge(peer, false); + assert(!disconnected && uni_hid_parser_switch2_haptics_dropped() == drops); +} + +static void test_immediate_connection_failure_preserves_reconnect_discovery(void) { + reset(); + uint8_t packet[64]; + size_t size = advertisement(packet, UNI_SW2_JOYCON_R_PID, false); + next_connect_error = ERROR_CODE_COMMAND_DISALLOWED; + assert(uni_bt_le_switch2_handle_advertisement(packet, size)); + assert(scan_running && !peers[0].used && !timer_count && !listeners); + assert(uni_bt_le_switch2_handle_advertisement(packet, size)); + assert(peers[0].used && connected == 2); // The same remembered peer can retry. + + reset(); + scan_enabled = scan_running = false; + next_connect_error = ERROR_CODE_COMMAND_DISALLOWED; + assert(uni_bt_le_switch2_handle_advertisement(packet, size)); + assert(!scan_running && !peers[0].used); // Explicit stop must not be overridden. +} + +static void test_idle_neutral_output_quiesces_and_resumes(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_L_PID, false); + advance(100); + assert(peer->rumbles == 3 && !rumble_active(peer)); + unsigned idle_writes = peer->rumbles; + advance(1300); + assert(peer->rumbles == idle_writes); + // Repeated host stops must not restart an already completed neutral burst. + for (unsigned i = 0; i < 20; ++i) { + assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 0, 0, 0, now_ms)); + advance(8); + } + assert(peer->rumbles == idle_writes); + // Quiet output must not delay a future effect or a finite watchdog stop. + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 20, 50, 40, 80); + advance(19); + assert(peer->rumbles == idle_writes); + advance(1); + assert(rumble_active(peer)); + advance(20); + assert(peer->rumbles > idle_writes + 1 && rumble_active(peer)); + advance(30); + assert(!rumble_active(peer)); + advance(100); + idle_writes = peer->rumbles; + advance(1300); + assert(peer->rumbles == idle_writes); + uni_switch2_haptics_frame_t frame = native_frame(3, 0, 8); + assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms)); + advance(1); + assert_block(peer, 0, &frame.sides[0]); + advance(100); + assert_silent(peer, 0); + idle_writes = peer->rumbles; + advance(1300); + assert(peer->rumbles == idle_writes); +} + +static void test_neutral_budget_waits_for_success_and_stale_active_completion(void) { + struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_R_PID, true); + next_write_error = BTSTACK_ACL_BUFFERS_FULL; + advance(1); + assert(peer->rumbles == 0); + advance(13); + assert(peer->rumbles == 1 && peer->query == QUERY_WRITE); + advance(100); + assert(peer->rumbles == 1); // Pending ATT writes cannot count as repeated stops. + query_done(peer, 0); + advance(13); + assert(peer->rumbles == 2); + query_done(peer, 0); + advance(13); + assert(peer->rumbles == 3); + query_done(peer, 0); + advance(100); + assert(peer->rumbles == 3 && peer->query == QUERY_NONE); + // Reset while an active write borrows its buffer. Its stale completion must + // invalidate the earlier idle state and require a fresh stop burst. + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 40, 80); + advance(1); + assert(rumble_active(peer) && peer->query == QUERY_WRITE); + uni_hid_parser_switch2_reset_haptics(&peer->device); + query_done(peer, 0); + for (unsigned i = 0; i < 3; ++i) { + advance(i ? 13 : 1); + assert(peer->rumbles == 5 + i && peer->query == QUERY_WRITE); + assert_silent(peer, 0); + query_done(peer, 0); + } + advance(100); + assert(peer->rumbles == 7 && peer->query == QUERY_NONE); +} + +int main(void) { + test_connected_callback_rejection(); + test_advertisement_bounds_and_admission(); + test_discovery_metadata_and_rejection_isolation(); + test_immediate_connection_failure_preserves_reconnect_discovery(); + test_missing_descriptor_and_setup_timeout(); + test_pairing_gate_and_write_ack_order(); + test_calibration_physical_inputs_and_sensor_units(); + test_rumble_delay_expiry_retry_and_teardown(); + test_write_request_buffers_and_failed_completion(); + test_native_fifo_stereo_counts_and_retry(); + test_native_hold_and_absent_side_watchdogs(); + test_feedback_advances_host_and_resumes_only_final_samples(); + test_full_fifo_stop_barrier_and_async_completion(); + test_async_expiry_and_topology_reset_do_not_revive_state(); + test_host_stop_does_not_cancel_local_feedback(); + test_conventional_host_lifetimes_and_invalid_native_input(); + test_native_watchdog_clock_wrap(); + test_identical_hold_coalescing_refreshes_borrowed_head(); + test_hold_coalescing_requires_identical_samples_and_side_masks(); + test_expired_history_does_not_starve_fresh_sequences(); + test_gatt_busy_retries_without_disconnect_or_sequence_loss(); + test_idle_neutral_output_quiesces_and_resumes(); + test_neutral_budget_waits_for_success_and_stale_active_completion(); + reset(); + puts("Switch2 protocol boundaries, setup failure, pairing, calibration, physical input, motion and rumble passed"); + return 0; +} diff --git a/tests/switch_parser_native_test.c b/tests/switch_parser_native_test.c index 0e812ae..6edd911 100644 --- a/tests/switch_parser_native_test.c +++ b/tests/switch_parser_native_test.c @@ -5,6 +5,7 @@ #include "bt/uni_bt_service.h" #include "parser/uni_hid_parser_switch.h" +#include "parser/uni_hid_parser_switch2.h" #include "platform/uni_platform.h" #include "uni_hid_device.h" @@ -22,6 +23,15 @@ static struct { uint16_t cid, len; uint8_t bytes[128]; } sent[256]; static uint32_t now_ms; static struct { btstack_timer_source_t* timer; uint32_t deadline; bool active; } timers[32]; +bool uni_hid_parser_switch2_is_ble_device(const uni_hid_device_t* d) { + (void)d; + return false; // This fixture exercises only Classic Switch devices. +} +void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) { + (void)d; + assert(!"Switch 2 teardown reached a Classic Switch fixture"); +} + static unsigned timer_index(btstack_timer_source_t* timer) { for (unsigned i = 0; i < 32; ++i) { if (timers[i].timer == timer) return i; diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 8dc9342..591c16f 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -81,7 +81,14 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: [ f"-I{root / 'tests' / 'bluepad32_native_stubs'}", f"-I{root / 'src' / 'firmware'}", + f"-I{root / 'bluepad32_config'}", str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"), + str(root / "src" / "firmware" / "profile" / "controller_profile.cpp"), + str(root / "src" / "firmware" / "profile" / "controller_profile_transform.cpp"), + str(root / "src" / "firmware" / "profile" / "controller_synthetic_input.cpp"), + str(root / "src" / "firmware" / "profile" / "controller_profile_runtime.cpp"), + str(root / "src" / "firmware" / "profile" / "profile_storage.cpp"), + str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"), str( root / "src" / "firmware" / "input" / "controller_macro_capture.cpp" ), @@ -91,6 +98,50 @@ 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 ( + "switch2-individual-core-start", + "switch2-individual-forward", + "switch2-individual-reverse", + "switch2-mode-forward", + "switch2-mode-reverse", + "switch2-mode-two-pairs", + "switch2-mode-seed-failure", + "switch2-mode-identity-failure", + "switch2-gesture-timing", + "switch2-gesture-slot-left-first", + "switch2-gesture-slot-right-first", + "switch2-gesture-slot-occupied", + "switch2-gesture-stale", + "switch2-gesture-clock-wrap", + "switch2-gesture-masking-epochs", + "switch2-gesture-override-lifetime", + "switch2-gesture-two-pairs", + "switch2-gesture-ambiguous", + "switch2-gesture-seed-failure", + "switch2-gesture-device-scope", + "switch2-forward", + "switch2-reverse", + "switch2-multiple-pairs", + "switch2-pair-failure-left", + "switch2-pair-failure-right", + "switch2-pair-replacement", + "switch2-admission", + "switch2-radio-policy", + "switch2-radio-individual", + "switch2-radio-settling", + "switch2-mate-reconnect", + "switch2-mate-pending", + "switch2-mate-pairing-window", + "switch2-pairing-inventory", + "switch2-hd-pro", + "switch2-hd-solo-left", + "switch2-hd-solo-right", + "switch2-hd-pair", + "switch2-hd-overflow", + "switch2-hd-epochs", + "switch2-hd-feedback", + ): + subprocess.run([str(executable), scenario], check=True, cwd=root) if native: subprocess.run([str(executable), "native-stateful"], check=True, cwd=root) subprocess.run( diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 46591f2..ae8334e 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -62,6 +62,7 @@ class FakeDevice: self.transaction_expected_size = 0 self.transaction_expected_crc = 0 self.transaction_status = config_manager.STATUS_OK + self.fail_configuration_commit_status: int | None = None self.pending_requested_mode: int | None = None self.mode_pending_reads = 0 self.fail_mode_status: int | None = None @@ -136,6 +137,8 @@ class FakeDevice: self.playtest_connection_generation = 17 self.playtest_state_generation = 93 self.playtest_button_mask = 0x9001 + self.playtest_extra_buttons = 0 + self.playtest_layout = 0 self.playtest_sticks = (-1234, 2345, -30000, 30000) self.playtest_triggers = (123, 65000) self.playtest_motion = (1, -2, 3, -4, 5, -6) @@ -232,6 +235,8 @@ class FakeDevice: payload[38] = 1 if self.playtest_motion is not None else 0 payload[39] = self.playtest_battery payload[40] = self.playtest_capabilities + payload[54] = self.playtest_extra_buttons + payload[55] = self.playtest_layout if self.playtest_motion is not None: struct.pack_into("= 3: self.configuration += bytes(config_manager.CONFIGURATION_SIZE - 8) self.configuration_generation += 1 self.transaction_payload = bytearray(self.configuration) @@ -705,9 +714,12 @@ def native_rumble_identity( def native_rumble_configuration( identities: tuple[config_manager.ControllerIdentity, ...] = (), + joycon_mode: int = config_manager.JOYCON_MODE_PAIRED, ) -> bytes: return ( - struct.pack(" None: +@pytest.mark.parametrize("schema", (3, 4)) +def test_native_rumble_configuration_canonical_wire_round_trip(schema: int) -> None: device = FakeDevice() identities = tuple( native_rumble_identity( @@ -756,17 +770,19 @@ def test_native_rumble_configuration_canonical_wire_round_trip() -> None: config_manager.write_configuration( device, config_manager.AdapterConfiguration( - 90, 0, 0, config_manager.REQUESTED_MODE_XINPUT, tuple(reversed(identities)) + 90, 0, 0, config_manager.REQUESTED_MODE_XINPUT, tuple(reversed(identities)), + schema_version=schema, ), 1.0, ) - assert device.configuration_schema == 3 + assert device.configuration_schema == schema assert device.configuration == native_rumble_configuration(identities) stored = config_manager.read_configuration(device) assert stored.native_switch_controllers == identities assert stored.crc == zlib.crc32(device.configuration) & 0xFFFFFFFF assert stored.pairing_window_seconds == 90 assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT + assert stored.joycon_mode == config_manager.JOYCON_MODE_PAIRED @pytest.mark.parametrize( @@ -814,7 +830,7 @@ def test_native_rumble_configuration_rejects_invalid_lists(malformation: str) -> @pytest.mark.parametrize( - "malformation", ("duplicate", "overflow", "global", "ble", "vendor", "product") + "malformation", ("duplicate", "overflow", "global", "ble", "pair", "vendor", "product") ) def test_native_rumble_write_rejects_invalid_approvals_before_transaction( malformation: str, @@ -828,6 +844,11 @@ def test_native_rumble_write_rejects_invalid_approvals_before_transaction( ), "global": (config_manager.ControllerIdentity.global_fallback(),), "ble": (replace(identity, transport=config_manager.TRANSPORT_BLE),), + "pair": ( + config_manager.ControllerIdentity.from_bytes( + bytes.fromhex("0503102030405060C12233445566") + ), + ), "vendor": (replace(identity, vendor_id=0x045E),), "product": (replace(identity, product_id=0x2019),), }[malformation] @@ -842,12 +863,17 @@ def test_native_rumble_write_rejects_invalid_approvals_before_transaction( assert not device.out_requests +@pytest.mark.parametrize( + ("schema", "joycon_mode"), + ((3, config_manager.JOYCON_MODE_PAIRED), (4, config_manager.JOYCON_MODE_INDIVIDUAL)), +) def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + schema: int, joycon_mode: int, ) -> None: device = FakeDevice() - device.configuration_schema = 3 - device.configuration = native_rumble_configuration() + device.configuration_schema = schema + device.configuration = native_rumble_configuration(joycon_mode=joycon_mode) first = native_rumble_identity() second = native_rumble_identity(bytes.fromhex("A1A2A3A4A5A6")) for identity in (first, second): @@ -878,13 +904,10 @@ def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings( assert approved.native_switch_controllers == (first,) assert approved.pairing_window_seconds == 90 assert approved.requested_mode == config_manager.REQUESTED_MODE_XINPUT + assert approved.joycon_mode == joycon_mode assert device.profiles == previous_profiles assert device.active_profiles == previous_active assert device.records == previous_pairings - assert config_manager.main(["config", "native-rumble", "list"]) == 0 - output = capsys.readouterr().out - assert f"Approved identity 2: {first.address_text}" in output - assert f"Approved identity 3: {second.address_text}" not in output assert ( config_manager.main(["config", "set", "--pairing-window-seconds", "120"]) == 0 ) @@ -892,10 +915,12 @@ def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings( assert preserved.pairing_window_seconds == 120 assert preserved.requested_mode == config_manager.REQUESTED_MODE_XINPUT assert preserved.native_switch_controllers == (first,) + assert preserved.joycon_mode == joycon_mode config_manager.set_mode(device, config_manager.REQUESTED_MODE_DINPUT, 1.0) preserved = config_manager.read_configuration(device) assert preserved.requested_mode == config_manager.REQUESTED_MODE_DINPUT assert preserved.native_switch_controllers == (first,) + assert preserved.joycon_mode == joycon_mode assert ( config_manager.main(["config", "native-rumble", "revoke", "--identity", "2"]) == 0 @@ -904,6 +929,7 @@ def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings( assert revoked.native_switch_controllers == () assert revoked.requested_mode == config_manager.REQUESTED_MODE_DINPUT assert revoked.pairing_window_seconds == 120 + assert revoked.joycon_mode == joycon_mode @pytest.mark.parametrize("identity_index", ("0", "1", "99")) @@ -985,6 +1011,7 @@ def test_configuration_transaction_and_reset() -> None: before.generation, before.crc, config_manager.REQUESTED_MODE_XINPUT, + joycon_mode=config_manager.JOYCON_MODE_INDIVIDUAL, ), 1.0, ) @@ -992,12 +1019,139 @@ def test_configuration_transaction_and_reset() -> None: stored = config_manager.read_configuration(device) assert stored.pairing_window_seconds == 90 assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT + assert stored.joycon_mode == config_manager.JOYCON_MODE_INDIVIDUAL reset = config_manager.reset_configuration(device, 1.0) assert reset.stored_generation == 5 reset_configuration = config_manager.read_configuration(device) assert reset_configuration.pairing_window_seconds == 60 assert reset_configuration.requested_mode == config_manager.REQUESTED_MODE_AUTO assert reset_configuration.native_switch_controllers == () + assert reset_configuration.joycon_mode == config_manager.JOYCON_MODE_PAIRED + + +@pytest.mark.parametrize( + ("schema", "payload"), + ( + (1, struct.pack(" None: + device = FakeDevice() + device.configuration_schema = schema + device.configuration = payload + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,)) + assert config_manager.main(["joycon-mode", "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == { + "mode": "paired", "generation": 3, "supported": False, + } + configuration = config_manager.read_configuration(device) + assert configuration.joycon_mode == config_manager.JOYCON_MODE_PAIRED + if schema == 3: + assert configuration.native_switch_controllers == (native_rumble_identity(),) + for mode in config_manager.JOYCON_MODE_NAMES: + assert config_manager.main(["joycon-mode", mode, "--json"]) == 1 + assert capsys.readouterr().out == "" + with pytest.raises(config_manager.ConfigManagerError): + config_manager.write_configuration( + device, replace(configuration, joycon_mode=config_manager.JOYCON_MODE_INDIVIDUAL), + 1.0, + ) + assert device.configuration == payload + assert not device.out_requests + + +@pytest.mark.parametrize( + ("offset", "value"), ((4, 2), (4, 255), (5, 1), (6, 1), (7, 1)) +) +def test_joycon_mode_rejects_invalid_wire_encoding(offset: int, value: int) -> None: + device = FakeDevice() + device.configuration_schema = 4 + payload = bytearray(native_rumble_configuration((native_rumble_identity(),))) + payload[offset] = value + device.configuration = bytes(payload) + with pytest.raises(config_manager.ConfigManagerError): + config_manager.read_configuration(device) + + +@pytest.mark.parametrize("size", (8, 231, 233)) +def test_joycon_mode_requires_exact_configuration_size(size: int) -> None: + device = FakeDevice() + device.configuration_schema = 4 + payload = native_rumble_configuration() + device.configuration = (payload + b"\x00")[:size] + with pytest.raises(config_manager.ConfigManagerError): + config_manager.read_configuration(device) + + +@pytest.mark.parametrize("mode", (-1, 2, True, 1.0, "individual")) +def test_joycon_mode_rejects_invalid_values_before_transaction(mode: object) -> None: + device = FakeDevice() + device.configuration_schema = 4 + device.configuration = native_rumble_configuration() + before = config_manager.read_configuration(device) + with pytest.raises(config_manager.ConfigManagerError): + config_manager.set_joycon_mode(device, mode, 1.0) + with pytest.raises(config_manager.ConfigManagerError): + config_manager.write_configuration(device, replace(before, joycon_mode=mode), 1.0) + assert not device.out_requests + + +def test_joycon_mode_cli_commits_and_reads_without_reboot_or_profile_changes( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + device = FakeDevice() + device.configuration_schema = 4 + identity = native_rumble_identity() + device.configuration = native_rumble_configuration((identity,)) + previous_profiles = dict(device.profiles) + previous_active = dict(device.active_profiles) + previous_aliases = dict(device.profile_aliases) + previous_names = dict(device.profile_names) + previous_pairings = list(device.records) + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,)) + for generation, mode in enumerate(("individual", "paired"), start=4): + assert config_manager.main(["joycon-mode", mode, "--json"]) == 0 + expected = {"mode": mode, "generation": generation, "supported": True} + assert json.loads(capsys.readouterr().out) == expected + stored = config_manager.read_configuration(device) + assert stored.joycon_mode == config_manager.JOYCON_MODE_NAMES.index(mode) + assert stored.pairing_window_seconds == 90 + assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT + assert stored.native_switch_controllers == (identity,) + assert device.configuration == native_rumble_configuration( + (identity,), config_manager.JOYCON_MODE_NAMES.index(mode) + ) + writes_before_read = tuple(device.out_requests) + assert config_manager.main(["joycon-mode", "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == expected + assert tuple(device.out_requests) == writes_before_read + assert device.profiles == previous_profiles + assert device.active_profiles == previous_active + assert device.profile_aliases == previous_aliases + assert device.profile_names == previous_names + assert device.records == previous_pairings + assert not device.reboot_transaction_ids + assert not device.bootsel_reboot_requested + + +def test_joycon_mode_cli_commit_failure_does_not_claim_new_preference( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + device = FakeDevice() + device.configuration_schema = 4 + device.configuration = native_rumble_configuration((native_rumble_identity(),)) + before = config_manager.read_configuration(device) + device.fail_configuration_commit_status = 8 + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,)) + assert config_manager.main(["joycon-mode", "individual", "--json"]) == 1 + assert capsys.readouterr().out == "" + assert config_manager.read_configuration(device) == before + assert not device.reboot_transaction_ids def test_configuration_transaction_ids_stay_in_host_range( @@ -1397,6 +1551,20 @@ def test_reenumeration_reports_missing_disappearance_and_return( config_manager._wait_for_reenumeration(snapshot, 0) +def test_runtime_diagnostics_distinguishes_unreported_from_zero_drops() -> None: + device = FakeDevice() + legacy = config_manager.read_runtime_diagnostics(device) + assert legacy.switch2_ingress_drops is None + assert legacy.switch2_output_drops is None + device.runtime_diagnostics_tail = struct.pack(" None: device = FakeDevice() device.configuration = struct.pack( @@ -1459,6 +1627,129 @@ def test_requested_and_active_mode_response_validation() -> None: config_manager.read_info(device) +@pytest.mark.parametrize( + ("left_type", "right_type", "flags"), [(0, 0, 1), (1, 0, 3), (0, 1, 5), (1, 1, 7)] +) +def test_joycon_pair_wire_round_trip_preserves_both_typed_members( + left_type: int, right_type: int, flags: int, +) -> None: + left = config_manager.ControllerIdentity( + True, config_manager.TRANSPORT_BLE, left_type, + bytes.fromhex("C10203040506"), 0x057E, 0x2067, + ) + right = config_manager.ControllerIdentity( + True, config_manager.TRANSPORT_BLE, right_type, + bytes.fromhex("D11213141516"), 0x057E, 0x2066, + ) + pair = config_manager.ControllerIdentity.make_joycon_pair(left, right) + wire = bytes((flags, 3)) + left.address + right.address + assert pair.to_bytes() == wire + assert config_manager.ControllerIdentity.from_bytes(wire) == pair + assert pair.joycon_pair_members() == (left, right) + assert pair.to_bytes() not in (left.to_bytes(), right.to_bytes()) + other_right = replace(right, address=bytes.fromhex("D11213141517")) + assert config_manager.ControllerIdentity.make_joycon_pair(left, other_right) != pair + + +def test_joycon_pair_distinguishes_address_types_without_normalizing_members() -> None: + left = config_manager.ControllerIdentity( + True, config_manager.TRANSPORT_BLE, 0, + bytes.fromhex("C10203040506"), 0x057E, 0x2067, + ) + right = replace(left, address_type=1, product_id=0x2066) + pair = config_manager.ControllerIdentity.make_joycon_pair(left, right) + assert pair.to_bytes() == bytes.fromhex("0503C10203040506C10203040506") + assert pair.joycon_pair_members() == (left, right) + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerIdentity.make_joycon_pair(left, replace(right, address_type=0)) + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerIdentity.make_joycon_pair(right, left) + + +@pytest.mark.parametrize("wire", [ + "0003102030405060C12233445566", # Stable flag missing. + "0D03102030405060C12233445566", # Reserved flag. + "0303102030405060C12233445566", # Left random address is not static. + "0503C12233445566102030405060", # Right random address is not static. + "0103102030405060102030405060", # Duplicate typed members. +]) +def test_joycon_pair_rejects_malformed_wire(wire: str) -> None: + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerIdentity.from_bytes(bytes.fromhex(wire)) + + +@pytest.mark.parametrize("malformation", [ + "global", "classic", "vendor", "model", "address_type", "random_address", +]) +def test_joycon_pair_requires_legitimate_ble_members(malformation: str) -> None: + pair = config_manager.ControllerIdentity.from_bytes( + bytes.fromhex("0503102030405060C12233445566") + ) + left, right = pair.joycon_pair_members() + invalid = { + "global": config_manager.ControllerIdentity.global_fallback(), + "classic": replace(right, transport=config_manager.TRANSPORT_CLASSIC), + "vendor": replace(right, vendor_id=0x045E), + "model": replace(right, product_id=0x2069), + "address_type": replace(right, address_type=3), + "random_address": replace(right, address=bytes.fromhex("412233445566")), + }[malformation] + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerIdentity.make_joycon_pair(left, invalid) + + +@pytest.mark.parametrize("fields", [ + {"stable": False}, + {"vendor_id": 0x045E}, + {"product_id": 0x2066}, + {"address_type": 2}, + {"partner_address_type": 3}, +]) +def test_joycon_pair_rejects_inconsistent_in_memory_identity(fields: dict[str, object]) -> None: + pair = config_manager.ControllerIdentity.from_bytes( + bytes.fromhex("0503102030405060C12233445566") + ) + with pytest.raises(config_manager.ConfigManagerError): + replace(pair, **fields) + + +@pytest.mark.parametrize("fields", [ + {"partner_address_type": 1}, + {"partner_address": bytes.fromhex("C12233445566")}, +]) +def test_physical_identity_cannot_hide_pair_members(fields: dict[str, object]) -> None: + with pytest.raises(config_manager.ConfigManagerError): + replace(native_rumble_identity(), **fields) + + +def test_pairing_inventory_rejects_logical_profile_owners() -> None: + device = FakeDevice() + device.records = [(config_manager.TRANSPORT_JOYCON_PAIR, 0, bytes.fromhex("102030405060"))] + with pytest.raises(config_manager.ConfigManagerError): + config_manager.read_pairings(device) + + +def test_profile_cli_lists_both_pair_addresses_but_native_inventory_omits_pair( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + device = FakeDevice() + pair = config_manager.ControllerIdentity.from_bytes( + bytes.fromhex("0503102030405060C12233445566") + ) + device.profile_identities.append(pair) + device.active_profiles[pair.to_bytes()] = 4 + monkeypatch.setattr(config_manager, "find_pico", lambda *_: device) + assert config_manager.main(["profiles", "list"]) == 0 + listing = capsys.readouterr().out + assert pair.address_text in listing + assert pair.partner_address_text in listing + assert pair.transport_text in listing + assert config_manager.main(["config", "native-rumble", "list"]) == 0 + physical = capsys.readouterr().out + assert pair.partner_address_text not in physical + assert device.stable_identity.address_text in physical + + def test_identity_and_profile_binary_json_round_trip() -> None: identity = config_manager.ControllerIdentity( True, @@ -1576,7 +1867,7 @@ def test_identity_and_profile_binary_json_round_trip() -> None: legacy_json_object = default_profile.to_json_object() legacy_json_object["schema_version"] = config_manager.PROFILE_LEGACY_SCHEMA_VERSION legacy_json_object["size"] = config_manager.PROFILE_LEGACY_SIZE - for field in ("shortcuts", "shift", "turbo_settings"): + for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map"): del legacy_json_object[field] del legacy_json_object["motion_toggle_chord"] del legacy_json_object["triggers"]["left"]["output"] @@ -1639,7 +1930,7 @@ def test_schema5_full_macro_stream_migrates_bytes_and_json(monkeypatch) -> None: obj["schema_version"] = 5 obj["size"] = 256 - for field in ("shortcuts", "shift", "turbo_settings"): + for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map"): del obj[field] for macro in obj["macros"]: del macro["playback"] @@ -1678,10 +1969,133 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None: assert encoded[283:294] == bytes((30, 99, 255, 4, 128, 1, 1, 1, 23, 37, 17)) assert encoded[294:336] == bytes(42) assert encoded[336:344] == bytes((3, 255, 0, 1, 1, 1, 2, 1)) - assert encoded[344:] == bytes(40) + assert encoded[344:358] == bytes([255]) * 14 + assert encoded[358:] == bytes(26) assert config_manager.ControllerProfile.from_bytes(encoded) == profile assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile + legacy_wire = bytearray(encoded) + struct.pack_into(" None: + obj = custom_profile().to_json_object() + obj["extra_button_map"] = dict(zip( + config_manager.EXTRA_BUTTONS, + ("south", "right_trigger", "left_trigger", None, "dpad_left", "start", "capture"), + )) + obj["shift"]["mode"] = "hold" + obj["shift"]["modifier"] = "gl" + obj["shift"]["extra_button_map"] = dict(zip( + config_manager.EXTRA_BUTTONS, + ("east", None, "west", "north", "system", "dpad_up", "dpad_right"), + )) + obj["shortcuts"]["modifier"] = "left_sr" + obj["shortcuts"]["profiles"][0] = "south" + obj["switching_chord"] = ["left_trigger", "c", "right_sr"] + obj["motion_toggle_chord"] = ["right_trigger", "gl", "left_sl"] + for index, names in enumerate((["c", "gl"], ["gr"], ["left_sl", "left_sr"], ["right_sl", "right_sr"])): + obj["macros"][index]["trigger"] = names + obj["macros"][index]["cancel"] = config_manager.EXTRA_BUTTONS[index + 3] + profile = config_manager.ControllerProfile.from_json_object(obj) + encoded = profile.to_bytes() + assert encoded[:4] == struct.pack(" None: + obj = config_manager.ControllerProfile.default().to_json_object() + target = obj + for key in path[:-1]: + target = target[key] + target[path[-1]] = "right_sr" + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerProfile.from_json_object(obj) + + +@pytest.mark.parametrize("offset,value", [ + (98, 18 << 2), (256, 18), (266, 24), (344, 1), (358, 1), (363, 1), +]) +def test_schema6_rejects_schema7_controls_in_old_fields(offset: int, value: int) -> None: + payload = bytearray(config_manager.ControllerProfile.default().to_bytes()) + struct.pack_into(" None: + payload = bytearray(config_manager.ControllerProfile.default().to_bytes()) + payload[offset] = value + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerProfile.from_bytes(payload) + + +@pytest.mark.parametrize("version", [3, 4]) +def test_legacy_control_profiles_preserve_custom_actions(version: int) -> None: + payload = legacy_profile_wire(version, macro_trigger=3, macro_cancel=2) + payload[4] = 17 + payload[72:75] = bytes((71, 202, 1)) + struct.pack_into(" None: config_manager.parse_profile_playtest(envelope) +def test_playtest_layout_and_extra_inputs_preserve_legacy_firmware() -> None: + device = FakeDevice() + device.stable_identity = replace(device.stable_identity, vendor_id=0x057E, product_id=0x2067) + device.playtest_extra_buttons = 0x55 + device.playtest_layout = 3 + payload, flags = device._profile_playtest_payload() + + def parse(data: bytes, schema: int) -> config_manager.ProfilePlaytest: + return config_manager.parse_profile_playtest(config_manager.parse_response( + make_response(config_manager.OP_PROFILE_PLAYTEST, data, flags=flags, schema=schema), + config_manager.OP_PROFILE_PLAYTEST, + )) + + current = parse(payload, 4) + assert current.to_json_object()["layout"] == "joycon2-pair" + assert current.to_json_object()["extra_buttons"] == ["c", "gr", "left_sr", "right_sr"] + 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[:55] + b"\x00", 4).layout is None + for data, schema in ( + (payload[:54] + b"\x80", 3), + (payload[:54] + b"\x80\x03", 4), + (payload[:55] + b"\x06", 4), + (payload[:55] + b"\xff", 4), + (payload, 2), + (payload, 3), + (payload[:55], 4), + (payload + b"\x00", 4), + (payload, 5), + ): + with pytest.raises(config_manager.ConfigManagerError): + parse(data, schema) + + device.playtest_connected = False + disconnected, flags = device._profile_playtest_payload() + assert parse(disconnected, 4).layout is None + with pytest.raises(config_manager.ConfigManagerError): + parse(disconnected[:55] + b"\x03", 4) + + def test_profile_reset_and_activate_wait_for_correlated_transactions( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_configuration_service_native.py b/tests/test_configuration_service_native.py index 9f3b03a..5ef4d94 100644 --- a/tests/test_configuration_service_native.py +++ b/tests/test_configuration_service_native.py @@ -58,6 +58,7 @@ def test_configuration_service_native(tmp_path: Path) -> None: for scenario in ( "lifecycle", "v2-migration", + "v3-migration", "native-approvals", "abandoned-receive", ): diff --git a/tests/test_profile_web.py b/tests/test_profile_web.py index 2eee9c9..eb0b40a 100644 --- a/tests/test_profile_web.py +++ b/tests/test_profile_web.py @@ -10,9 +10,10 @@ from dataclasses import replace from typing import Any import pytest +import usb.core from switch_pico_bridge import config_manager, profile_web -from tests.test_config_manager import FakeDevice, custom_profile +from tests.test_config_manager import FakeDevice, custom_profile, native_rumble_identity @contextmanager @@ -63,35 +64,50 @@ def test_editor_serves_assets_and_complete_schema( ) -> None: with running_server(monkeypatch, FakeDevice()) as (base_url, _): with urllib.request.urlopen(f"{base_url}/", timeout=2) as response: - page = response.read().decode("utf-8") + response.read() assert response.headers["Content-Security-Policy"] with urllib.request.urlopen(f"{base_url}/app.js", timeout=2) as response: - script_size = len(response.read()) + response.read() assert response.headers["Content-Type"].startswith("text/javascript") - with urllib.request.urlopen( - f"{base_url}/assets/controller-switch-pro.svg", timeout=2 - ) as response: - artwork_size = len(response.read()) - assert response.headers["Content-Type"] == "image/svg+xml" + for filename in ( + "switch-pro-controller-simple.svg", + "switch-2-pro-controller-simple.svg", + "switch-2-joycon-left.svg", + "switch-2-joycon-single.svg", + "switch-2-joycons-connected.svg", + "ps5-dualsense-simple.svg", + "xbox-controller-simple.svg", + "wii-remote-simple.svg", + "wii-remote-nunchuk-simple.svg", + ): + with urllib.request.urlopen( + f"{base_url}/assets/{filename}", timeout=2 + ) as response: + assert response.headers["Content-Type"] == "image/svg+xml" + assert b" 1000 - assert artwork_size > 10000 assert schema["buttons"] == list(config_manager.LOGICAL_BUTTONS) assert schema["controls"] == list(config_manager.LOGICAL_CONTROLS) assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES) assert schema["turbo_modes"] == list(config_manager.TURBO_MODES) assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES) assert schema["profile_capacity"] == 8 - assert schema["control_labels"]["generic"]["south"] == "A" - assert schema["control_labels"]["xbox"]["left_shoulder"] == "LB" - assert schema["control_labels"]["switch"]["east"] == "A" - assert schema["control_labels"]["switch"]["left_trigger"] == "ZL" - assert schema["control_labels"]["playstation"]["south"] == "Cross" - assert schema["control_labels"]["playstation"]["select"] == "Create" assert ( config_manager.ControllerProfile.from_json( json.dumps(schema["default_profile"]) @@ -100,6 +116,80 @@ def test_editor_serves_assets_and_complete_schema( ) +@pytest.mark.parametrize(("product_id", "extras"), [ + (0x2069, {"c", "gl", "gr"}), + (0x2067, {"left_sl", "left_sr"}), + (0x2066, {"c", "right_sl", "right_sr"}), +]) +def test_switch2_input_choices_are_never_output_targets( + monkeypatch: pytest.MonkeyPatch, product_id: int, extras: set[str], +) -> None: + device = FakeDevice() + identity = replace(device.stable_identity, vendor_id=0x057E, product_id=product_id) + device.profile_identities = [device.global_identity, identity] + device.active_profiles[identity.to_bytes()] = 0 + with running_server(monkeypatch, device) as (base_url, _): + status, listing = request_json(f"{base_url}/api/profiles") + assert status == 200 + status, schema = request_json(f"{base_url}/api/schema") + assert status == 200 + owner = listing["identities"][1] + assert owner["controller"]["style"] == "switch" + assert set(owner["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == extras + assert set(owner["modifier_controls"]) & set(config_manager.EXTRA_BUTTONS) == extras + assert set(schema["extra_buttons"]).isdisjoint(schema["output_controls"]) + assert schema["output_controls"] == list(config_manager.OUTPUT_CONTROLS) + + +def test_editor_migrates_schema6_and_saves_extra_mappings_without_metadata_loss( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + key = (device.stable_identity.to_bytes(), 1) + profile = custom_profile() + legacy_wire = bytearray(profile.to_bytes()) + legacy_wire[:2] = b"\x06\x00" + legacy_wire[344:] = bytes(40) + device.profiles[key] = bytes(legacy_wire) + device.profile_aliases[key[0]] = "Living room" + device.profile_names[key] = "Racing" + device.playtest_extra_buttons = 0x7F + with running_server(monkeypatch, device) as (base_url, token): + status, migrated = request_json(f"{base_url}/api/profiles/1/2") + assert status == 200 + assert config_manager.ControllerProfile.from_json_object(migrated["profile"]) == profile + draft = migrated["profile"] + draft["extra_button_map"] = dict(zip(config_manager.EXTRA_BUTTONS, config_manager.LOGICAL_BUTTONS[:7])) + draft["shift"]["mode"] = "hold" + draft["shift"]["modifier"] = "c" + draft["shift"]["extra_button_map"]["right_sr"] = "dpad_right" + draft["macros"][0]["trigger"] = ["gl", "right_sr"] + draft["macros"][0]["cancel"] = "gr" + draft["switching_chord"] = ["left_sl", "left_sr"] + draft["motion_toggle_chord"] = ["right_sl", "c"] + status, validated = request_json( + f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token, + ) + assert status == 200 + expected_profile = config_manager.ControllerProfile.from_json_object(draft) + assert config_manager.ControllerProfile.from_json_object(validated["profile"]) == expected_profile + assert device.profiles[key] == bytes(legacy_wire) + status, _ = request_json( + f"{base_url}/api/profiles/1/2", method="PUT", value=draft, token=token, + ) + assert status == 200 + status, stored = request_json(f"{base_url}/api/profiles/1/2") + assert status == 200 + assert config_manager.ControllerProfile.from_json_object(stored["profile"]) == expected_profile + assert stored["alias"] == "Living room" + assert stored["name"] == "Racing" + assert stored["active"] is True + status, sample = request_json(f"{base_url}/api/profiles/1/2/playtest") + assert status == 200 + assert sample["extra_buttons"] == list(config_manager.EXTRA_BUTTONS) + assert sample["buttons"] == ["south", "dpad_up", "dpad_right"] + + def test_editor_identifies_connected_controller_artwork( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -134,23 +224,228 @@ def test_editor_identifies_connected_controller_artwork( status, listing = request_json(f"{base_url}/api/profiles") assert status == 200 - assert [identity["controller"] for identity in listing["identities"]] == [ - {"model": "Generic controller", "style": "generic"}, - {"model": "Nintendo Switch Pro Controller", "style": "switch"}, - {"model": "Sony DualSense", "style": "playstation"}, - {"model": "Xbox controller", "style": "xbox"}, - ] - assert [identity["label"] for identity in listing["identities"]] == [ - "Default profile", - "Switch Pro · 05:06", - "DualSense · 15:16", - "Xbox · 50:60", + assert [ + (identity["controller"]["style"], identity["controller"]["layout"]) + for identity in listing["identities"] + ] == [ + ("generic", "generic"), ("switch", "switch-pro"), + ("playstation", "dualsense"), ("xbox", "xbox"), ] assert [identity["key"] for identity in listing["identities"]] == [ identity.to_bytes().hex() for identity in device.profile_identities ] +@pytest.fixture +def joycon_pair_device() -> FakeDevice: + device = FakeDevice() + pair = config_manager.ControllerIdentity.from_bytes( + bytes.fromhex("0503102030405060C12233445566") + ) + left, right = pair.joycon_pair_members() + device.profile_identities = [device.global_identity, left, right, pair] + device.stable_identity = pair + device.active_profiles = { + identity.to_bytes(): index for index, identity in enumerate(device.profile_identities) + } + default_profile = config_manager.ControllerProfile.default().to_bytes() + device.profiles = { + (identity.to_bytes(), index): default_profile + for identity in device.profile_identities + for index in range(config_manager.PROFILE_CAPACITY) + } + device.playtest_layout = 3 + device.playtest_motion = None + return device + + +def test_persistent_pair_owner_is_offline_capable_and_isolated_from_solo_banks( + monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice, +) -> None: + device = joycon_pair_device + _, left, right, pair = device.profile_identities + device.playtest_connected = False + with running_server(monkeypatch, device) as (base_url, token): + status, listing = request_json(f"{base_url}/api/profiles") + assert status == 200 + owners = listing["identities"] + assert [owner["key"] for owner in owners] == [ + identity.to_bytes().hex() for identity in device.profile_identities + ] + assert [owner["controller"]["layout"] for owner in owners[1:]] == [ + "joycon2-left", "joycon2-right", "joycon2-pair", + ] + owner = owners[3] + assert owner["controller"]["model"] != owners[1]["controller"]["model"] + assert "50:60" in owner["label"] and "55:66" in owner["label"] + assert owner["identity"]["members"]["left"]["address"] == left.address_text + assert owner["identity"]["members"]["left"]["address_type"] == 0 + assert owner["identity"]["members"]["right"]["address"] == right.address_text + assert owner["identity"]["members"]["right"]["address_type"] == 1 + assert set(owner["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == { + "c", "left_sl", "left_sr", "right_sl", "right_sr", + } + status, offline = request_json(f"{base_url}/api/profiles/3/8/playtest") + assert status == 200 + assert offline["connected"] is False + assert offline["controller"] == owner["controller"] + assert offline["source_controls"] == owner["source_controls"] + + expected = { + index: config_manager.ControllerProfile.default().to_json_object() + for index in (1, 2, 3) + } + for bank, output in ((3, "north"), (1, "east"), (2, "west")): + expected[bank]["button_map"]["south"] = output + status, _ = request_json( + f"{base_url}/api/profiles/{bank}/8", method="PUT", + value=expected[bank], token=token, + ) + assert status == 200 + for other_bank, expected_profile in expected.items(): + status, stored = request_json(f"{base_url}/api/profiles/{other_bank}/8") + assert status == 200 + assert stored["profile"] == expected_profile + for path, value in ( + ("profiles/3/8/name", "Pair platformer"), + ("identities/3/alias", "Couch pair"), + ): + status, _ = request_json( + f"{base_url}/api/{path}", method="PUT", value={"value": value}, token=token, + ) + assert status == 200 + status, _ = request_json( + f"{base_url}/api/profiles/3/8/activate", method="POST", token=token, + ) + assert status == 200 + status, listing = request_json(f"{base_url}/api/profiles") + assert status == 200 + assert [owner["active_profile"] for owner in listing["identities"]] == [1, 2, 3, 8] + assert listing["identities"][3]["label"] == "Couch pair" + assert listing["identities"][3]["key"] == pair.to_bytes().hex() + for bank in (1, 2, 3): + status, stored = request_json(f"{base_url}/api/profiles/{bank}/8") + assert status == 200 + assert stored["name"] == ("Pair platformer" if bank == 3 else "") + assert stored["alias"] == ("Couch pair" if bank == 3 else "") + + device.playtest_connected = True + for bank in (1, 2, 3): + status, live = request_json(f"{base_url}/api/profiles/{bank}/8/playtest") + assert status == 200 + assert live["owner_key"] == device.profile_identities[bank].to_bytes().hex() + assert live["identity_key"] == pair.to_bytes().hex() + assert (live["identity_key"] == live["owner_key"]) == (bank == 3) + assert live["label"] == "Couch pair" + other_pair = config_manager.ControllerIdentity.make_joycon_pair( + left, replace(right, address=bytes.fromhex("C12233445567")) + ) + device.stable_identity = other_pair + status, other_live = request_json(f"{base_url}/api/profiles/3/8/playtest") + assert status == 200 + assert other_live["owner_key"] == pair.to_bytes().hex() + assert other_live["identity_key"] == other_pair.to_bytes().hex() + assert other_live["owner_key"] != other_live["identity_key"] + + +def test_capture_cannot_bind_pair_input_to_a_solo_owner( + monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice, +) -> None: + device = joycon_pair_device + left = device.profile_identities[1] + with running_server(monkeypatch, device) as (base_url, token): + status, _ = request_json(f"{base_url}/api/profiles/1/1/playtest") + assert status == 200 + status, _ = request_json( + f"{base_url}/api/profiles/1/1/capture/start", method="POST", token=token, + value={ + "owner_key": left.to_bytes().hex(), + "capture_id": "solo-bank-paired-input", + "slot": device.playtest_slot, + "connection_generation": device.playtest_connection_generation, + "macro_index": 0, + "profile": config_manager.ControllerProfile.default().to_json_object(), + "channels": 1, + "max_events": 8, + "axis_quantum": 512, + "trigger_quantum": 1024, + "max_duration_ms": 1000, + }, + ) + assert status == 400 + + +@pytest.mark.parametrize("owner_index", [0, 1]) +def test_live_layout_transitions_do_not_infer_topology_from_profile_owner( + monkeypatch: pytest.MonkeyPatch, owner_index: int, +) -> None: + device = FakeDevice() + left = replace(device.stable_identity, vendor_id=0x057E, product_id=0x2067) + device.stable_identity = left + device.profile_identities = [device.global_identity, left] + device.active_profiles[left.to_bytes()] = 0 + device.profiles[(left.to_bytes(), 0)] = config_manager.ControllerProfile.default().to_bytes() + device.playtest_motion = None + with running_server(monkeypatch, device) as (base_url, _): + for code, expected in ((1, "joycon2-left"), (3, "joycon2-pair"), (1, "joycon2-left")): + device.playtest_layout = code + status, sample = request_json(f"{base_url}/api/profiles/{owner_index}/1/playtest") + assert status == 200 + assert sample["controller"]["layout"] == expected + assert sample["owner_key"] == device.profile_identities[owner_index].to_bytes().hex() + assert sample["identity_key"] == left.to_bytes().hex() + assert sample["identity"]["is_joycon_pair"] is False + expected_extras = ( + {"c", "left_sl", "left_sr", "right_sl", "right_sr"} + if code == 3 else {"left_sl", "left_sr"} + ) + assert set(sample["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == expected_extras + status, listing = request_json(f"{base_url}/api/profiles") + assert status == 200 + assert [owner["key"] for owner in listing["identities"]] == [ + device.global_identity.to_bytes().hex(), left.to_bytes().hex(), + ] + assert listing["identities"][1]["controller"]["layout"] == "joycon2-left" + device.playtest_connected = False + status, offline = request_json(f"{base_url}/api/profiles/{owner_index}/1/playtest") + assert status == 200 + assert offline["connected"] is False + assert offline["layout"] is None + assert offline["controller"] == listing["identities"][owner_index]["controller"] + + +def test_live_metadata_cannot_turn_an_unrelated_identity_into_a_pair( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + device.playtest_layout = 3 + with running_server(monkeypatch, device) as (base_url, _): + status, sample = request_json(f"{base_url}/api/profiles/0/1/playtest") + assert status == 200 + assert sample["controller"]["layout"] == "xbox" + + +def test_wii_pid_does_not_claim_a_remote_or_extension_without_live_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + device.stable_identity = replace(device.stable_identity, vendor_id=0x057E, product_id=0x0330) + device.profile_identities = [device.global_identity, device.stable_identity] + device.active_profiles[device.stable_identity.to_bytes()] = 0 + with running_server(monkeypatch, device) as (base_url, _): + 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")): + device.playtest_layout = code + status, sample = request_json(f"{base_url}/api/profiles/0/1/playtest") + assert status == 200 + assert sample["controller"]["layout"] == expected + if code: + 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: @@ -162,19 +457,12 @@ def test_editor_reads_writes_and_activates_profiles_atomically( assert listing["identities"][1]["key"] == ( device.stable_identity.to_bytes().hex() ) - assert listing["identities"][1]["controller"] == { - "model": "Xbox controller", - "style": "xbox", - } + assert listing["identities"][1]["controller"]["layout"] == "xbox" status, playtest = request_json(f"{base_url}/api/profiles/1/8/playtest") assert status == 200 assert playtest["connected"] is True - assert playtest["label"] == "Xbox · 50:60" - assert playtest["controller"] == { - "model": "Xbox controller", - "style": "xbox", - } + assert playtest["controller"]["layout"] == "xbox" assert playtest["buttons"] == [ "south", "dpad_up", @@ -317,6 +605,184 @@ def test_editor_rejects_invalid_or_unauthorized_mutations( assert device.profiles[(device.global_identity.to_bytes(), 0)] == original +def test_joycon_mode_preserves_configuration_and_all_profile_banks( + monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice, +) -> None: + device = joycon_pair_device + config_manager.write_configuration( + device, + config_manager.AdapterConfiguration( + 135, 0, 0, config_manager.REQUESTED_MODE_XINPUT, + (native_rumble_identity(),), + ), + 1.0, + ) + for index, identity in enumerate(device.profile_identities): + key = identity.to_bytes() + device.profiles[(key, index)] = custom_profile().to_bytes() + device.profile_names[(key, index)] = f"Layout {index}" + device.profile_aliases[key] = f"Controller {index}" + before = config_manager.read_configuration(device) + profiles = device.profiles.copy() + names = device.profile_names.copy() + aliases = device.profile_aliases.copy() + active = device.active_profiles.copy() + pairings = config_manager.read_pairings(device) + with running_server(monkeypatch, device) as (base_url, token): + status, initial = request_json(f"{base_url}/api/joycon-mode") + assert status == 200 + assert initial == { + "mode": "paired", "generation": before.generation, "supported": True, + } + for mode in ("individual", "paired"): + status, committed = request_json( + f"{base_url}/api/joycon-mode", method="PUT", + value={"mode": mode}, token=token, + ) + assert status == 200 + stored = config_manager.read_configuration(device) + assert committed == { + "mode": mode, "generation": stored.generation, "supported": True, + } + assert stored.joycon_mode == config_manager.JOYCON_MODE_NAMES.index(mode) + assert stored.generation > before.generation + assert replace( + stored, joycon_mode=before.joycon_mode, + generation=before.generation, crc=before.crc, + ) == before + assert request_json(f"{base_url}/api/joycon-mode") == (200, committed) + assert device.profiles == profiles + assert device.profile_names == names + assert device.profile_aliases == aliases + assert device.active_profiles == active + assert config_manager.read_pairings(device) == pairings + assert device.reboot_transaction_ids == [] + + +@pytest.mark.parametrize("schema", [1, 2, 3]) +def test_joycon_mode_legacy_reads_default_and_refuses_mutations( + monkeypatch: pytest.MonkeyPatch, schema: int, +) -> None: + device = FakeDevice() + config_manager.write_configuration( + device, + config_manager.AdapterConfiguration( + 95, 0, 0, + native_switch_controllers=(native_rumble_identity(),) if schema == 3 else (), + schema_version=schema, + ), + 1.0, + ) + before = config_manager.read_configuration(device) + with running_server(monkeypatch, device) as (base_url, token): + status, legacy = request_json(f"{base_url}/api/joycon-mode") + assert status == 200 + assert legacy == { + "mode": "paired", "generation": before.generation, "supported": False, + } + for mode in ("paired", "individual"): + status, _ = request_json( + f"{base_url}/api/joycon-mode", method="PUT", + value={"mode": mode}, token=token, + ) + assert status == 400 + assert config_manager.read_configuration(device) == before + + +def test_joycon_mode_rejects_unauthorized_and_malformed_requests( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + config_manager.write_configuration( + device, config_manager.AdapterConfiguration(110, 0, 0), 1.0, + ) + before = config_manager.read_configuration(device) + with running_server(monkeypatch, device) as (base_url, token): + endpoint = f"{base_url}/api/joycon-mode" + for supplied_token in (None, "invalid"): + status, _ = request_json( + endpoint, method="PUT", value={"mode": "individual"}, + token=supplied_token, + ) + assert status == 403 + for body in ( + {}, [], {"mode": 1}, {"mode": True}, {"mode": None}, + {"mode": ["individual"]}, {"mode": "Individual"}, + {"mode": "individual", "pairing_window_seconds": 10}, + ): + status, _ = request_json( + endpoint, method="PUT", value=body, token=token, + ) + assert status == 400 + for raw in (b"", b"{", b"\xff", b" " * (profile_web._MAXIMUM_REQUEST_BYTES + 1)): + request = urllib.request.Request( + endpoint, data=raw, method="PUT", + headers={"X-Switch-Pico-Token": token}, + ) + with pytest.raises(urllib.error.HTTPError) as rejected: + urllib.request.urlopen(request, timeout=2) + assert rejected.value.code == 400 + for method in ("GET", "PUT"): + request = urllib.request.Request( + endpoint, method=method, + data=b'{"mode":"individual"}' if method == "PUT" else None, + headers={"Host": "untrusted.example", "X-Switch-Pico-Token": token}, + ) + with pytest.raises(urllib.error.HTTPError) as rejected: + urllib.request.urlopen(request, timeout=2) + assert rejected.value.code == 403 + assert config_manager.read_configuration(device) == before + + +def test_joycon_mode_commit_is_not_reported_as_confirmed_when_readback_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + config_manager.write_configuration( + device, config_manager.AdapterConfiguration(110, 0, 0), 1.0, + ) + read_configuration = config_manager.read_configuration + before = read_configuration(device) + + def disconnect_after_commit(device: config_manager.UsbDevice) -> config_manager.AdapterConfiguration: + configuration = read_configuration(device) + if configuration.generation != before.generation: + raise usb.core.USBError("device disconnected") + return configuration + + monkeypatch.setattr(config_manager, "read_configuration", disconnect_after_commit) + with running_server(monkeypatch, device) as (base_url, token): + status, response = request_json( + f"{base_url}/api/joycon-mode", method="PUT", + value={"mode": "individual"}, token=token, + ) + assert status == 503 + assert "error" in response + assert "mode" not in response + assert read_configuration(device).joycon_mode == config_manager.JOYCON_MODE_INDIVIDUAL + + +@pytest.mark.parametrize("method", ["GET", "PUT"]) +def test_joycon_mode_usb_failure_returns_service_unavailable( + monkeypatch: pytest.MonkeyPatch, method: str, +) -> None: + device = FakeDevice() + before = device.configuration + + def disconnected(*args: Any, **kwargs: Any) -> Any: + raise usb.core.USBError("device disconnected") + + monkeypatch.setattr(device, "ctrl_transfer", disconnected) + with running_server(monkeypatch, device) as (base_url, token): + status, response = request_json( + f"{base_url}/api/joycon-mode", method=method, + value={"mode": "individual"} if method == "PUT" else None, token=token, + ) + assert status == 503 + assert "error" in response + assert device.configuration == before + + def test_recorder_accepts_first_connection_generation_zero( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_switch2_haptics_native.py b/tests/test_switch2_haptics_native.py new file mode 100644 index 0000000..1de129d --- /dev/null +++ b/tests/test_switch2_haptics_native.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_switch2_haptics_encoding(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("cc") or shutil.which("gcc") + assert compiler is not None, "a host C compiler is required" + executable = tmp_path / "switch2_haptics" + subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root / 'bluepad32_config'}", + str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"), + str(root / "tests" / "switch2_haptics_test.c"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_switch2_pairing_native.py b/tests/test_switch2_pairing_native.py new file mode 100644 index 0000000..e6b008f --- /dev/null +++ b/tests/test_switch2_pairing_native.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_switch2_pairing_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("cc") or shutil.which("gcc") + assert compiler is not None, "a host C compiler is required" + executable = tmp_path / "switch2_pairing" + subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root / 'tests' / 'switch2_pairing_native_stubs'}", + f"-I{root / 'bluepad32_config'}", + str(root / "bluepad32_config" / "parser" / "uni_switch2_pairing.c"), + str(root / "tests" / "switch2_pairing_test.c"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_switch2_parser_native.py b/tests/test_switch2_parser_native.py new file mode 100644 index 0000000..11776d1 --- /dev/null +++ b/tests/test_switch2_parser_native.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) + +from prepare_bluepad32 import prepare_bluepad32 + + +def test_switch2_parser_protocol_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" + sdk_candidates = [ + root / "build" / "_deps" / "pico_sdk-src", + root / "external" / "pico-sdk", + ] + if sdk_path := os.environ.get("PICO_SDK_PATH"): + sdk_candidates.insert(0, Path(sdk_path)) + btstack = next( + (sdk / "lib" / "btstack" / "src" for sdk in sdk_candidates + if (sdk / "lib" / "btstack" / "src" / "ble" / "gatt_client.h").is_file()), + None, + ) + if btstack is None: + pytest.skip("Pico SDK BTstack headers required; configure firmware or set PICO_SDK_PATH") + prepared = prepare_bluepad32( + root / "external" / "bluepad32", + root / "patches" / "bluepad32-sdl3-imu.patch", + tmp_path / "bluepad32-src", + ) + component = prepared / "src" / "components" / "bluepad32" + executable = tmp_path / "switch2_parser_native_test" + subprocess.run( + [ + compiler, + "-std=gnu11", + "-O1", + "-Wall", + "-Wextra", + "-ffunction-sections", + "-fdata-sections", + "-DENABLE_BLE", + "-DENABLE_CLASSIC", + f"-I{root / 'tests' / 'switch2_parser_native_stubs'}", + f"-I{root / 'bluepad32_config'}", + f"-I{component / 'include'}", + f"-I{btstack}", + f"-I{btstack.parent / '3rd-party' / 'bluedroid' / 'encoder' / 'include'}", + f"-I{btstack.parent / '3rd-party' / 'bluedroid' / 'decoder' / 'include'}", + f"-I{btstack.parent / '3rd-party' / 'yxml'}", + str(root / "tests" / "switch2_parser_native_test.c"), + str(root / "bluepad32_config" / "parser" / "uni_hid_parser_switch2.c"), + str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"), + str(btstack / "btstack_util.c"), + "-Wl,--gc-sections", + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 0265f95..eebcef6 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -188,6 +188,10 @@ void test_pairing_encoding() { memcmp(&payload[kResponseHeaderSize + 6], classic_address, 6) == 0, "pairings were not migrated into the versioned envelope"); + snapshot.status = Bluepad32PairingSnapshotStatus::kFailed; + require(encode_pairing_snapshot(snapshot, payload, sizeof(payload)) == size && + payload[6] == static_cast(Status::kStorageError), + "failed persistent pairing clear must report storage failure, not success or pending"); } void perform_out(UsbConfigurationManagement::Operation operation, @@ -224,7 +228,7 @@ void test_vendor_requests() { "pairing read did not use the versioned envelope"); current_diagnostics = { - 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1, + 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1, 3, UINT32_MAX, }; request = setup_request( Operation::kRuntimeDiagnostics, TUSB_DIR_IN, @@ -237,7 +241,9 @@ void test_vendor_requests() { read_u32(control_payload, kResponseHeaderSize) == 6 && read_u32(control_payload, kResponseHeaderSize + 4) == 1200 && control_payload[kResponseHeaderSize + 28] == 2 && - control_payload[kResponseHeaderSize + 31] == 1, + control_payload[kResponseHeaderSize + 31] == 1 && + read_u32(control_payload, kResponseHeaderSize + 32) == 3 && + read_u32(control_payload, kResponseHeaderSize + 36) == UINT32_MAX, "runtime diagnostics did not expose backend counters"); perform_out(Operation::kPairingRefresh, {}); @@ -296,6 +302,7 @@ void test_mode_vendor_requests() { using namespace UsbConfigurationManagement; current_configuration.configuration.requested_mode = AdapterRequestedMode::kXInput; + current_configuration.configuration.joycon_mode = JoyConMode::kIndividual; current_active_mode = AdapterUsbMode::kSwitchProbe; tusb_control_request_t request = @@ -333,8 +340,10 @@ void test_mode_vendor_requests() { control_payload[10] == ADAPTER_CONFIGURATION_SCHEMA_VERSION && control_payload[kResponseHeaderSize + 2] == - static_cast(AdapterRequestedMode::kXInput), - "configuration response did not keep requested mode separate"); + static_cast(AdapterRequestedMode::kXInput) && + control_payload[kResponseHeaderSize + 4] == + static_cast(JoyConMode::kIndividual), + "configuration response did not report the saved USB and player modes"); std::vector mode_set(5); write_u32(&mode_set, 0, 0x12345678); @@ -511,6 +520,9 @@ void test_profile_vendor_requests() { current_playtest[2].state_generation = 0x55667788; current_playtest[2].identity = expected_identity; current_playtest[2].physical_button_mask = 0x8001; + current_playtest[2].state.extra_buttons = 0x7f; + current_playtest[2].controller_layout = + Bluepad32ControllerLayout::kJoyCon2MergedPair; current_playtest[2].state.left_stick_x = -1234; current_playtest[2].state.left_stick_y = 2345; current_playtest[2].state.right_stick_x = INT16_MIN; @@ -550,13 +562,25 @@ void test_profile_vendor_requests() { static_cast(read_u16( control_payload, kResponseHeaderSize + 52)) == -6 && control_payload[kResponseHeaderSize + 39] == 201 && - control_payload[kResponseHeaderSize + 40] == 0x0f, + control_payload[kResponseHeaderSize + 40] == 0x0f && + control_payload[kResponseHeaderSize + 54] == 0x7f && + control_payload[kResponseHeaderSize + 55] == 3, "profile playtest response lost live controller state"); + current_playtest[2].controller_layout = + static_cast(6); + uint8_t invalid_playtest[kMaximumResponseSize]{}; + require(encode_profile_playtest( + 2, current_playtest[2], invalid_playtest, sizeof(invalid_playtest)) == 0, + "unknown playtest layout metadata must be rejected"); + current_playtest[2].controller_layout = + Bluepad32ControllerLayout::kJoyCon2MergedPair; current_playtest[2].active = false; require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request) && control_payload[kResponseHeaderSize] == 0 && - control_payload[kResponseHeaderSize + 1] == 0xff, + control_payload[kResponseHeaderSize + 1] == 0xff && + control_payload[kResponseHeaderSize + 54] == 0 && + control_payload[kResponseHeaderSize + 55] == 0, "disconnected profile playtest was not encoded"); current_profile_metadata = {}; current_profile_metadata.metadata.state = ProfileServiceState::kReady;