Compare commits

...

9 commits

80 changed files with 13094 additions and 4744 deletions

View file

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

View file

@ -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=$<IF:$<BOOL:${SWITCH_PICO_HAPTICS_EXPERIMENT}>,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)

259
README.md
View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,84 @@
// SPDX-License-Identifier: Apache-2.0
#include "parser/uni_switch2_haptics.h"
#include <stddef.h>
#include <string.h>
static uint8_t frequency_index(uint8_t index) {
if (index < 1) return 1;
return index > 127 ? 127 : index;
}
static uint16_t amplitude_code(uint16_t q15) {
if (q15 > 32767) q15 = 32767;
// Match SDL's conservative native envelope while retaining a linear input
// curve. This is transport gain, not a claim of calibrated physical force.
return (uint16_t)(((uint32_t)q15 * 29000u / 32767u) >> 6);
}
static uint64_t load_sample(const uint8_t data[5]) {
uint64_t value = 0;
for (unsigned i = 0; i < 5; ++i) value |= (uint64_t)data[i] << (8 * i);
return value;
}
static bool valid_side(const uni_switch2_haptics_side_t* side) {
if (side->count > UNI_SWITCH2_HAPTICS_MAX_SAMPLES) return false;
for (unsigned i = 0; i < side->count; ++i) {
uint64_t value = load_sample(side->samples[i]);
unsigned first_frequency = value & 1023u;
unsigned second_frequency = (value >> 20) & 1023u;
if (first_frequency == 0 || first_frequency > 670 ||
second_frequency == 0 || second_frequency > 670 ||
((value >> 10) & 1023u) > UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE ||
((value >> 30) & 1023u) > UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE)
return false;
}
return true;
}
void uni_switch2_haptics_encode_sample(uint8_t out[5], uint8_t low_index, uint8_t high_index,
uint16_t low_q15, uint16_t high_q15) {
if (!out) return;
// The two physical frequency fields share one measured logarithmic scale.
// Original Switch indices have32 steps/octave; Switch2 has96 steps/octave.
uint64_t value = 193u + 3u * frequency_index(low_index);
value |= (uint64_t)amplitude_code(low_q15) << 10;
value |= (uint64_t)(289u + 3u * frequency_index(high_index)) << 20;
value |= (uint64_t)amplitude_code(high_q15) << 30;
for (unsigned i = 0; i < 5; ++i) out[i] = (uint8_t)(value >> (8 * i));
}
void uni_switch2_haptics_silence(uni_switch2_haptics_frame_t* frame) {
if (!frame) return;
memset(frame, 0, sizeof(*frame));
for (unsigned side = 0; side < 2; ++side) {
frame->sides[side].count = 1;
uni_switch2_haptics_encode_sample(frame->sides[side].samples[0], 64, 64, 0, 0);
}
}
bool uni_switch2_haptics_valid(const uni_switch2_haptics_frame_t* frame) {
return frame && (frame->sides[0].count || frame->sides[1].count) &&
valid_side(&frame->sides[0]) && valid_side(&frame->sides[1]);
}
bool uni_switch2_haptics_is_stop(const uni_switch2_haptics_frame_t* frame) {
if (!uni_switch2_haptics_valid(frame) || !frame->sides[0].count || !frame->sides[1].count)
return false;
for (unsigned side = 0; side < 2; ++side) {
for (unsigned i = 0; i < frame->sides[side].count; ++i) {
uint64_t value = load_sample(frame->sides[side].samples[i]);
if (((value >> 10) & 1023u) || ((value >> 30) & 1023u)) return false;
}
}
return true;
}
bool uni_switch2_haptics_write_block(uint8_t out[16], const uni_switch2_haptics_side_t* side,
uint8_t sequence) {
if (!out || !side || !side->count || side->count > UNI_SWITCH2_HAPTICS_MAX_SAMPLES) return false;
memset(out, 0, 16);
out[0] = (uint8_t)(0x40u | (side->count << 4) | (sequence & 15u));
memcpy(out + 1, side->samples, 5u * side->count);
return true;
}

View file

@ -0,0 +1,36 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define UNI_SWITCH2_HAPTICS_MAX_SAMPLES 3
#define UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE 453
#define UNI_SWITCH2_HAPTICS_WATCHDOG_MS 50
typedef struct {
uint8_t count; // Zero leaves this side's host state/watchdog untouched.
uint8_t samples[UNI_SWITCH2_HAPTICS_MAX_SAMPLES][5];
} uni_switch2_haptics_side_t;
typedef struct {
uni_switch2_haptics_side_t sides[2]; // Physical left/right; Joy-Con uses side 0.
} uni_switch2_haptics_frame_t;
// Source indices64 mean160Hz(low)/320Hz(high); amplitudes are linear Q0.15.
// Measured native frequency law: Hz ~=10*2^((code-1)/96), hence exact index mapping.
// Linear amplitude scaling uses SDL's29000/65535 safety envelope (10-bit max453).
void uni_switch2_haptics_encode_sample(uint8_t out[5], uint8_t low_index, uint8_t high_index,
uint16_t low_q15, uint16_t high_q15);
void uni_switch2_haptics_silence(uni_switch2_haptics_frame_t* frame);
bool uni_switch2_haptics_valid(const uni_switch2_haptics_frame_t* frame);
bool uni_switch2_haptics_is_stop(const uni_switch2_haptics_frame_t* frame);
// Native callers validate the host envelope separately; conventional feedback
// retains its existing full10-bit amplitude codes.
// Requires count1..3; unused slots are zero, header selects only the valid samples.
bool uni_switch2_haptics_write_block(uint8_t out[16], const uni_switch2_haptics_side_t* side,
uint8_t sequence);
#ifdef __cplusplus
}
#endif

View file

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

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

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

View file

@ -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<uint8_t>(configuration.pairing_window_seconds >> 8);
output[2] = static_cast<uint8_t>(configuration.requested_mode);
output[3] = configuration.native_switch_controller_count;
output[4] = static_cast<uint8_t>(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<JoyConMode>(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;

View file

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

View file

@ -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<uint8_t>(identity.transport);
if (transport > static_cast<uint8_t>(ControllerTransport::kBle)) {
if (identity.transport == ControllerTransport::kJoyConPair) {
return controller_identity_is_joycon_pair(identity);
}
if (transport > static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(ControllerTransport::kBle) ||
input[3] != 0) {
input_size != CONTROLLER_IDENTITY_ENCODED_SIZE) {
return false;
}
ControllerIdentity decoded{};
decoded.stable = input[0] != 0;
decoded.transport = static_cast<ControllerTransport>(input[1]);
decoded.address_type = input[2];
memcpy(decoded.address, &input[4], sizeof(decoded.address));
decoded.vendor_id = static_cast<uint16_t>(input[10]) |
static_cast<uint16_t>(input[11] << 8);
decoded.product_id = static_cast<uint16_t>(input[12]) |
static_cast<uint16_t>(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<uint16_t>(input[10]) |
static_cast<uint16_t>(input[11] << 8);
decoded.product_id = static_cast<uint16_t>(input[12]) |
static_cast<uint16_t>(input[13] << 8);
}
if (!controller_identity_valid(decoded)) {
return false;
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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<uint8_t>(profile.shift.mode) >
static_cast<uint8_t>(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<uint8_t>(macro.mode) >
static_cast<uint8_t>(ControllerProfileMacroMode::kRepeat) ||
macro.repeat_count == 0 ||
@ -503,6 +518,8 @@ bool controller_profile_encode(const ControllerProfile& profile,
static_cast<uint8_t>(stream_offset - macro_start);
output[336 + macro_index * 2] = static_cast<uint8_t>(macro.mode);
output[337 + macro_index * 2] = macro.repeat_count;
output[358 + macro_index] = static_cast<uint8_t>(
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<uint8_t>(
profile.switching_chord >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL);
output[363] = static_cast<uint8_t>(
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<uint32_t>(descriptor[2] & 0x03u) << 16);
const uint8_t cancel =
static_cast<uint8_t>((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<ControllerProfileMacroMode>(
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<ControllerProfileTurboMode>(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<uint32_t>(input[358 + index])
<< CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL;
}
profile.switching_chord |= static_cast<uint32_t>(input[362])
<< CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL;
profile.motion_toggle_chord |= static_cast<uint32_t>(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;
}

View file

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

View file

@ -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<uint8_t>(
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<uint32_t>(
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<uint8_t>(
~(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<uint16_t>(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]),

View file

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

View file

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

View file

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

View file

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

View file

@ -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<RecordType>(record[6]),
identity_value, record[7], record_generation,
pack_record(arena, offset));
if (!apply_record(index, identity_count, static_cast<RecordType>(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;
}

View file

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

View file

@ -193,7 +193,7 @@ size_t encode_info(uint8_t* output, size_t output_size) {
size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) {
Bluepad32BackendDiagnostics diagnostics{};
bluepad32_input_backend_diagnostics(&diagnostics);
uint8_t payload[32]{};
uint8_t payload[40]{};
write_u32(&payload[0], diagnostics.initialization_stage);
write_u32(&payload[4], diagnostics.rumble_timer_ticks);
write_u32(&payload[8], diagnostics.configuration_timer_ticks);
@ -205,6 +205,8 @@ size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) {
payload[29] = diagnostics.rumble_capable_slots;
payload[30] = diagnostics.feedback_pending_slots;
payload[31] = diagnostics.rumble_pending_slots;
write_u32(&payload[32], diagnostics.switch2_ingress_drops);
write_u32(&payload[36], diagnostics.switch2_output_drops);
return encode_response(Operation::kRuntimeDiagnostics, Status::kOk,
0, 0, 0, payload, sizeof(payload), output,
output_size);
@ -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<uint8_t>(snapshot.controller_layout) >
static_cast<uint8_t>(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<uint8_t>(snapshot.controller_layout);
if (snapshot.state.motion_sample_count != 0) {
payload[0] |= 2;
const ControllerMotionSample& motion =

View file

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

View file

@ -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("<HH", payload)
expected_size = (
PROFILE_SIZE if version >= 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("<H", payload, 78)[0] | (
((payload[75] >> 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("<H", descriptor)[0] | (
(descriptor[2] & 0x03) << 16
)
if has_extra_buttons:
trigger_mask |= payload[358 + macro_index] << 18
cancel = (descriptor[2] >> 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("<H", payload, 286)[0]
settings_offset = 288
@ -1932,7 +2108,8 @@ class ControllerProfile:
settings_offset += 3
if payload[settings_offset:336] != bytes(336 - settings_offset):
raise ConfigManagerError("nonzero Turbo override padding")
if payload[344:] != bytes(40):
reserved_offset = 364 if has_extra_buttons else 344
if any(payload[reserved_offset:]):
raise ConfigManagerError("profile reserved fields must be zero")
elif any(mode > 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("<H", payload, 286, override_mask)
payload[344:351] = bytes(self.extra_button_map)
payload[351:358] = bytes(self.shift.extra_button_map)
payload[362] = (self.switching_chord >> 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("<H", payload)[0]
@ -3183,6 +3391,8 @@ def read_configuration(device: UsbDevice) -> 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(
"<HBB4x",
"<HBBB3x",
configuration.pairing_window_seconds,
configuration.requested_mode,
len(identities),
configuration.joycon_mode,
)
+ b"".join(identity.to_bytes() for identity in identities)
+ bytes(
@ -3288,6 +3506,17 @@ def write_configuration(
return _wait_for_transaction(device, transaction_id, timeout)
def set_joycon_mode(
device: UsbDevice, mode: int, timeout: float
) -> 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(

View file

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

View file

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 205 KiB

View file

@ -1,463 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="419.12717mm"
height="304.54602mm"
viewBox="0 0 419.12717 304.54603"
version="1.1"
id="svg5"
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
sodipodi:docname="Switch Pro Controller VSCView.svg"
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009man Work Projects\VSCView - Controller Theme Layout\VSCView - Controller Theme (Ver. 1.20)\Nintendo Switch Controller Images\Switch Pro Controller\Templates\Switch Pro Controller Overlay.png"
inkscape:export-xdpi="89.994171"
inkscape:export-ydpi="89.994171"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.5"
inkscape:cx="709"
inkscape:cy="694"
inkscape:window-width="1920"
inkscape:window-height="995"
inkscape:window-x="-9"
inkscape:window-y="-9"
inkscape:window-maximized="1"
inkscape:current-layer="svg5" />
<defs
id="defs2">
<inkscape:path-effect
effect="bspline"
id="path-effect11354"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect11187"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect41470"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
</defs>
<g
id="layer2"
inkscape:label="Nintendo Switch Pro Controller"
style="display:inline"
transform="translate(-80.073102,14.620127)">
<g
id="g10592"
inkscape:label="Digital Triggers"
style="display:inline">
<path
id="path1458"
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:8.31496;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
inkscape:label="ZR Trigger"
d="m 1193.8594,3.9244459 c -30.6291,0.049759 -51.9558,10.8927201 -82.25,24.1087571 l -30.9864,15.619141 v 0 c 60.6006,-2.534721 177.2868,6.012391 206.2911,17.507812 17.5526,6.941643 47.521,19.514293 73.8007,43.001954 C 1310.1862,40.291377 1254.6354,3.8257017 1193.8594,3.9244459 Z"
sodipodi:nodetypes="scccccs"
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009man Work Projects\VSCView - Controller Theme Layout\VSCView - Controller Theme (Ver. 1.20)\Nintendo Switch Controller Images\Switch Pro Controller\Theme SVG\Theme Assets\Active Presses\Button Color\NSwitchPro_ZR.png"
inkscape:export-xdpi="89.874435"
inkscape:export-ydpi="89.874435"
transform="matrix(0.26458333,0,0,0.26458333,80.073102,-14.558417)" />
<path
id="path8519"
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:8.31496;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
inkscape:label="ZL Trigger"
d="M 388.70312,3.9244459 C 327.84634,3.8255797 272.22886,40.385003 221.64648,104.41406 247.97833,80.800743 278.04726,68.120999 295.64844,61.160156 324.65272,49.664735 441.33889,41.117623 501.93945,43.652344 v 0 L 470.95312,28.033203 C 440.65896,14.817164 419.33216,3.9742049 388.70312,3.9244459 Z"
transform="matrix(0.26458333,0,0,0.26458333,80.073102,-14.558417)"
sodipodi:nodetypes="scccccs"
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009man Work Projects\VSCView - Controller Theme Layout\VSCView - Controller Theme (Ver. 1.20)\Nintendo Switch Controller Images\Switch Pro Controller\Theme SVG\Theme Assets\Active Presses\Button Color\NSwitchPro_ZL.png"
inkscape:export-xdpi="90.144424"
inkscape:export-ydpi="90.144424" />
</g>
<g
id="g3226"
style="display:inline"
inkscape:label="Controller Body"
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009~1\VSCVIE~1\VSCVIE~1.20)\NINTEN~1\SWITCH~1\TEMPLA~1\SWITCH~4.PNG"
inkscape:export-xdpi="89.994171"
inkscape:export-ydpi="89.994171">
<g
id="g20999"
inkscape:label="Shoulders"
style="display:inline">
<path
style="display:inline;fill:#3a3b40;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.8;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 278.583,-2.464337 c -28.76946,0 -85.94915,1.52074839 -111.02858,7.9960404 -25.07936,6.4590456 269.26062,6.4590456 244.18129,0 C 386.63998,-0.94358861 329.46023,-2.464337 300.69077,-2.464337 Z m 0,0"
id="path1675-3"
inkscape:label="Shoulder Top" />
<g
id="g3215"
inkscape:label="Bumpers"
style="display:inline">
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.2;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 349.00293,5.0036016 20.78687,-9.8062225 c 16.0339,-0.670645 43.10528,3.3847582 50.77933,6.426255 7.69035,3.041349 24.39363,10.1544219 30.7451,27.7326099"
id="path5183"
sodipodi:nodetypes="cccc"
inkscape:label="R Bumper" />
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.2;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 229.86272,5.0036016 -20.78687,-9.8062225 c -16.0339,-0.670645 -43.10528,3.3847582 -50.77933,6.426255 -7.69035,3.041349 -24.39363,10.1544219 -30.7451,27.7326099"
id="path2365"
sodipodi:nodetypes="cccc"
inkscape:label="L Bumper" />
</g>
<path
style="display:inline;fill:#3a3b40;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.8;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 278.583,3.9619915 c -28.76946,0 -85.94915,1.5206749 -111.02858,7.9795715 -25.07936,6.475292 -47.95454,23.203229 -47.95454,23.203229 -1.22458,2.387321 -2.92887,6.475293 -2.92887,6.475293 27.03871,4.692961 105.59767,6.818626 168.0186,6.93317 54.12641,0.118702 146.98623,3.335721 180.52342,0.01486 0,0 -3.30226,-9.876446 -5.131,-13.441106 0,0 -23.26699,-16.711541 -48.34632,-23.186833 C 386.63998,5.4812083 329.46023,3.9604598 300.69077,3.9604598 Z"
id="path1685-5"
sodipodi:nodetypes="cccccccccc"
inkscape:label="Shoulder Middle" />
</g>
<path
id="path9077"
style="color:#000000;display:inline;fill:#303136;fill-opacity:1;stroke-width:6.04724;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
d="m 1492.3419,363.6276 c 0,0 -120.2829,187.36702 -214.886,314.43124 -47.6216,63.96202 -90.2553,112.22577 -99.0594,118.48022 27.4793,18.41386 45.2809,61.44345 90.4551,155.73242 68.1907,142.33002 119.3493,163.89862 176.1855,159.57232 56.836,-4.3279 127.8652,-70.4537 132.1231,-182.56255 2.9038,-76.49795 -44.0083,-367.63608 -84.8183,-565.65365 z"
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
inkscape:label="Secondary Color - Right Grip"
sodipodi:nodetypes="cscsccc" />
<path
id="path9067"
style="color:#000000;display:inline;fill:#303136;fill-opacity:1;stroke-width:6.04724;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
d="M 402.42219,795.85127 C 399.38105,793.06819 336.76422,723.08734 320.52137,703.52495 258.47949,628.80361 167.18978,498.80406 115.64482,412.79566 95.419032,379.04672 87.37911,367.31405 87.37911,367.31405 46.574325,565.32969 0.25264151,852.79352 3.15625,929.28125 c 4.2576,112.10885 75.287003,178.23635 132.12305,182.56255 56.836,4.3277 107.99474,-17.2423 176.18554,-159.57232 45.17551,-94.29154 63.47682,-138.00721 90.95735,-156.42021 z"
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
inkscape:label="Secondary Color - Left Grip"
sodipodi:nodetypes="csscccsc" />
<path
id="path9073"
style="color:#000000;display:inline;opacity:1;fill:#3a3b40;fill-opacity:1;stroke-width:6.04724;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
d="m 747.63867,60.839844 c -110.83321,0 -331.01859,5.748832 -427.6582,30.222656 -96.57774,24.41158 -184.70117,87.69531 -184.70117,87.69531 -11.01001,20.95955 -28.77243,92.5631 -47.282585,181.69038 0.873431,0.0997 0.707604,2.43957 1.16188,3.1922 0.715225,1.38029 27.588795,50.1741 66.836665,108.25343 66.51882,98.43512 163.05602,227.8697 177.48473,243.46334 32.4081,35.02463 70.21689,79.2567 70.90251,79.45725 12.98151,-7.07235 30.71466,-10.76753 51.98078,-10.76753 h 667.58982 c 21.3568,0 38.2517,2.93993 51.2679,10.08307 1.7345,-0.43644 25.6031,-26.33704 59.4122,-64.27553 13.2152,-14.82933 41.5543,-49.99609 41.5543,-49.99609 0,0 59.0253,-77.54759 113.8892,-157.48009 54.4807,-79.37419 104.8128,-161.13504 101.9565,-161.74543 -18.5107,-89.12547 -35.9671,-160.91574 -46.9961,-181.875 0,0 -88.0614,-63.28373 -184.7012,-87.69531 C 1163.7579,66.588676 943.50977,60.839844 832.73828,60.839844 Z"
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
inkscape:label="Primary Color - Front"
sodipodi:nodetypes="sccccsscsscscscccss" />
<path
id="path44935"
style="color:#000000;display:inline;fill:#211e1e;stroke-width:1;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
d="m 278.38666,9.1209533 c -29.36903,0 -87.49413,1.4566687 -113.4706,8.0351677 -25.88784,6.543567 -49.30656,23.408391 -49.30655,23.408391 -0.16443,0.117573 -0.29839,0.272371 -0.39274,0.451134 a 0.50005001,0.50005001 0 0 0 -0.002,0 c -1.28805,2.452052 -2.7102,6.774486 -4.34548,12.705167 -1.63548,5.931445 -3.44791,13.429921 -5.35367,22.011576 -3.81155,17.163289 -7.995508,38.658521 -11.851476,60.523441 -3.855972,21.86493 -7.382584,44.0963 -9.876917,62.7326 -2.494304,18.63606 -3.96839,33.60292 -3.677812,41.25743 1.146538,30.19005 20.208895,48.33477 36.160065,49.54891 7.71368,0.58736 15.31861,-0.64567 23.09006,-6.83782 7.76932,-6.19044 15.73229,-17.19509 24.79694,-36.11511 9.02811,-18.84372 13.9548,-29.97353 18.73994,-36.1425 2.39403,-3.08636 4.69,-4.92631 7.52564,-6.06474 2.83367,-1.13765 6.27904,-1.56631 10.89804,-1.56631 h 176.63315 c 4.61903,0 8.06488,0.42866 10.89856,1.56631 2.83565,1.13843 5.1311,2.97839 7.52512,6.06474 4.78521,6.16902 9.71394,17.29881 18.74201,36.1425 9.06463,18.92 17.02604,29.92469 24.79538,36.11511 7.77147,6.1921 15.37586,7.42499 23.08955,6.83782 15.95108,-1.21461 35.01344,-19.35886 36.16006,-49.54891 0.29056,-7.6544 -1.18272,-22.62137 -3.67574,-41.25743 -2.49301,-18.63629 -6.01778,-40.86765 -9.87273,-62.7326 -3.85495,-21.86495 -8.03917,-43.362164 -11.85148,-60.525507 -1.90614,-8.581679 -3.7191,-16.077965 -5.35574,-22.00951 -1.6364,-5.93072 -3.05906,-10.252647 -4.34961,-12.705167 a 0.50005001,0.50005001 0 0 0 -0.002,0 c -0.0944,-0.178761 -0.22833,-0.333558 -0.39275,-0.451134 -0.004,-0.0028 -23.40438,-16.865401 -49.30654,-23.408391 -25.9608,-6.578724 -84.10235,-8.0351677 -113.45509,-8.0351677 z m 0,2.5998407 h 22.51593 c 29.26218,0 87.6711,1.584111 112.8143,7.955071 5.3e-4,1.38e-4 0.001,-1.38e-4 0.002,0 a 0.50005001,0.50005001 0 0 0 0,0.0021 c 24.79081,6.262258 47.36709,22.251704 48.14021,22.802741 1.05212,2.078752 2.46999,6.229985 4.04317,11.931573 1.61812,5.864421 3.42424,13.330539 5.32422,21.884452 3.79997,17.107826 7.97854,38.573109 11.82822,60.408199 3.84968,21.83505 7.36883,44.04288 9.85521,62.62925 2.48634,18.58613 3.92719,33.63042 3.65456,40.81249 -1.10646,29.13294 -19.64105,45.98175 -33.75608,47.05656 -7.32887,0.55788 -14.00144,-0.48695 -21.27105,-6.2792 -7.26742,-5.79049 -15.09723,-16.4695 -24.0745,-35.20716 -9.01368,-18.81362 -13.85919,-29.94323 -19.0314,-36.6112 -2.58745,-3.33571 -5.31452,-5.56064 -8.61291,-6.88485 -3.29649,-1.32345 -7.08838,-1.75183 -11.86543,-1.75183 h -176.6325 c -4.77702,0 -8.56894,0.42838 -11.86543,1.75183 -3.29844,1.32423 -6.02399,3.54923 -8.61136,6.88485 -5.17218,6.66792 -10.01976,17.79755 -19.03346,36.6112 -8.97728,18.73766 -16.80657,29.41662 -24.07399,35.20716 -7.2696,5.7923 -13.94274,6.83726 -21.27156,6.2792 -14.11497,-1.07438 -32.649174,-17.92362 -33.755566,-47.05656 -0.27264,-7.18195 1.16849,-22.22637 3.656108,-40.81249 2.487652,-18.58637 6.008639,-40.79417 9.859345,-62.62925 3.850713,-21.83509 8.031053,-43.300284 11.830283,-60.408199 1.8996,-8.553937 3.70513,-16.019931 5.32216,-21.884452 1.5725,-5.70302 2.99039,-9.854922 4.04109,-11.93364 0.78309,-0.557723 23.367,-16.539757 48.1366,-22.800674 a 0.50005001,0.50005001 0 0 0 0,-5.3e-4 0.50005001,0.50005001 0 0 0 0,-5.29e-4 0.50005001,0.50005001 0 0 0 0,-5.29e-4 0.50005001,0.50005001 0 0 0 0,-5.29e-4 c 5.3e-4,-1.38e-4 10e-4,1.38e-4 0.002,0 25.16012,-6.37119 83.55126,-7.955071 112.8298,-7.955071 z"
inkscape:label="Controller Outline" />
<path
id="path15766"
style="color:#000000;display:inline;opacity:1;fill:#211e1e;fill-opacity:1;stroke-width:0.2;stroke-linecap:square;stroke-miterlimit:9.53846;-inkscape-stroke:none"
d="m 103.54984,89.758219 c -0.46441,0.240657 -0.80478,0.65855 -0.56351,1.122682 8.42782,16.264539 56.93552,89.848909 83.91219,114.452959 0.38624,0.35299 0.64705,0.10389 0.99937,-0.28288 0.35301,-0.3863 0.66878,-0.77094 0.28172,-1.12321 -26.41372,-24.09041 -75.45466,-98.34021 -83.58167,-114.024199 -0.1369,-0.265665 -0.95889,-0.191562 -1.0481,-0.145352 z m 371.93828,-0.254497 -0.10335,0.0041 c -0.33848,0.01543 -0.64484,0.210875 -0.80099,0.511598 -8.10964,15.68127 -57.07626,89.81787 -83.48999,113.90829 -0.38727,0.35208 -0.30372,0.78502 0.0491,1.17149 0.35227,0.38708 0.84561,0.58765 1.23193,0.2346 26.97647,-24.60363 75.48404,-98.17394 83.89668,-114.441079 0.23913,-0.46419 0.0557,-1.03981 -0.40823,-1.279509 -0.11581,-0.05996 -0.2459,-0.07335 -0.37518,-0.08217 z"
sodipodi:nodetypes="ccccccccccccccccc"
inkscape:label="Outline Color Blend" />
<g
id="g20546"
style="display:inline"
inkscape:label="Joystick Outline">
<g
id="g20740"
style="display:inline"
inkscape:label="Analog Stick Outline Body - Right">
<path
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 377.83935,140.67905 c 0,18.82089 -15.0705,34.07712 -33.66779,34.07712 -18.5973,0 -33.66772,-15.25623 -33.66772,-34.07712 0,-18.82088 15.07042,-34.07703 33.66772,-34.07703 18.59729,0 33.66779,15.25615 33.66779,34.07703 z m 0,0"
id="path1711-8"
inkscape:label="Outline #2" />
<path
style="display:inline;fill:#2c2c2c;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 370.998,140.67905 c 0,14.99458 -12.00083,27.16027 -26.82644,27.16027 -14.82554,0 -26.82652,-12.16569 -26.82652,-27.16027 0,-14.99457 12.00098,-27.14386 26.82652,-27.14386 14.82561,0 26.82644,12.14929 26.82644,27.14386"
id="path1713-1"
inkscape:label="Outline #1" />
</g>
<g
id="g20744"
style="display:inline"
inkscape:label="Analog Stick Outline Body - Left">
<path
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 207.91654,82.385032 c 0,18.820888 -15.07045,34.077108 -33.66774,34.077108 -18.59729,0 -33.66783,-15.25622 -33.66783,-34.077108 0,-18.82096 15.07054,-34.077037 33.66783,-34.077037 18.59729,0 33.66774,15.256077 33.66774,34.077037 z m 0,0"
id="path1695-1"
inkscape:label="Outline #2" />
<path
style="display:inline;fill:#2c2c2c;fill-opacity:1;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 201.07528,82.385032 c 0,14.99457 -12.01729,27.143938 -26.82648,27.143938 -14.82564,0 -26.82648,-12.149368 -26.82648,-27.143938 0,-14.99457 12.00084,-27.160262 26.82648,-27.160262 14.80919,0 26.82648,12.165692 26.82648,27.160262 z m 0,0"
id="path1699-1"
inkscape:label="Outline #1" />
</g>
</g>
<path
id="path14321"
style="display:inline;fill:#c7c7c7;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.297881"
d="m 287.64024,16.987688 v 5.61e-4 h -7.18474 c -4.55541,0 -8.25167,3.691333 -8.25167,8.246746 V 40.91088 c 0,4.555458 3.69626,8.246199 8.25167,8.246199 h 7.18474 c 0.1302,0 0.23893,-0.103795 0.23893,-0.233995 V 17.222229 c 0,-0.1302 -0.10873,-0.234541 -0.23893,-0.234541 z m 3.19589,0 c -0.11456,0 -0.20769,0.09424 -0.20769,0.208785 V 48.92307 c 0,0.1302 0.10381,0.233996 0.23399,0.233996 h 5.26348 c 4.5554,0 8.24674,-3.690743 8.24674,-8.246201 l -5.6e-4,-15.676431 c 0,-4.555413 -3.6908,-8.246746 -8.2462,-8.246746 z m -10.38063,2.74928 h 4.67 v 26.670816 h -4.67 c -1.47336,0 -2.85306,-0.572603 -3.88911,-1.613838 -1.04123,-1.036003 -1.61384,-2.415507 -1.61384,-3.883628 V 25.234434 c 0,-1.46812 0.57261,-2.847867 1.61384,-3.889106 1.03605,-1.036003 2.41575,-1.60836 3.88911,-1.60836 z m -0.14084,3.878693 c -1.67116,0 -3.02437,1.353716 -3.02437,3.02492 0,1.665928 1.35321,3.024922 3.02437,3.024922 1.67121,0 3.02492,-1.358994 3.02492,-3.024922 0,-1.671204 -1.35371,-3.02492 -3.02492,-3.02492 z m 16.90998,8.03851 c 1.67114,0 3.02492,1.35895 3.02492,3.024919 0,1.671206 -1.35378,3.024373 -3.02492,3.024373 -1.67121,0 -3.02492,-1.353167 -3.02492,-3.024373 0,-1.665969 1.35371,-3.024919 3.02492,-3.024919 z m 12.75507,22.209604 c -1.21304,0 -2.19689,0.984148 -2.19689,2.202384 0,1.218235 0.98385,2.207317 2.19689,2.207317 1.21824,0 2.2024,-0.989082 2.2024,-2.207317 0,-1.218236 -0.98416,-2.202384 -2.2024,-2.202384 z m -45.34587,0.135355 v 4.138989 h 0.95791 v -2.519672 l 1.87412,2.519672 h 0.90091 V 53.99913 h -0.95789 v 2.478023 l -1.85331,-2.478023 z m 6.62744,0 v 4.138989 h 1.03625 V 53.99913 Z m 3.93075,0 v 4.138989 h 0.95296 v -2.519672 l 1.87907,2.519672 h 0.89541 V 53.99913 h -0.95788 v 2.478023 l -1.85332,-2.478023 z m 6.46084,0 v 0.994606 h 1.28066 v 3.144383 h 1.00501 v -3.144383 h 1.24943 V 53.99913 Z m 6.34631,0 v 4.138989 h 3.25398 v -0.885007 h -2.2961 V 56.46181 h 2.12402 v -0.869667 h -2.12402 v -0.712937 h 2.2961 V 53.99913 Z m 6.45589,0 v 4.138989 h 0.95242 v -2.519672 l 1.87961,2.519672 h 0.89542 V 53.99913 h -0.95788 v 2.478023 l -1.85332,-2.478023 z m 6.99679,0 v 4.138989 h 1.72891 c 1.17657,0 2.12896,-0.926652 2.12896,-2.07196 0,-1.140206 -0.95239,-2.067029 -2.12896,-2.067029 z m 8.52785,0.895421 c 0.64557,0 1.17162,0.526025 1.17162,1.171608 0,0.650864 -0.52609,1.171061 -1.17162,1.171061 -0.64035,0 -1.16613,-0.520331 -1.16613,-1.171061 0,-0.645537 0.52578,-1.171608 1.16613,-1.171608 z m -7.55956,0.08822 h 0.80226 c 0.5987,0 1.08284,0.484688 1.08284,1.083382 0,0.603927 -0.48414,1.087766 -1.08284,1.087766 h -0.80226 z m -2.38431,4.784525 c -2.30114,0 -4.16475,1.864213 -4.16475,4.165296 0,2.295982 1.86361,4.159815 4.16475,4.159815 1.47859,0 2.78567,-0.775887 3.51975,-1.942089 l -1.4478,-1.259831 c -0.36465,0.739317 -1.13484,1.249423 -2.03031,1.249423 -1.24427,0 -2.25389,-0.98908 -2.25389,-2.207314 0,-1.223426 1.00962,-2.212795 2.25389,-2.212795 0.89547,0 1.66585,0.51015 2.03031,1.249422 l 1.4478,-1.259834 C 302.82151,60.543141 301.51443,59.7673 300.03584,59.7673 Z m -32.22638,0.140836 c -1.61911,0 -3.49289,0.78078 -3.49289,2.40514 0,1.327587 0.95255,1.936495 3.11313,2.311434 1.57748,0.270735 2.24558,0.406117 2.22322,0.921724 -0.0156,0.535922 -0.68738,0.83295 -1.86921,0.83295 -1.67115,0 -2.7438,-0.760066 -2.7438,-0.760066 l -0.84828,1.536022 c 0,0 1.45729,0.87953 3.48249,0.87953 2.76447,0 3.80581,-1.093181 3.80581,-2.540496 0,-1.213046 -0.63007,-1.993651 -3.56634,-2.509259 -0.87986,-0.156193 -1.66074,-0.286757 -1.71796,-0.734309 -0.0312,-0.21333 0.11992,-0.697596 1.6922,-0.697596 1.17659,0 2.37938,0.562239 2.37938,0.562239 l 0.64006,-1.561778 c 0,0 -1.43703,-0.645535 -3.09781,-0.645535 z m 4.25901,0.10905 2.21225,7.809447 h 2.02045 l 1.30148,-4.597113 1.29108,4.597113 h 2.02538 l 2.22813,-7.809447 h -1.93167 l -1.35354,5.09195 -1.4889,-5.09195 h -1.54644 l -1.47848,5.071127 -1.34862,-5.071127 z m 12.67178,0 v 7.809447 h 1.9525 v -7.809447 z m 3.55045,0 v 1.874684 h 2.41062 v 5.934763 h 1.89497 v -5.934763 h 2.35308 v -1.874684 z m 16.7379,0 v 7.814926 h 1.83249 v -3.009573 h 3.24905 v 3.009577 h 1.83741 v -7.814926 h -1.83741 v 2.967929 h -3.24905 v -2.967929 z"
inkscape:label="Nintendo Switch Logo" />
</g>
<g
id="g10760"
inkscape:label="Analog Joysticks"
style="display:inline">
<g
id="g2495"
inkscape:label="Right Joystick"
style="display:inline;stroke-width:0.901068"
transform="matrix(1.1098172,0,0,1.1097717,-37.796036,-15.434424)"
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009~1\VSCVIE~1\VSCVIE~1.20)\NINTEN~1\SWITCH~1\THEMES~1\THEMEA~1\ACTIVE~1\BUTTON~1\NSWITC~4.PNG"
inkscape:export-xdpi="90.207504"
inkscape:export-ydpi="90.207504">
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.44171;stroke-miterlimit:9.53846;stroke-dasharray:none"
d="m 366.06709,140.67905 c 0,12.23121 -9.79667,22.15669 -21.89553,22.15669 -12.08246,0 -21.89553,-9.92548 -21.89553,-22.15669 0,-12.23112 9.81307,-22.1566 21.89553,-22.1566 12.09886,0 21.89553,9.92548 21.89553,22.1566"
id="path1717-9"
inkscape:label="Color" />
<path
id="path1719-5"
style="color:#000000;fill:#211e1e;stroke-width:3.77953;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
d="m 998.16797,457.98242 c -52.54376,0 -95.24609,43.2182 -95.24609,96.33985 0,53.12169 42.70233,96.33203 95.24609,96.33203 52.61003,0 95.24803,-43.21336 95.24803,-96.33203 0,-53.11863 -42.638,-96.33985 -95.24803,-96.33985 z m 0,6.80664 c 48.88923,0 88.43943,40.04719 88.43943,89.53321 0,49.48601 -39.5502,89.5332 -88.43943,89.5332 -48.81792,0 -88.4375,-40.05021 -88.4375,-89.5332 0,-49.483 39.61958,-89.53321 88.4375,-89.53321 z m -0.0312,19.52344 c -38.2086,0 -69.17774,31.42482 -69.17774,70.00977 0,38.64299 30.9725,70.00195 69.17774,70.00195 38.20533,0 69.24223,-31.35568 69.24223,-70.00195 0,-38.58827 -31.0336,-70.00977 -69.24223,-70.00977 z m 0,6.04492 c 34.89833,0 63.19733,28.61127 63.19733,63.96485 0,35.4169 -28.2957,63.95703 -63.19733,63.95703 -34.90163,0 -63.13281,-28.53685 -63.13281,-63.95703 0,-35.35685 28.23449,-63.96485 63.13281,-63.96485 z"
transform="matrix(0.23840262,0,0,0.2384124,106.2058,8.5222802)"
inkscape:label="Outline" />
</g>
<g
id="g2489"
inkscape:label="Left Joystick"
style="display:inline;stroke-width:0.901069"
transform="matrix(1.1098161,0,0,1.1097704,-19.135317,-9.0434377)"
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009~1\VSCVIE~1\VSCVIE~1.20)\NINTEN~1\SWITCH~1\THEMES~1\THEMEA~1\ACTIVE~1\BUTTON~1\NSWITC~3.PNG"
inkscape:export-xdpi="87.630684"
inkscape:export-ydpi="87.630684">
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.44171;stroke-miterlimit:9.53846;stroke-dasharray:none"
d="m 196.14423,82.385032 c 0,12.231124 -9.81296,22.156678 -21.89551,22.156678 -12.09884,0 -21.89543,-9.925554 -21.89543,-22.156678 0,-12.231198 9.79659,-22.156755 21.89543,-22.156755 12.08255,0 21.89551,9.925557 21.89551,22.156755"
id="path1701-7"
inkscape:label="Color" />
<path
id="path1707-6"
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.44171;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 190.02859,82.385027 c 0,8.814462 -7.05807,15.969873 -15.77265,15.969873 -8.71458,0 -15.78701,-7.155411 -15.78701,-15.969873 0,-8.814527 7.07243,-15.969941 15.78701,-15.969941 8.71458,0 15.77265,7.155414 15.77265,15.969941 z m 0,0 m 6.11564,5e-6 c 0,12.231124 -9.81296,22.156678 -21.89551,22.156678 -12.09884,0 -21.89543,-9.925554 -21.89543,-22.156678 0,-12.231198 9.79659,-22.156755 21.89543,-22.156755 12.08255,0 21.89551,9.925557 21.89551,22.156755 z m 0,0"
inkscape:label="Outline" />
</g>
</g>
<g
id="g1427"
inkscape:label="Face Buttons"
style="display:inline">
<g
id="g10427"
inkscape:label="X Button">
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
d="m 414.49511,53.671306 c 0,-8.323055 -6.66169,-15.043682 -14.87451,-15.043682 -8.21289,0 -14.85826,6.720627 -14.85826,15.043682 0,8.306659 6.64537,15.043533 14.85826,15.043533 8.21282,0 14.87451,-6.736874 14.87451,-15.043533"
id="path1737-4"
inkscape:label="Color" />
<path
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 414.49511,53.671306 c 0,-8.323055 -6.66169,-15.043607 -14.87451,-15.043607 -8.21289,0 -14.85826,6.720552 -14.85826,15.043607 0,8.306659 6.64537,15.043681 14.85826,15.043681 8.21282,0 14.87451,-6.737022 14.87451,-15.043681 z m 0,0"
id="path1739-4"
inkscape:label="Outline" />
<path
id="path5694"
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
d="m 395.50194,48.189305 0.23257,0.379307 3.00548,4.923729 -3.45093,5.644618 h 1.79472 l 2.5528,-4.183724 2.5373,4.183724 h 1.79473 l -3.45094,-5.644618 3.23805,-5.303036 h -1.77715 l -2.34199,3.842145 -2.34145,-3.842145 z"
inkscape:label="Text" />
</g>
<g
id="g10432"
inkscape:label="Y Button">
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
d="m 367.25896,67.68476 c -8.21282,0 -14.87451,6.720553 -14.87451,15.043607 0,8.306734 6.66169,15.043682 14.87451,15.043682 8.21289,0 14.87459,-6.736948 14.87459,-15.043682 0,-8.323054 -6.6617,-15.043607 -14.87459,-15.043607"
id="path1733-7"
inkscape:label="Color" />
<path
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 367.25904,67.68476 c -8.2129,0 -14.87459,6.720553 -14.87459,15.043607 0,8.306734 6.66169,15.043682 14.87459,15.043682 8.21281,0 14.87451,-6.736948 14.87451,-15.043682 0,-8.323054 -6.6617,-15.043607 -14.87451,-15.043607 z m 0,0"
id="path1735-4"
inkscape:label="Outline" />
<path
id="path5704"
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
d="m 363.00989,77.245929 0.23048,0.379306 3.29488,5.402255 v 5.167643 h 1.51154 V 83.02749 l 3.47472,-5.781561 h -1.77562 l -2.47272,4.086055 -2.50372,-4.086055 z"
inkscape:label="Text" />
</g>
<g
id="g10422"
inkscape:label="A Button">
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
d="m 432.60263,97.772049 c 8.21289,0 14.87458,-6.736948 14.87458,-15.043682 0,-8.30666 -6.66169,-15.060003 -14.87458,-15.060003 -8.21282,0 -14.87459,6.753343 -14.87459,15.060003 0,8.306734 6.66177,15.043682 14.87459,15.043682"
id="path1725-4"
inkscape:label="Color" />
<path
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 432.60263,97.772049 c 8.21281,0 14.87451,-6.736948 14.87451,-15.043682 0,-8.30666 -6.6617,-15.060003 -14.87451,-15.060003 -8.2129,0 -14.87467,6.753343 -14.87467,15.060003 0,8.306734 6.66177,15.043682 14.87467,15.043682 z m 0,0"
id="path1727-2"
inkscape:label="Outline" />
<path
id="path5684"
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
d="m 432.3457,76.134888 -5.11546,10.947135 h 1.71101 l 1.59991,-3.433384 h 4.14031 l 1.61541,3.433384 h 1.67587 l -5.04672,-10.947135 h -0.1602 z m 0.28731,3.064412 1.35911,2.935221 h -2.74196 z"
inkscape:label="Text" />
</g>
<g
id="g10417"
inkscape:label="B Button"
style="display:inline">
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
d="m 384.76234,111.19683 c 0,8.30666 6.64537,15.04361 14.85826,15.04361 8.21282,0 14.87451,-6.73695 14.87451,-15.04361 0,-8.32305 -6.66169,-15.059925 -14.87451,-15.059925 -8.21289,0 -14.85826,6.736875 -14.85826,15.059925"
id="path1729-5"
inkscape:label="Color" />
<path
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 384.76234,111.19683 c 0,8.30666 6.64544,15.04361 14.85826,15.04361 8.21282,0 14.87451,-6.73695 14.87451,-15.04361 0,-8.32305 -6.66169,-15.060074 -14.87451,-15.060074 -8.21282,0 -14.85826,6.737024 -14.85826,15.060074 z m 0,0"
id="path1731-8"
inkscape:label="Outline" />
<path
id="path5714"
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
d="m 399.47225,105.71448 -2.29081,5.3e-4 v 0.24959 10.69961 h 3.09181 c 1.07363,0 1.95181,-0.30075 2.62154,-0.93586 h 0.003 v -0.002 c 0.64646,-0.62979 0.97462,-1.41132 0.97462,-2.30425 0,-0.44095 -0.0871,-0.86836 -0.26718,-1.24799 l -0.003,-0.002 c -0.17555,-0.38581 -0.41021,-0.71541 -0.72864,-0.99839 l -0.004,-0.002 -0.003,-0.002 c -0.24373,-0.20363 -0.61497,-0.35596 -0.98599,-0.51004 0.29816,-0.22146 0.60555,-0.43885 0.77721,-0.74207 0.25247,-0.41527 0.3788,-0.88916 0.3788,-1.4025 0,-0.53531 -0.14348,-1.04004 -0.41031,-1.48622 l -0.003,-0.004 -0.004,-0.006 c -0.28318,-0.42562 -0.66442,-0.77237 -1.15033,-0.98857 h -0.003 c -0.49593,-0.22406 -1.16128,-0.31626 -1.99625,-0.31626 z m -0.77668,1.52962 h 0.94101 c 0.7048,0 1.16608,0.13228 1.42214,0.34985 l 0.006,0.006 0.006,0.004 c 0.28122,0.21413 0.40618,0.49457 0.40618,0.91209 0,0.31298 -0.0794,0.58368 -0.24804,0.83768 -0.18725,0.25995 -0.40392,0.43401 -0.70126,0.54725 -0.27373,0.10158 -0.79496,0.17932 -1.47846,0.17932 h -0.35346 z m 0.33742,4.39456 c 0.85844,0 1.47833,0.0404 1.76993,0.11111 0.4972,0.12048 0.85116,0.33801 1.10897,0.63872 l 0.003,0.004 0.003,0.002 c 0.27053,0.2999 0.39274,0.6386 0.39274,1.04283 0,0.32553 -0.096,0.61375 -0.28318,0.88884 v 0.002 l -0.003,0.002 c -0.18621,0.28793 -0.41764,0.47979 -0.73432,0.60927 h -0.003 -0.003 c -0.28475,0.12139 -0.8182,0.20877 -1.55083,0.20877 h -1.0392 v -3.5078 z"
inkscape:label="Text" />
</g>
</g>
<g
id="g1458"
inkscape:label="Home"
style="display:inline">
<g
id="g3754"
inkscape:label="LED"
style="display:inline">
<path
id="path1749-3"
style="display:none;mix-blend-mode:lighten;fill:#b0b0b0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.04724;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
inkscape:label="OFF"
d="m 902.57812,287.30469 c -25.42484,0 -46.0996,20.88841 -46.0996,46.66015 0,25.77147 20.67476,46.59961 46.0996,46.59961 25.48661,0 46.09766,-20.82814 46.09766,-46.59961 0,-25.77174 -20.61105,-46.66015 -46.09766,-46.66015 z m 0,12.66992 c 18.5751,0 33.57032,15.20215 33.57032,33.99023 0,18.72584 -14.99522,33.99219 -33.57032,33.99219 -18.51337,0 -33.57226,-15.26635 -33.57226,-33.99219 0,-18.78808 15.05889,-33.99023 33.57226,-33.99023 z"
transform="matrix(0.26458333,0,0,0.26458333,80.072992,-5.9766386)" />
<path
id="path4088"
style="display:inline;mix-blend-mode:lighten;fill:#2a4fff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.04724;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
inkscape:label="ON"
d="m 902.57812,287.30469 c -25.42484,0 -46.0996,20.88841 -46.0996,46.66015 0,25.77147 20.67476,46.59961 46.0996,46.59961 25.48661,0 46.09766,-20.82814 46.09766,-46.59961 0,-25.77174 -20.61105,-46.66015 -46.09766,-46.66015 z m 0,12.66992 c 18.5751,0 33.57032,15.20215 33.57032,33.99023 0,18.72584 -14.99522,33.99219 -33.57032,33.99219 -18.51337,0 -33.57226,-15.26635 -33.57226,-33.99219 0,-18.78808 15.05889,-33.99023 33.57226,-33.99023 z"
transform="matrix(0.26458333,0,0,0.26458333,80.072992,-5.9766386)" />
</g>
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
d="m 318.87991,73.39148 c 4.91466,0 8.88234,4.022539 8.88234,8.993552 0,4.954544 -3.96768,8.993478 -8.88234,8.993478 -4.89833,0 -8.88233,-4.038934 -8.88233,-8.993478 0,-4.971013 3.984,-8.993552 8.88233,-8.993552"
id="path4084"
inkscape:label="Color" />
<path
id="path1751-4"
style="color:#000000;display:inline;fill:#211e1e;stroke-width:3.77953;stroke-miterlimit:9.53846;-inkscape-stroke:none"
d="m 900.68555,283.15039 c -27.08126,0 -49.125,22.29152 -49.125,49.67969 0,27.38843 22.04865,49.62109 49.125,49.62109 27.13383,0 49.11914,-22.23751 49.11914,-49.62109 0,-27.38333 -21.98043,-49.67969 -49.11914,-49.67969 z m 0,6.04492 c 23.83442,0 43.07422,19.47576 43.07422,43.63477 0,24.1593 -19.23489,43.57617 -43.07422,43.57617 -23.77391,0 -43.08008,-19.42172 -43.08008,-43.57617 0,-24.15416 19.31108,-43.63477 43.08008,-43.63477 z m 0,6.6211 c -20.1687,0 -36.59766,16.61035 -36.59766,37.01367 0,20.34695 16.42224,37.01367 36.59766,37.01367 20.23162,0 36.59375,-16.67339 36.59375,-37.01367 0,-20.39667 -16.36882,-37.01367 -36.59375,-37.01367 z m 0,6.04687 c 16.92521,0 30.54687,13.7879 30.54687,30.9668 0,17.11191 -13.62835,30.96679 -30.54687,30.9668 -16.85129,0 -30.55274,-13.86156 -30.55274,-30.9668 0,-17.17225 13.69472,-30.9668 30.55274,-30.9668 z"
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
inkscape:label="Outline" />
<path
style="display:inline;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.297882"
d="m 320.56433,84.51897 c 0,0.245744 -0.0984,0.344162 -0.33691,0.344162 h -2.69787 c -0.23909,0 -0.33751,-0.09842 -0.33751,-0.344162 v -2.016361 c 0,-0.239102 0.0984,-0.336311 0.33751,-0.336311 h 2.69787 c 0.23185,0 0.32302,0.09117 0.33691,0.323631 z m 3.56907,-3.280941 c -1.67914,-1.426277 -3.35829,-2.852432 -5.03743,-4.278708 -0.15457,-0.140683 -0.28076,-0.140683 -0.43533,0 -1.67214,1.426276 -3.35829,2.852431 -5.03744,4.278708 -0.29525,0.252988 -0.1757,0.589904 0.22462,0.589904 h 0.65336 c 0.23186,0 0.33027,0.09842 0.33027,0.337519 v 4.376944 c 0,0.245743 0.0984,0.343557 0.34416,0.343557 h 7.41215 c 0.2391,0 0.33751,-0.09842 0.33751,-0.343557 v -4.376944 c 0,-0.239101 0.0984,-0.337519 0.33028,-0.337519 h 0.65336 c 0.40032,0 0.52711,-0.336916 0.22462,-0.589904"
id="path14283"
inkscape:label="Icon" />
</g>
<g
id="g1451"
inkscape:label="Capture"
style="display:inline">
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.564261"
d="m 255.83852,90.838877 c -2.02465,0 -3.67381,-1.667934 -3.67381,-3.711846 v -8.813649 c 0,-2.043912 1.64916,-3.695451 3.67381,-3.695451 h 8.57199 c 2.02473,0 3.65741,1.651539 3.65741,3.695451 v 8.813649 c 0,2.043912 -1.63268,3.711846 -3.65741,3.711846 z m 0,0"
id="path1759-9"
inkscape:label="Color" />
<path
style="display:inline;fill:#211e1e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.564261"
d="m 264.41051,73.751285 h -8.58839 c -2.46546,0 -4.50643,2.06038 -4.50643,4.562097 v 8.813649 c 0,2.518112 2.04097,4.562172 4.50643,4.562172 h 8.58839 c 2.48186,0 4.50651,-2.04406 4.50651,-4.562172 v -8.813649 c 0,-2.501717 -2.02465,-4.562097 -4.50651,-4.562097 m 0,1.716971 c 1.5512,0 2.80845,1.27534 2.80845,2.845126 v 8.813649 c 0,1.569787 -1.25725,2.861595 -2.80845,2.861595 h -8.58839 c -1.55112,0 -2.8083,-1.291808 -2.8083,-2.861595 v -8.813649 c 0,-1.569786 1.25718,-2.845126 2.8083,-2.845126 h 8.58839"
id="path1761-8"
inkscape:label="Outline" />
<path
style="display:inline;fill:#545759;fill-opacity:1;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 265.42287,82.728367 c 0,2.959595 -2.3675,5.363385 -5.30656,5.363385 -2.92265,0 -5.30641,-2.40379 -5.30641,-5.363385 0,-2.97599 2.38376,-5.379707 5.30641,-5.379707 2.93906,0 5.30656,2.403717 5.30656,5.379707 z m 0,0"
id="path1765-8"
inkscape:label="Symbol" />
</g>
<g
id="g1445"
inkscape:label="Minus"
style="display:inline">
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
d="m 247.75631,51.005934 c 0,5.281706 -4.21264,9.549506 -9.43749,9.549506 -5.19221,0 -9.4211,-4.2678 -9.4211,-9.549506 0,-5.281557 4.22889,-9.549357 9.4211,-9.549357 5.22485,0 9.43749,4.2678 9.43749,9.549357"
id="path1767-5"
inkscape:label="Color" />
<path
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 247.75624,51.005934 c 0,5.281706 -4.21257,9.549506 -9.43742,9.549506 -5.19221,0 -9.4211,-4.2678 -9.4211,-9.549506 0,-5.281557 4.22889,-9.549357 9.4211,-9.549357 5.22485,0 9.43742,4.2678 9.43742,9.549357 z m 0,0"
id="path1769-6"
inkscape:label="Outline" />
<path
id="path6584"
style="color:#000000;display:inline;fill:#000000;fill-opacity:1;stroke-width:0.499999;stroke-miterlimit:9.53846;-inkscape-stroke:none"
d="m 233.05657,50.134924 v 0.249598 1.492414 h 10.55646 v -1.742012 z"
inkscape:label="Symbol" />
</g>
<g
id="g1440"
inkscape:label="Plus"
style="display:inline">
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
d="m 350.06585,51.005934 c 0,5.085409 -4.08195,9.222343 -9.11089,9.222343 -5.02887,0 -9.11083,-4.136934 -9.11083,-9.222343 0,-5.085408 4.08196,-9.222343 9.11083,-9.222343 5.02894,0 9.11089,4.136935 9.11089,9.222343"
id="path1773-8"
inkscape:label="Color" />
<path
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
d="m 350.06585,51.005934 c 0,5.085409 -4.08195,9.222492 -9.11082,9.222492 -5.02894,0 -9.1109,-4.137083 -9.1109,-9.222492 0,-5.085408 4.08196,-9.222343 9.1109,-9.222343 5.02887,0 9.11082,4.136935 9.11082,9.222343 z m 0,0"
id="path1775-8"
inkscape:label="Outline" />
<path
id="path6498"
style="color:#000000;display:inline;fill:#000000;fill-opacity:1;stroke-width:0.499999;stroke-miterlimit:9.53846;-inkscape-stroke:none"
d="m 340.03506,45.866968 v 4.218863 h -4.1951 v 1.840198 h 4.1951 v 4.202843 h 1.82211 v -4.202843 h 4.19766 v -1.840198 h -4.19766 v -4.218863 z"
inkscape:label="Symbol" />
</g>
<g
id="g1435"
inkscape:label="D-PAD"
style="display:inline">
<path
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.564261"
d="m 215.9498,171.45317 c -0.47339,0 -0.84908,-0.37613 -0.84908,-0.85085 v -19.19701 h -18.95656 c -0.47338,0 -0.849,-0.37613 -0.849,-0.85077 v -19.73672 c 0,-0.47482 0.37557,-0.86621 0.849,-0.86621 h 18.95656 V 110.771 c 0,-0.47482 0.37558,-0.86628 0.84908,-0.86628 h 19.49534 c 0.47345,0 0.8491,0.39322 0.8491,0.86628 v 19.18061 h 18.97279 c 0.45713,0 0.84903,0.39248 0.84903,0.86621 v 19.73672 c 0,0.47482 -0.39182,0.85077 -0.84903,0.85077 h -18.97279 v 19.19701 c 0,0.47482 -0.3755,0.85085 -0.8491,0.85085 z m 0,0"
id="path1779-7"
inkscape:label="Color" />
<path
style="display:inline;fill:#211e1e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
d="M 235.44514,109.05477 H 215.9498 c -0.93071,0 -1.69808,0.76895 -1.69808,1.7169 v 18.33036 h -18.10749 c -0.94692,0 -1.69807,0.76887 -1.69807,1.7169 v 19.73665 c 0,0.9481 0.75107,1.71689 1.69807,1.71689 h 18.10749 v 18.33037 c 0,0.9481 0.76745,1.70058 1.69808,1.70058 h 19.49534 c 0.94698,0 1.69805,-0.75226 1.69805,-1.70058 v -18.33037 h 18.12384 c 0.93066,0 1.68173,-0.76887 1.68173,-1.71689 v -19.73665 c 0,-0.9481 -0.75107,-1.7169 -1.68173,-1.7169 h -18.12384 v -18.33036 c 0,-0.9481 -0.75107,-1.7169 -1.69805,-1.7169 m 0,1.7169 v 20.04726 h 19.82189 v 19.73665 h -19.82189 v 20.04726 H 215.9498 v -20.04726 h -19.80557 v -19.73665 h 19.80557 v -20.04726 h 19.49534"
id="path1781-2"
inkscape:label="Outline" />
<path
id="path1783-2"
style="display:inline;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.13264"
d="m 548.21875,463.86523 c -0.77142,0 -1.54272,0.51065 -2.12891,1.53125 l -3.26953,5.68555 c -1.17267,2.03928 -3.0854,5.43727 -4.25781,7.47852 l -3.27148,5.74804 c -1.17238,2.04084 -0.18743,3.70899 2.16015,3.70899 h 21.53711 c 2.3562,0 3.33257,-1.66774 2.16016,-3.70899 l -3.27149,-5.74804 c -1.1721,-2.03955 -3.08544,-5.43727 -4.25781,-7.47852 l -3.27148,-5.68555 c -0.58606,-1.0206 -1.35749,-1.53125 -2.12891,-1.53125 z m -67.85352,77.11524 c -0.48532,0.006 -1.02874,0.16564 -1.60156,0.49609 l -5.61523,3.27539 c -2.03543,1.23364 -5.36999,3.15108 -7.40625,4.32617 l -5.61524,3.33594 c -2.03539,1.17468 -2.03539,3.08881 0,4.26367 l 5.61524,3.33789 c 2.03539,1.17495 5.36999,3.09052 7.40625,4.32813 l 5.61523,3.27344 c 2.03539,1.17494 3.70313,0.16819 3.70313,-2.16211 v -21.81641 c 0,-1.68879 -0.86129,-2.67406 -2.10157,-2.6582 z m 135.65821,0.002 c -1.22227,-0.0113 -2.05078,0.97942 -2.05078,2.6543 v 21.8164 c 0,2.34966 1.60436,3.33507 3.64062,2.16016 l 5.67774,-3.27344 c 2.03625,-1.23647 5.3689,-3.15299 7.34375,-4.32812 l 5.67773,-3.33399 c 2.03626,-1.17498 2.03626,-3.09053 0,-4.26562 l -5.67773,-3.33594 c -1.97458,-1.17468 -5.3075,-3.09048 -7.34375,-4.32812 l -5.67774,-3.27344 c -0.5727,-0.33046 -1.11156,-0.48775 -1.58984,-0.49219 z m -78.57227,80.0918 c -2.35619,0 -3.33256,1.66774 -2.16015,3.70898 l 3.27148,5.74805 c 1.17237,2.03928 3.08544,5.37551 4.25781,7.47852 l 3.26953,5.68554 c 1.17241,2.04125 3.08544,2.04125 4.25782,0 l 3.27148,-5.68554 c 1.17241,-2.10123 3.08544,-5.43727 4.25781,-7.47852 l 3.27149,-5.74805 c 1.17267,-2.04079 0.18742,-3.70898 -2.16016,-3.70898 z"
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
inkscape:label="Symbols" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 101 KiB

View file

@ -0,0 +1,117 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="548" viewBox="0 0 800 548" role="img" aria-labelledby="title description">
<title id="title">PS5 DualSense wireless controller — white</title>
<desc id="description">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.</desc>
<metadata>Visual reference: https://www.playstation.com/en-us/accessories/dualsense-wireless-controller/ . Independently drawn vector artwork.</metadata>
<defs>
<linearGradient id="shell" x1="0" y1="0" x2=".8" y2="1">
<stop stop-color="#3c454b" />
<stop offset="1" stop-color="#20272c" />
</linearGradient>
<radialGradient id="dish" cx="50%" cy="90%" r="100%">
<stop stop-color="#283239" />
<stop offset="1" stop-color="#424f58" />
</radialGradient>
<linearGradient id="white" x1=".15" y1="0" x2=".85" y2="1">
<stop stop-color="#f2f4f7" />
<stop offset="1" stop-color="#bfc8d4" />
</linearGradient>
<g id="stick">
<circle cy="2" r="62" fill="#202a31" stroke="#546169" stroke-width="2" />
<circle cy="1" r="52" fill="#141e25" />
<circle cy="-2" r="47" fill="#536169" stroke="#748189" stroke-width="1.5" />
<circle cy="-2" r="39" fill="url(#dish)" stroke="#1b272f" stroke-width="2.5" />
</g>
<g id="ps-key">
<circle cy="1" r="25" fill="#a3adb9" />
<circle cy="-1" r="23" fill="url(#white)" stroke="#f7f9fb" stroke-width="1.2" />
</g>
<path id="ps-direction" d="M-10-27H10Q21-27 21-16V1Q21 6 16 10L5 20Q0 24-5 20L-16 10Q-21 6-21 1V-16Q-21-27-10-27Z" />
</defs>
<g id="controller">
<g id="shoulders" fill="url(#shell)" stroke="#657179" stroke-width="1.5">
<path d="M113 61V42C142 24 177 23 218 24L219 60Z" />
<path d="M687 61V42C658 24 623 23 582 24L581 60Z" />
</g>
<path d="M133 53C192 34 293 37 400 37C507 37 608 34 667 53C723 107 753 187 770 285C783 365 780 447 756 493C744 516 713 529 677 529C660 528 641 476 614 420C597 385 581 356 553 355C487 357 313 357 247 355C219 356 203 385 186 420C159 476 140 528 123 529C87 529 56 516 44 493C20 447 17 365 30 285C47 187 77 107 133 53Z" fill="url(#shell)" stroke="#172128" stroke-width="2" />
<path id="left-white-shell" d="M133 53C166 43 209 39 247 42L266 148C272 178 263 195 241 222C188 290 144 405 106 510C100 528 87 525 73 514C35 487 25 433 27 363C26 247 60 124 112 66Z" fill="url(#white)" stroke="#aab5c1" stroke-width="1.5" />
<use xlink:href="#left-white-shell" transform="translate(800 0) scale(-1 1)" />
<path d="M120 62C153 49 196 46 242 47M680 62C647 49 604 46 558 47" fill="none" stroke="#fff" stroke-width="2" />
<path d="M249 356C219 357 204 380 184 423C162 470 143 520 130 526M551 356C581 357 596 380 616 423C638 470 657 520 670 526" fill="none" stroke="#5b6872" stroke-width="1.5" />
<g id="touchpad">
<path d="M252 42C294 35 506 35 548 42Q558 45 555 64L537 161Q530 201 497 204H303Q270 201 263 161L245 64Q242 45 252 42Z" fill="#15202b" />
<path d="M251 48L270 157Q276 184 287 190M549 48L530 157Q524 184 513 190" fill="none" stroke="#477df1" stroke-width="5" stroke-linecap="round" />
<path d="M262 42Q400 33 538 42Q551 43 548 60L531 153Q525 190 496 194H304Q275 190 269 153L252 60Q249 43 262 42Z" fill="url(#white)" stroke="#c9d2dc" stroke-width="1.3" />
<path d="M264 46Q400 38 536 46" fill="none" stroke="#fff" stroke-width="1.7" />
</g>
<g id="create-button" transform="translate(231 92) rotate(-13)">
<rect x="-9" y="-17" width="18" height="34" rx="9" fill="url(#white)" stroke="#8d99a5" stroke-width="1.4" />
<path d="M-6-24L-9-29M0-24V-31M6-24L9-29" stroke="#8c99a5" stroke-width="1.2" />
</g>
<g id="options-button" transform="translate(569 92) rotate(13)">
<rect x="-9" y="-17" width="18" height="34" rx="9" fill="url(#white)" stroke="#8d99a5" stroke-width="1.4" />
<path d="M-5-24H5M-5-28H5M-5-32H5" stroke="#8c99a5" stroke-width="1.2" />
</g>
<g id="directional-pad" transform="translate(166 174)">
<g transform="translate(0 -39)">
<use xlink:href="#ps-direction" fill="url(#white)" stroke="#8e99a5" stroke-width="2.7" />
<path d="M-4-10L0-16L4-10Z" fill="#a4afba" />
</g>
<g transform="translate(39 0) rotate(90)">
<use xlink:href="#ps-direction" fill="url(#white)" stroke="#8e99a5" stroke-width="2.7" />
<path d="M-4-10L0-16L4-10Z" fill="#a4afba" />
</g>
<g transform="translate(0 39) rotate(180)">
<use xlink:href="#ps-direction" fill="url(#white)" stroke="#8e99a5" stroke-width="2.7" />
<path d="M-4-10L0-16L4-10Z" fill="#a4afba" />
</g>
<g transform="translate(-39 0) rotate(-90)">
<use xlink:href="#ps-direction" fill="url(#white)" stroke="#8e99a5" stroke-width="2.7" />
<path d="M-4-10L0-16L4-10Z" fill="#a4afba" />
</g>
</g>
<g id="face-buttons" fill="none" stroke="#8d99a8" stroke-width="2.4">
<g transform="translate(634 120)">
<use xlink:href="#ps-key" stroke="none" />
<path d="M0-16L-15 10H15Z" />
</g>
<g transform="translate(580 174)">
<use xlink:href="#ps-key" stroke="none" />
<path d="M-12-13H12V11H-12Z" />
</g>
<g transform="translate(688 174)">
<use xlink:href="#ps-key" stroke="none" />
<circle cy="-1" r="14" />
</g>
<g transform="translate(634 228)">
<use xlink:href="#ps-key" stroke="none" />
<path d="M-11-12L11 10M11-12L-11 10" />
</g>
</g>
<use xlink:href="#stick" transform="translate(282 282) scale(.85)" />
<use xlink:href="#stick" transform="translate(518 282) scale(.85)" />
<g id="ps-button" transform="translate(400 277)" fill="#111b22" stroke="#63717b" stroke-width=".8">
<path d="M-6-22L7-18C18-14 18-3 8 0L3-2V-12L0-13V14L-6 12Z" />
<path d="M-9-3L-22 2C-34 7-21 14-10 11L-10 6C-17 8-23 7-17 5L-9 2ZM4 0C15-3 27 1 24 6C23 8 12 12 3 15V9L16 5C21 3 13 2 5 5Z" />
</g>
<g id="mute-button">
<rect x="-19" y="-5" width="38" height="10" rx="5" transform="translate(400 322)" fill="#131e26" />
<rect x="-17" y="-4" width="34" height="7" rx="3.5" transform="translate(400 322)" fill="#59666f" />
</g>
<g id="microphone" transform="translate(400 342)" stroke="#687984" stroke-width="1.2" fill="none">
<rect x="-2" y="-5" width="4" height="7" rx="2" />
<path d="M-4-1V1Q0 7 4 1V-1M0 5V7M-3 7H3" />
</g>
<g id="speaker" fill="#111b22">
<circle cx="372" cy="218" r="3.3" />
<circle cx="383" cy="218" r="3.3" />
<circle cx="394" cy="218" r="3.3" />
<circle cx="405" cy="218" r="3.3" />
<circle cx="416" cy="218" r="3.3" />
<circle cx="427" cy="218" r="3.3" />
<circle cx="383" cy="229" r="3.3" />
<circle cx="394" cy="229" r="3.3" />
<circle cx="405" cy="229" r="3.3" />
<circle cx="416" cy="229" r="3.3" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

@ -0,0 +1,69 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="560" viewBox="-17 -10 200 560" role="img" aria-labelledby="title description">
<title id="title">Nintendo Switch 2 — left Joy-Con 2</title>
<desc id="description">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.</desc>
<metadata>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.</metadata>
<defs>
<linearGradient id="shell" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#3b4348" />
<stop offset="1" stop-color="#20262b" />
</linearGradient>
<linearGradient id="face" x1="0" y1="0" x2=".85" y2="1">
<stop stop-color="#4b555a" />
<stop offset="1" stop-color="#293137" />
</linearGradient>
<linearGradient id="button" x1="0" y1="0" x2=".4" y2="1">
<stop stop-color="#505b62" />
<stop offset="1" stop-color="#303a41" />
</linearGradient>
<radialGradient id="dish" cx="50%" cy="90%" r="100%">
<stop stop-color="#283239" />
<stop offset="1" stop-color="#424f58" />
</radialGradient>
<path id="right-shell" d="M3 5H20C111 5 164 61 164 139V376C164 482 132 537 20 537H3Z" />
<g id="key">
<circle cy="1" r="20.5" fill="#172127" />
<circle cy="-1" r="18.5" fill="url(#button)" stroke="#657178" stroke-width="1.2" />
</g>
<g id="stick">
<circle cy="2" r="46" fill="#172127" />
<circle r="43" fill="#39464d" stroke="currentColor" stroke-width="3" />
<circle cy="-1" r="38.5" fill="#182329" />
<circle cy="-2" r="35.5" fill="#56646c" />
<circle cy="-2" r="29.5" fill="url(#dish)" stroke="#202b32" stroke-width="2" />
<path d="M0-40V-32M-38-2H-31M31-2H38M0 29V36" fill="none" stroke="#1a252c" stroke-width="2" stroke-linecap="round" />
</g>
<g id="capture">
<rect x="-15.5" y="-15.5" width="31" height="31" rx="4" fill="#172127" />
<rect x="-13.5" y="-14" width="27" height="27" rx="2.5" fill="url(#button)" stroke="#626e76" stroke-width="1" />
<circle cy="-1" r="9.5" fill="none" stroke="#1c2830" stroke-width="1.4" />
<path d="M-9 2A9.5 9.5 0 0 0 9 2" fill="none" stroke="#738188" stroke-width=".8" />
</g>
</defs>
<g id="left-joycon" aria-label="Left Joy-Con 2">
<path d="M139 1C61 0 3 48-5 125L1 127C8 59 56 10 139 9Z" fill="url(#shell)" stroke="#667178" stroke-width="1.2" />
<path d="M168 9Q171 9 171 15V524Q171 532 167 533H158V9Z" fill="#77cce8" stroke="#4a9fba" stroke-width="1" />
<use xlink:href="#right-shell" transform="translate(164 0) scale(-1 1)" fill="url(#face)" stroke="#172127" stroke-width="2" />
<path d="M158 9H144C58 9 5 64 4 138" fill="none" stroke="#79848a" stroke-opacity=".7" stroke-width="1.5" />
<path d="M4 390C8 483 39 531 143 533H157" fill="none" stroke="#4c585f" stroke-width="1.2" />
<rect x="119" y="58" width="28" height="9" rx="1.5" fill="#19232a" stroke="#68757d" stroke-width="1" />
<path d="M121 60H145V63H121Z" fill="#48545c" />
<use xlink:href="#stick" transform="translate(84 141)" color="#83d4ee" />
<g transform="translate(84 239)">
<use xlink:href="#key" />
<path d="M0-9L-7 3H7Z" fill="#162128" />
</g>
<g transform="translate(44 278)">
<use xlink:href="#key" />
<path d="M-8-1L5-8V6Z" fill="#162128" />
</g>
<g transform="translate(124 278)">
<use xlink:href="#key" />
<path d="M8-1L-5-8V6Z" fill="#162128" />
</g>
<g transform="translate(84 317)">
<use xlink:href="#key" />
<path d="M0 7L-7-5H7Z" fill="#162128" />
</g>
<use xlink:href="#capture" transform="translate(119 375)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="560" viewBox="-19 -10 200 560" role="img" aria-labelledby="title description">
<title id="title">Nintendo Switch 2 — right Joy-Con 2</title>
<desc id="description">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.</desc>
<metadata>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.</metadata>
<defs>
<linearGradient id="shell" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#3b4348" />
<stop offset="1" stop-color="#20262b" />
</linearGradient>
<linearGradient id="face" x1="0" y1="0" x2=".85" y2="1">
<stop stop-color="#4b555a" />
<stop offset="1" stop-color="#293137" />
</linearGradient>
<linearGradient id="button" x1="0" y1="0" x2=".4" y2="1">
<stop stop-color="#505b62" />
<stop offset="1" stop-color="#303a41" />
</linearGradient>
<radialGradient id="dish" cx="50%" cy="90%" r="100%">
<stop stop-color="#283239" />
<stop offset="1" stop-color="#424f58" />
</radialGradient>
<path id="right-shell" d="M3 5H20C111 5 164 61 164 139V376C164 482 132 537 20 537H3Z" />
<g id="key">
<circle cy="1" r="20.5" fill="#172127" />
<circle cy="-1" r="18.5" fill="url(#button)" stroke="#657178" stroke-width="1.2" />
</g>
<g id="stick">
<circle cy="2" r="46" fill="#172127" />
<circle r="43" fill="#39464d" stroke="currentColor" stroke-width="3" />
<circle cy="-1" r="38.5" fill="#182329" />
<circle cy="-2" r="35.5" fill="#56646c" />
<circle cy="-2" r="29.5" fill="url(#dish)" stroke="#202b32" stroke-width="2" />
<path d="M0-40V-32M-38-2H-31M31-2H38M0 29V36" fill="none" stroke="#1a252c" stroke-width="2" stroke-linecap="round" />
</g>
<g id="home">
<circle cy="1" r="18" fill="#162027" />
<circle cy="-1" r="16" fill="url(#button)" stroke="#626e76" stroke-width="1.1" />
<path d="M-11-3L0-12L11-3H8V7H-8V-3ZM-3-2V3H3V-2Z" fill="#172127" fill-rule="evenodd" />
</g>
<g id="chat">
<rect x="-15.5" y="-15.5" width="31" height="31" rx="4" fill="#162027" />
<rect x="-13.5" y="-14" width="27" height="27" rx="2.5" fill="url(#button)" stroke="#626e76" stroke-width="1" />
<path d="M6-7C-4-13-12-4-6 5C-3 9 3 9 7 5" fill="none" stroke="#172127" stroke-width="3" />
</g>
</defs>
<g id="right-joycon" aria-label="Right Joy-Con 2">
<path d="M25 1C103 0 161 48 169 125L163 127C156 59 108 10 25 9Z" fill="url(#shell)" stroke="#667178" stroke-width="1.2" />
<path d="M-4 9Q-7 9-7 15V524Q-7 532-3 533H6V9Z" fill="#f38c7d" stroke="#c56960" stroke-width="1" />
<use xlink:href="#right-shell" fill="url(#face)" stroke="#172127" stroke-width="2" />
<path d="M6 9H20C106 9 159 64 160 138" fill="none" stroke="#79848a" stroke-opacity=".7" stroke-width="1.5" />
<path d="M160 390C156 483 125 531 21 533H7" fill="none" stroke="#4c585f" stroke-width="1.2" />
<g transform="translate(31 63)">
<path d="M-4-14H4V-4H14V4H4V14H-4V4H-14V-4H-4Z" fill="#182229" stroke="#68757d" stroke-width="1.1" />
<path d="M-2-12H2V-2H12V2H2V12H-2V2H-12V-2H-2Z" fill="url(#button)" />
</g>
<g transform="translate(80 101)">
<use xlink:href="#key" />
<path d="M-6-9L6 7M6-9L-6 7" fill="none" stroke="#bcc5c9" stroke-width="1.7" />
</g>
<g transform="translate(40 140)">
<use xlink:href="#key" />
<path d="M-6-9L0-1L6-9M0-1V7" fill="none" stroke="#bcc5c9" stroke-width="1.7" />
</g>
<g transform="translate(120 140)">
<use xlink:href="#key" />
<path d="M-7 7L0-10L7 7M-4.5 2H4.5" fill="none" stroke="#bcc5c9" stroke-width="1.7" stroke-linejoin="round" />
</g>
<g transform="translate(80 179)">
<use xlink:href="#key" />
<path d="M-5-9V7H1C9 7 9-1 1-1H-5M-5-9H1C8-9 8-1 1-1" fill="none" stroke="#bcc5c9" stroke-width="1.7" stroke-linejoin="round" />
</g>
<use xlink:href="#stick" transform="translate(80 278)" color="#f58d80" />
<use xlink:href="#home" transform="translate(45 375)" />
<use xlink:href="#chat" transform="translate(45 431)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

@ -0,0 +1,143 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="688" height="560" viewBox="-8 -10 688 560" role="img" aria-labelledby="title description">
<title id="title">Nintendo Switch 2 — connected Joy-Con 2 pair</title>
<desc id="description">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.</desc>
<metadata>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.</metadata>
<defs>
<linearGradient id="shell" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#3b4348" />
<stop offset="1" stop-color="#20262b" />
</linearGradient>
<linearGradient id="face" x1="0" y1="0" x2=".85" y2="1">
<stop stop-color="#4b555a" />
<stop offset="1" stop-color="#293137" />
</linearGradient>
<linearGradient id="button" x1="0" y1="0" x2=".4" y2="1">
<stop stop-color="#505b62" />
<stop offset="1" stop-color="#303a41" />
</linearGradient>
<radialGradient id="dish" cx="50%" cy="90%" r="100%">
<stop stop-color="#283239" />
<stop offset="1" stop-color="#424f58" />
</radialGradient>
<path id="right-shell" d="M3 5H20C111 5 164 61 164 139V376C164 482 132 537 20 537H3Z" />
<g id="key">
<circle cy="1" r="20.5" fill="#172127" />
<circle cy="-1" r="18.5" fill="url(#button)" stroke="#657178" stroke-width="1.2" />
</g>
<g id="stick">
<circle cy="2" r="46" fill="#172127" />
<circle r="43" fill="#39464d" stroke="currentColor" stroke-width="3" />
<circle cy="-1" r="38.5" fill="#182329" />
<circle cy="-2" r="35.5" fill="#56646c" />
<circle cy="-2" r="29.5" fill="url(#dish)" stroke="#202b32" stroke-width="2" />
<path d="M0-40V-32M-38-2H-31M31-2H38M0 29V36" fill="none" stroke="#1a252c" stroke-width="2" stroke-linecap="round" />
</g>
<g id="home">
<circle cy="1" r="18" fill="#162027" />
<circle cy="-1" r="16" fill="url(#button)" stroke="#626e76" stroke-width="1.1" />
<path d="M-11-3L0-12L11-3H8V7H-8V-3ZM-3-2V3H3V-2Z" fill="#172127" fill-rule="evenodd" />
</g>
<g id="chat">
<rect x="-15.5" y="-15.5" width="31" height="31" rx="4" fill="#162027" />
<rect x="-13.5" y="-14" width="27" height="27" rx="2.5" fill="url(#button)" stroke="#626e76" stroke-width="1" />
<path d="M6-7C-4-13-12-4-6 5C-3 9 3 9 7 5" fill="none" stroke="#172127" stroke-width="3" />
</g>
<g id="capture">
<rect x="-15.5" y="-15.5" width="31" height="31" rx="4" fill="#172127" />
<rect x="-13.5" y="-14" width="27" height="27" rx="2.5" fill="url(#button)" stroke="#626e76" stroke-width="1" />
<circle cy="-1" r="9.5" fill="none" stroke="#1c2830" stroke-width="1.4" />
<path d="M-9 2A9.5 9.5 0 0 0 9 2" fill="none" stroke="#738188" stroke-width=".8" />
</g>
</defs>
<g id="grip" aria-label="Connecting Joy-Con grip">
<path id="grip-wing" d="M82 164C41 188 27 287 12 398C0 470-1 517 22 536C42 553 69 535 94 507L125 339Z" fill="url(#shell)" stroke="#1b252b" stroke-width="2" />
<use xlink:href="#grip-wing" transform="translate(672 0) scale(-1 1)" />
<path d="M71 185C43 242 31 331 21 404C12 470 10 510 30 525" fill="none" stroke="#66747c" stroke-width="3" />
<path d="M601 185C629 242 641 331 651 404C660 470 662 510 642 525" fill="none" stroke="#66747c" stroke-width="3" />
<path d="M219 5H453V538H219Z" fill="#141f26" />
<path d="M231 5H441Q444 5 444 9V537H228V9Q228 5 231 5Z" fill="url(#shell)" stroke="#182228" stroke-width="1.5" />
<path d="M231 9H441" stroke="#7b878d" stroke-width="2" />
<path d="M231 534H441" stroke="#4d5961" stroke-width="1.5" />
</g>
<g transform="translate(64 0)">
<g id="left-joycon" aria-label="Left Joy-Con 2">
<path d="M139 1C61 0 3 48-5 125L1 127C8 59 56 10 139 9Z" fill="url(#shell)" stroke="#667178" stroke-width="1.2" />
<path d="M161 9H165V532H161Z" fill="#77cce8" />
<use xlink:href="#right-shell" transform="translate(164 0) scale(-1 1)" fill="url(#face)" stroke="#172127" stroke-width="2" />
<path d="M158 9H144C58 9 5 64 4 138" fill="none" stroke="#79848a" stroke-opacity=".7" stroke-width="1.5" />
<path d="M4 390C8 483 39 531 143 533H157" fill="none" stroke="#4c585f" stroke-width="1.2" />
<rect x="119" y="58" width="28" height="9" rx="1.5" fill="#19232a" stroke="#68757d" stroke-width="1" />
<path d="M121 60H145V63H121Z" fill="#48545c" />
<use xlink:href="#stick" transform="translate(84 141)" color="#83d4ee" />
<g transform="translate(84 239)">
<use xlink:href="#key" />
<path d="M0-9L-7 3H7Z" fill="#162128" />
</g>
<g transform="translate(44 278)">
<use xlink:href="#key" />
<path d="M-8-1L5-8V6Z" fill="#162128" />
</g>
<g transform="translate(124 278)">
<use xlink:href="#key" />
<path d="M8-1L-5-8V6Z" fill="#162128" />
</g>
<g transform="translate(84 317)">
<use xlink:href="#key" />
<path d="M0 7L-7-5H7Z" fill="#162128" />
</g>
<use xlink:href="#capture" transform="translate(119 375)" />
</g>
</g>
<g transform="translate(444 0)">
<g id="right-joycon" aria-label="Right Joy-Con 2">
<path d="M25 1C103 0 161 48 169 125L163 127C156 59 108 10 25 9Z" fill="url(#shell)" stroke="#667178" stroke-width="1.2" />
<path d="M1 9H5V533H1Z" fill="#f38c7d" stroke="#c56960" stroke-width="1" />
<use xlink:href="#right-shell" fill="url(#face)" stroke="#172127" stroke-width="2" />
<path d="M6 9H20C106 9 159 64 160 138" fill="none" stroke="#79848a" stroke-opacity=".7" stroke-width="1.5" />
<path d="M160 390C156 483 125 531 21 533H7" fill="none" stroke="#4c585f" stroke-width="1.2" />
<g transform="translate(31 63)">
<path d="M-4-14H4V-4H14V4H4V14H-4V4H-14V-4H-4Z" fill="#182229" stroke="#68757d" stroke-width="1.1" />
<path d="M-2-12H2V-2H12V2H2V12H-2V2H-12V-2H-2Z" fill="url(#button)" />
</g>
<g transform="translate(80 101)">
<use xlink:href="#key" />
<path d="M-6-9L6 7M6-9L-6 7" fill="none" stroke="#bcc5c9" stroke-width="1.7" />
</g>
<g transform="translate(40 140)">
<use xlink:href="#key" />
<path d="M-6-9L0-1L6-9M0-1V7" fill="none" stroke="#bcc5c9" stroke-width="1.7" />
</g>
<g transform="translate(120 140)">
<use xlink:href="#key" />
<path d="M-7 7L0-10L7 7M-4.5 2H4.5" fill="none" stroke="#bcc5c9" stroke-width="1.7" stroke-linejoin="round" />
</g>
<g transform="translate(80 179)">
<use xlink:href="#key" />
<path d="M-5-9V7H1C9 7 9-1 1-1H-5M-5-9H1C8-9 8-1 1-1" fill="none" stroke="#bcc5c9" stroke-width="1.7" stroke-linejoin="round" />
</g>
<use xlink:href="#stick" transform="translate(80 278)" color="#f58d80" />
<use xlink:href="#home" transform="translate(45 375)" />
<use xlink:href="#chat" transform="translate(45 431)" />
</g>
</g>
<g id="console-mark" transform="translate(266 208) scale(.87)" fill="#151e23">
<path d="M36 0H22C9 0 0 10 0 23V59C0 72 9 82 22 82H36ZM29 7V75H22C13 75 7 69 7 59V23C7 13 13 7 22 7Z" fill-rule="evenodd" />
<circle cx="21" cy="25" r="7.8" />
<path d="M46 0H61C74 0 82 10 82 23V59C82 72 74 82 61 82H46ZM64 37A8 8 0 1 0 64 53A8 8 0 1 0 64 37Z" fill-rule="evenodd" />
<path d="M94 17 C103 4 113-3 127-3 C147-3 158 8 158 25 C158 42 146 52 119 66 H159 V82 H94 V68 C136 35 139 32 138 24 C138 17 133 14 127 14 C119 14 112 19 106 27Z" />
<g font-family="Arial, Helvetica, sans-serif" text-anchor="middle" font-weight="700">
<path aria-label="NINTENDO" d="M26.371 111 21.274 101.994Q21.424 103.305 21.424 104.102V111H19.249V99.304H22.046L27.218 108.385Q27.068 107.132 27.068 106.103V99.304H29.243V111ZM36.526 111V99.304H38.974V111ZM53.371 111 48.274 101.994Q48.424 103.305 48.424 104.102V111H46.249V99.304H49.046L54.218 108.385Q54.068 107.132 54.068 106.103V99.304H56.243V111ZM68.805 101.197V111H66.356V101.197H62.579V99.304H72.59V101.197ZM78.91 111V99.304H88.107V101.197H81.359V104.135H87.601V106.028H81.359V109.107H88.448V111ZM102.371 111 97.274 101.994Q97.424 103.305 97.424 104.102V111H95.249V99.304H98.046L103.218 108.385Q103.068 107.132 103.068 106.103V99.304H105.243V111ZM122.951 105.065Q122.951 106.875 122.242 108.223Q121.532 109.572 120.233 110.286Q118.934 111 117.257 111H112.526V99.304H116.759Q119.714 99.304 121.333 100.794Q122.951 102.284 122.951 105.065ZM120.486 105.065Q120.486 103.181 119.507 102.189Q118.527 101.197 116.709 101.197H114.974V109.107H117.05Q118.627 109.107 119.556 108.02Q120.486 106.933 120.486 105.065ZM141.175 105.098Q141.175 106.924 140.452 108.311Q139.73 109.697 138.385 110.431Q137.041 111.166 135.248 111.166Q132.492 111.166 130.927 109.543Q129.363 107.92 129.363 105.098Q129.363 102.284 130.923 100.707Q132.484 99.13 135.264 99.13Q138.045 99.13 139.61 100.724Q141.175 102.317 141.175 105.098ZM138.676 105.098Q138.676 103.206 137.78 102.131Q136.883 101.056 135.264 101.056Q133.621 101.056 132.724 102.122Q131.828 103.189 131.828 105.098Q131.828 107.024 132.745 108.132Q133.662 109.24 135.248 109.24Q136.891 109.24 137.784 108.161Q138.676 107.082 138.676 105.098Z" />
<path aria-label="SWITCH" d="M33.054 135.26Q33.054 138.696 30.506 140.514Q27.958 142.332 23.027 142.332Q18.528 142.332 15.971 140.738Q13.415 139.145 12.684 135.907L17.416 135.127Q17.897 136.986 19.292 137.825Q20.686 138.663 23.16 138.663Q28.29 138.663 28.29 135.542Q28.29 134.546 27.7 133.898Q27.111 133.251 26.04 132.819Q24.969 132.388 21.931 131.773Q19.308 131.159 18.279 130.786Q17.25 130.412 16.419 129.906Q15.589 129.399 15.008 128.686Q14.427 127.972 14.104 127.009Q13.78 126.046 13.78 124.801Q13.78 121.63 16.162 119.945Q18.544 118.26 23.093 118.26Q27.443 118.26 29.626 119.621Q31.809 120.982 32.44 124.12L27.692 124.768Q27.327 123.257 26.206 122.493Q25.085 121.729 22.994 121.729Q18.544 121.729 18.544 124.519Q18.544 125.432 19.018 126.013Q19.491 126.594 20.42 127Q21.35 127.407 24.189 128.021Q27.559 128.735 29.012 129.341Q30.464 129.947 31.311 130.752Q32.158 131.558 32.606 132.678Q33.054 133.799 33.054 135.26ZM60.897 142H55.086L51.916 128.47Q51.334 126.079 50.936 123.473Q50.538 125.647 50.289 126.785Q50.04 127.922 46.752 142H40.942L34.916 118.608H39.879L43.266 133.716L44.03 137.368Q44.495 135.061 44.935 132.96Q45.375 130.86 48.247 118.608H53.725L56.68 131.06Q57.029 132.454 57.859 137.368L58.274 135.442L59.154 131.624L61.976 118.608H66.94ZM69.748 142V118.608H74.645V142ZM90.252 122.394V142H85.355V122.394H77.801V118.608H97.823V122.394ZM111.886 138.48Q116.319 138.48 118.045 134.031L122.312 135.642Q120.934 139.028 118.27 140.68Q115.605 142.332 111.886 142.332Q106.242 142.332 103.162 139.136Q100.083 135.94 100.083 130.196Q100.083 124.436 103.054 121.348Q106.026 118.26 111.67 118.26Q115.788 118.26 118.377 119.912Q120.967 121.563 122.013 124.768L117.697 125.946Q117.149 124.187 115.547 123.149Q113.945 122.111 111.77 122.111Q108.45 122.111 106.731 124.17Q105.013 126.229 105.013 130.196Q105.013 134.23 106.781 136.355Q108.549 138.48 111.886 138.48ZM141.107 142V131.973H130.914V142H126.016V118.608H130.914V127.922H141.107V118.608H146.004V142Z" />
</g>
</g>
<g id="player-indicators" fill="#536068">
<rect x="237" y="244" width="6" height="7" rx="1.2" />
<rect x="237" y="263" width="6" height="7" rx="1.2" />
<rect x="237" y="282" width="6" height="7" rx="1.2" />
<rect x="237" y="301" width="6" height="7" rx="1.2" />
<rect x="429" y="244" width="6" height="7" rx="1.2" />
<rect x="429" y="263" width="6" height="7" rx="1.2" />
<rect x="429" y="282" width="6" height="7" rx="1.2" />
<rect x="429" y="301" width="6" height="7" rx="1.2" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,132 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1232" height="884" viewBox="-16 160 1232 884" role="img" aria-labelledby="title description">
<title id="title">Nintendo Switch 2 Pro Controller — lightweight transparent illustration</title>
<desc id="description">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.</desc>
<metadata>Reference: https://www.nintendo.com/us/store/products/nintendo-switch-2-pro-controller-123674/ . Original vector illustration; no embedded photographs or external assets.</metadata>
<defs>
<linearGradient id="shell" x1=".1" y1="0" x2=".85" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#3b4348" />
<stop offset="1" stop-color="#20262b" />
</linearGradient>
<radialGradient id="face" cx="43%" cy="-18%" r="114%" gradientTransform="translate(0 -.06) scale(1 1.06)">
<stop offset="0" stop-color="#4b555a" />
<stop offset="1" stop-color="#293137" />
</radialGradient>
<linearGradient id="ivory" x1="0" y1="0" x2=".15" y2="1">
<stop offset="0" stop-color="#e6e8e4" />
<stop offset="1" stop-color="#b5bec2" />
</linearGradient>
<linearGradient id="buttonTop" x1=".3" y1="0" x2=".6" y2="1">
<stop offset="0" stop-color="#505b62" />
<stop offset="1" stop-color="#303a41" />
</linearGradient>
<radialGradient id="stickDish" cx="49%" cy="95%" r="107%">
<stop offset="0" stop-color="#283239" />
<stop offset="1" stop-color="#424f58" />
</radialGradient>
<path id="bodyShape" d="M132 278 C153 234 219 211 319 205 C470 188 730 188 881 205 C981 211 1047 234 1068 278 C1102 312 1118 395 1133 477 C1158 613 1180 743 1195 846 C1209 940 1180 1004 1115 1023 C1061 1041 1022 1006 992 957 C958 902 933 838 907 800 C898 786 888 778 862 778 L338 778 C312 778 302 786 293 800 C267 838 242 902 208 957 C178 1006 139 1041 85 1023 C20 1004 -9 940 5 846 C20 743 42 613 67 477 C82 395 98 312 132 278 Z" />
<path id="faceShape" d="M133 282 C211 216 336 202 600 202 C864 202 989 216 1067 282 C1080 327 1089 386 1082 451 C1077 536 1036 618 977 696 C950 733 914 748 858 749 L342 749 C286 748 250 733 223 696 C164 618 123 536 118 451 C111 386 120 327 133 282 Z" />
<g id="actionButton">
<circle cy="2" r="49.5" fill="#14191c" stroke="#51585b" stroke-opacity=".38" stroke-width="1.5" />
<circle cy="1" r="46.5" fill="#1b242a" />
<circle cy="-2" r="44.5" fill="url(#buttonTop)" stroke="#687278" stroke-width="1.8" />
<path d="M-36-27 A44 44 0 0 1 29-35" fill="none" stroke="#b1b8b9" stroke-width="1.15" opacity=".22" stroke-linecap="round" />
</g>
<g id="smallButton">
<circle cy="1" r="31" fill="#0c1114" stroke="#62696a" stroke-opacity=".3" stroke-width="1.5" />
<circle cy="1" r="28" fill="#1b242a" />
<circle cy="-2" r="26.5" fill="url(#buttonTop)" stroke="#687278" stroke-width="1.7" />
</g>
<g id="analogStick">
<circle r="106" fill="#3f484d" />
<circle r="96" cy="2" fill="#222a2f" />
<circle r="78" cy="1" fill="#131c22" />
<circle r="72" cy="-5" fill="#68747b" stroke="#8d989c" stroke-width="2" />
<circle r="60" cy="-5" fill="url(#stickDish)" stroke="#172127" stroke-width="3" />
</g>
</defs>
<g id="controller">
<g id="shoulder-buttons">
<path d="M127 289 C139 226 207 185 342 177 C366 174 377 177 398 187 L443 209 L244 270 Z" fill="url(#ivory)" stroke="#a3aaab" stroke-width="1.6" />
<path d="M1073 289 C1061 226 993 185 858 177 C834 174 823 177 802 187 L757 209 L956 270 Z" fill="url(#ivory)" stroke="#a3aaab" stroke-width="1.6" />
<path d="M141 258 C170 210 240 186 342 180 C365 177 379 181 395 189" fill="none" stroke="#eceeeb" stroke-width="2" />
<path d="M1059 258 C1030 210 960 186 858 180 C835 177 821 181 805 189" fill="none" stroke="#eceeeb" stroke-width="2" />
<path d="M416 201 Q600 185 784 201 L806 222 H394Z" fill="#a5acae" stroke="#cbd0d0" stroke-width="1.5" />
<path d="M448 198 Q600 190 752 198" stroke="#eff0ed" stroke-opacity=".65" stroke-width="2" fill="none" />
</g>
<use xlink:href="#bodyShape" fill="url(#shell)" stroke="#171c20" stroke-width="2" />
<use xlink:href="#faceShape" fill="url(#face)" stroke="#101619" stroke-width="2.4" />
<path d="M134 279 C222 218 365 202 600 202 C835 202 978 218 1066 279" stroke="#687275" stroke-width="2.2" fill="none" />
<path d="M124 322 C105 459 138 574 225 694 C251 730 287 746 342 747 H858 C913 746 949 730 975 694" fill="none" stroke="#778083" stroke-opacity=".1" stroke-width="1.2" />
<path d="M339 779 H861" stroke="#737e81" stroke-opacity=".23" stroke-width="1.6" />
<g id="console-mark" transform="translate(520 242)" fill="#151e23">
<path d="M36 0H22C9 0 0 10 0 23V59C0 72 9 82 22 82H36ZM29 7V75H22C13 75 7 69 7 59V23C7 13 13 7 22 7Z" fill-rule="evenodd" />
<circle cx="21" cy="25" r="7.8" />
<path d="M46 0H61C74 0 82 10 82 23V59C82 72 74 82 61 82H46ZM64 37A8 8 0 1 0 64 53A8 8 0 1 0 64 37Z" fill-rule="evenodd" />
<path d="M94 17 C103 4 113-3 127-3 C147-3 158 8 158 25 C158 42 146 52 119 66 H159 V82 H94 V68 C136 35 139 32 138 24 C138 17 133 14 127 14 C119 14 112 19 106 27Z" />
<g>
<path aria-label="NINTENDO" d="M26.371 111 21.274 101.994Q21.424 103.305 21.424 104.102V111H19.249V99.304H22.046L27.218 108.385Q27.068 107.132 27.068 106.103V99.304H29.243V111ZM36.526 111V99.304H38.974V111ZM53.371 111 48.274 101.994Q48.424 103.305 48.424 104.102V111H46.249V99.304H49.046L54.218 108.385Q54.068 107.132 54.068 106.103V99.304H56.243V111ZM68.805 101.197V111H66.356V101.197H62.579V99.304H72.59V101.197ZM78.91 111V99.304H88.107V101.197H81.359V104.135H87.601V106.028H81.359V109.107H88.448V111ZM102.371 111 97.274 101.994Q97.424 103.305 97.424 104.102V111H95.249V99.304H98.046L103.218 108.385Q103.068 107.132 103.068 106.103V99.304H105.243V111ZM122.951 105.065Q122.951 106.875 122.242 108.223Q121.532 109.572 120.233 110.286Q118.934 111 117.257 111H112.526V99.304H116.759Q119.714 99.304 121.333 100.794Q122.951 102.284 122.951 105.065ZM120.486 105.065Q120.486 103.181 119.507 102.189Q118.527 101.197 116.709 101.197H114.974V109.107H117.05Q118.627 109.107 119.556 108.02Q120.486 106.933 120.486 105.065ZM141.175 105.098Q141.175 106.924 140.452 108.311Q139.73 109.697 138.385 110.431Q137.041 111.166 135.248 111.166Q132.492 111.166 130.927 109.543Q129.363 107.92 129.363 105.098Q129.363 102.284 130.923 100.707Q132.484 99.13 135.264 99.13Q138.045 99.13 139.61 100.724Q141.175 102.317 141.175 105.098ZM138.676 105.098Q138.676 103.206 137.78 102.131Q136.883 101.056 135.264 101.056Q133.621 101.056 132.724 102.122Q131.828 103.189 131.828 105.098Q131.828 107.024 132.745 108.132Q133.662 109.24 135.248 109.24Q136.891 109.24 137.784 108.161Q138.676 107.082 138.676 105.098Z" />
<path aria-label="SWITCH" d="M33.054 135.26Q33.054 138.696 30.506 140.514Q27.958 142.332 23.027 142.332Q18.528 142.332 15.971 140.738Q13.415 139.145 12.684 135.907L17.416 135.127Q17.897 136.986 19.292 137.825Q20.686 138.663 23.16 138.663Q28.29 138.663 28.29 135.542Q28.29 134.546 27.7 133.898Q27.111 133.251 26.04 132.819Q24.969 132.388 21.931 131.773Q19.308 131.159 18.279 130.786Q17.25 130.412 16.419 129.906Q15.589 129.399 15.008 128.686Q14.427 127.972 14.104 127.009Q13.78 126.046 13.78 124.801Q13.78 121.63 16.162 119.945Q18.544 118.26 23.093 118.26Q27.443 118.26 29.626 119.621Q31.809 120.982 32.44 124.12L27.692 124.768Q27.327 123.257 26.206 122.493Q25.085 121.729 22.994 121.729Q18.544 121.729 18.544 124.519Q18.544 125.432 19.018 126.013Q19.491 126.594 20.42 127Q21.35 127.407 24.189 128.021Q27.559 128.735 29.012 129.341Q30.464 129.947 31.311 130.752Q32.158 131.558 32.606 132.678Q33.054 133.799 33.054 135.26ZM60.897 142H55.086L51.916 128.47Q51.334 126.079 50.936 123.473Q50.538 125.647 50.289 126.785Q50.04 127.922 46.752 142H40.942L34.916 118.608H39.879L43.266 133.716L44.03 137.368Q44.495 135.061 44.935 132.96Q45.375 130.86 48.247 118.608H53.725L56.68 131.06Q57.029 132.454 57.859 137.368L58.274 135.442L59.154 131.624L61.976 118.608H66.94ZM69.748 142V118.608H74.645V142ZM90.252 122.394V142H85.355V122.394H77.801V118.608H97.823V122.394ZM111.886 138.48Q116.319 138.48 118.045 134.031L122.312 135.642Q120.934 139.028 118.27 140.68Q115.605 142.332 111.886 142.332Q106.242 142.332 103.162 139.136Q100.083 135.94 100.083 130.196Q100.083 124.436 103.054 121.348Q106.026 118.26 111.67 118.26Q115.788 118.26 118.377 119.912Q120.967 121.563 122.013 124.768L117.697 125.946Q117.149 124.187 115.547 123.149Q113.945 122.111 111.77 122.111Q108.45 122.111 106.731 124.17Q105.013 126.229 105.013 130.196Q105.013 134.23 106.781 136.355Q108.549 138.48 111.886 138.48ZM141.107 142V131.973H130.914V142H126.016V118.608H130.914V127.922H141.107V118.608H146.004V142Z" />
</g>
</g>
<g id="minus-button" transform="translate(454 341)">
<use xlink:href="#smallButton" />
<path d="M-13-4H13V2H-13Z" fill="#141b20" />
<path d="M-13 3H13" stroke="#778186" stroke-opacity=".45" />
</g>
<g id="plus-button" transform="translate(743 341)">
<use xlink:href="#smallButton" />
<path d="M-3-16H3V-5H14V1H3V12H-3V1H-14V-5H-3Z" fill="#11191e" />
<path d="M-14 2H-4V13H3 M4 2H14" stroke="#7a8589" stroke-opacity=".35" fill="none" />
</g>
<g id="left-stick" transform="translate(272 436)">
<use xlink:href="#analogStick" />
</g>
<g id="right-stick" transform="translate(746 607)">
<use xlink:href="#analogStick" />
</g>
<g id="action-buttons">
<g transform="translate(909 351)">
<use xlink:href="#actionButton" />
<path d="M-11-17L11 13M11-17L-11 13" fill="none" stroke="#bac0c1" stroke-width="3" stroke-linecap="square" />
</g>
<g transform="translate(813 434)">
<use xlink:href="#actionButton" />
<path d="M-11-17L0 0L11-17M0 0V13" fill="none" stroke="#bac0c1" stroke-width="3" stroke-linecap="square" stroke-linejoin="round" />
</g>
<g transform="translate(1005 434)">
<use xlink:href="#actionButton" />
<path d="M-13 13L-1-17H1L13 13M-8 3H8" fill="none" stroke="#bac0c1" stroke-width="3" stroke-linejoin="round" stroke-linecap="square" />
</g>
<g transform="translate(909 518)">
<use xlink:href="#actionButton" />
<path d="M-9-17V13H2C17 13 17-3 3-3H-9M-9-17H1C14-17 15-3 3-3" fill="none" stroke="#bac0c1" stroke-width="3" stroke-linejoin="round" />
</g>
</g>
<g id="capture-button" transform="translate(526 434)">
<rect x="-28" y="-28" width="56" height="56" rx="9" fill="#11171b" stroke="#667073" stroke-opacity=".24" stroke-width="1.3" />
<rect x="-24.5" y="-25" width="49" height="49" rx="6" fill="url(#buttonTop)" stroke="#687278" stroke-width="1.8" />
<circle cy="-1" r="17" fill="none" stroke="#101b21" stroke-width="2" />
<path d="M-16 5A17 17 0 0 0 16 5" fill="none" stroke="#7a858a" stroke-opacity=".6" stroke-width="1.5" />
</g>
<g id="home-button" transform="translate(672 435)">
<use xlink:href="#smallButton" />
<path d="M-18-5L0-20L18-5H12V11H-12V-5ZM-4-3V5H4V-3Z" fill="#11191e" fill-rule="evenodd" />
<path d="M-12 12H12 M-4-2H4" fill="none" stroke="#929a9d" stroke-opacity=".35" />
</g>
<g id="directional-pad" transform="translate(424 602)">
<path d="M-22-99H23Q36-99 36-86V-36H85Q99-36 99-22V23Q99 37 85 37H36V86Q36 99 23 99H-23Q-36 99-36 86V37H-85Q-99 37-99 23V-22Q-99-36-85-36H-36V-86Q-36-99-22-99Z" fill="#11171b" stroke="#50595c" stroke-opacity=".3" stroke-width="1.5" />
<path d="M-21-91H22Q29-91 29-83V-30H83Q91-30 91-22V22Q91 30 83 30H29V83Q29 91 21 91H-21Q-29 91-29 83V30H-83Q-91 30-91 22V-22Q-91-30-83-30H-29V-83Q-29-91-21-91Z" fill="#141d23" />
<path d="M-20-88H20Q27-88 27-81V-30Q27-27 31-27H81Q88-27 88-20V19Q88 26 81 26H31Q27 26 27 30V80Q27 87 20 87H-20Q-27 87-27 80V30Q-27 26-31 26H-81Q-88 26-88 19V-20Q-88-27-81-27H-31Q-27-27-27-30V-81Q-27-88-20-88Z" fill="#657178" />
<path d="M-18-81H18Q21-81 21-77V-31Q21-20 32-20H77Q81-20 81-17V16Q81 19 77 19H32Q21 19 21 30V76Q21 80 18 80H-18Q-21 80-21 76V30Q-21 19-32 19H-77Q-81 19-81 16V-17Q-81-20-77-20H-32Q-21-20-21-31V-77Q-21-81-18-81Z" fill="url(#buttonTop)" />
<path d="M-21-84H19 M-84-21H-33Q-23-21-23-32V-78 M31-22H80" stroke="#adb5b7" stroke-opacity=".4" stroke-width="1.6" fill="none" stroke-linecap="round" />
<path d="M-6-53L0-65L7-53Z M-6 56L0 68L7 56Z M-54-6L-66 1L-54 8Z M55-6L67 1L55 8Z" fill="#b0b8ba" opacity=".78" />
<circle cy="-1" r="15" fill="#202a31" opacity=".13" />
</g>
<g id="chat-button" transform="translate(599 694)">
<rect x="-27" y="-27" width="54" height="54" rx="8" fill="#0c1216" stroke="#717b7e" stroke-opacity=".3" stroke-width="1.5" />
<rect x="-23.5" y="-24" width="47" height="47" rx="5" fill="url(#buttonTop)" stroke="#687278" stroke-width="1.4" />
<path d="M10-10C-7-20-19-6-10 7C-5 14 4 14 11 8" fill="none" stroke="#10181d" stroke-width="5.5" stroke-linecap="square" />
<path d="M-10 9C-5 15 5 16 12 10" fill="none" stroke="#8b9599" stroke-opacity=".35" stroke-width="1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,113 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="570" viewBox="0 0 800 570" role="img" aria-labelledby="title description">
<title id="title">Nintendo Switch Pro Controller — original model</title>
<desc id="description">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.</desc>
<metadata>Visual reference: https://www.nintendo.com/us/store/products/pro-controller/ . Independently drawn vector artwork.</metadata>
<defs>
<linearGradient id="shell" x1="0" y1="0" x2=".8" y2="1">
<stop stop-color="#3c454b" />
<stop offset="1" stop-color="#20272c" />
</linearGradient>
<linearGradient id="face" x1=".2" y1="0" x2=".75" y2="1">
<stop stop-color="#4b555a" />
<stop offset="1" stop-color="#293137" />
</linearGradient>
<linearGradient id="button" x1="0" y1="0" x2=".3" y2="1">
<stop stop-color="#505b62" />
<stop offset="1" stop-color="#303a41" />
</linearGradient>
<radialGradient id="dish" cx="50%" cy="90%" r="100%">
<stop stop-color="#283239" />
<stop offset="1" stop-color="#424f58" />
</radialGradient>
<g id="stick">
<circle cy="2" r="62" fill="#202a31" stroke="#546169" stroke-width="2" />
<circle cy="1" r="52" fill="#141e25" />
<circle cy="-2" r="47" fill="#536169" stroke="#748189" stroke-width="1.5" />
<circle cy="-2" r="39" fill="url(#dish)" stroke="#1b272f" stroke-width="2.5" />
</g>
<g id="key">
<circle cy="2" r="29" fill="#172128" />
<circle cy="-1" r="26.5" fill="url(#button)" stroke="#657179" stroke-width="1.3" />
</g>
<g id="small-key">
<circle cy="1" r="19" fill="#172128" />
<circle cy="-1" r="17" fill="url(#button)" stroke="#657179" stroke-width="1.1" />
</g>
<g id="capture">
<rect x="-17" y="-17" width="34" height="34" rx="5" fill="#172128" />
<rect x="-14.5" y="-15" width="29" height="29" rx="3" fill="url(#button)" stroke="#657179" stroke-width="1.2" />
<circle cy="-1" r="10.5" fill="none" stroke="#202c33" stroke-width="1.5" />
</g>
<g id="home">
<use xlink:href="#small-key" />
<path d="M-11-3L0-13L11-3H8V8H-8V-3ZM-3-2V4H3V-2Z" fill="#18232b" fill-rule="evenodd" />
</g>
</defs>
<g id="controller">
<g id="shoulder-buttons" fill="url(#shell)" stroke="#647079" stroke-width="1.4">
<path d="M105 94C114 49 155 23 231 22L275 46L198 98Z" />
<path d="M695 94C686 49 645 23 569 22L525 46L602 98Z" />
</g>
<path d="M153 57C218 34 300 40 400 39C500 40 582 34 647 57C696 75 720 128 738 220C754 301 776 414 780 458C785 512 763 543 723 552C681 560 658 529 631 480L597 421Q586 405 566 405H234Q214 405 203 421L169 480C142 529 119 560 77 552C37 543 15 512 20 458C24 414 46 301 62 220C80 128 104 75 153 57Z" fill="url(#shell)" stroke="#182229" stroke-width="2" />
<path d="M153 57C218 34 300 40 400 39C500 40 582 34 647 57C696 75 716 122 733 207C684 223 646 257 616 303C585 351 574 385 566 405H234C226 385 215 351 184 303C154 257 116 223 67 207C84 122 104 75 153 57Z" fill="url(#face)" stroke="#182229" stroke-width="1.8" />
<path d="M107 112C124 81 139 69 157 63C223 41 302 45 400 44C498 45 577 41 643 63C661 69 676 81 693 112" fill="none" stroke="#87939a" stroke-width="1.6" />
<path d="M69 212C120 232 160 270 188 313C213 351 224 381 230 402M731 212C680 232 640 270 612 313C587 351 576 381 570 402" fill="none" stroke="#637179" stroke-width="1.3" />
<path d="M40 433C31 499 51 535 81 544M760 433C769 499 749 535 719 544" fill="none" stroke="#56636b" stroke-opacity=".6" stroke-width="1.3" />
<g id="smoky-shell-detail" fill="none" stroke="#85939b" stroke-width="2" opacity=".12">
<circle cx="171" cy="103" r="10" />
<circle cx="629" cy="103" r="10" />
<circle cx="258" cy="355" r="9" />
<circle cx="547" cy="361" r="9" />
<path d="M198 87H246L271 102M602 87H554L529 102M350 262H445L461 279V345H367L350 328ZM274 373H340M474 375H525" />
</g>
<use xlink:href="#stick" transform="translate(190 181)" />
<use xlink:href="#stick" transform="translate(500 306)" />
<g id="minus-button" transform="translate(301 118)">
<use xlink:href="#small-key" />
<path d="M-9-3H9V2H-9Z" fill="#18232b" />
</g>
<g id="plus-button" transform="translate(499 118)">
<use xlink:href="#small-key" />
<path d="M-2.5-12H2.5V-3H11V2H2.5V11H-2.5V2H-11V-3H-2.5Z" fill="#18232b" />
</g>
<use xlink:href="#capture" transform="translate(349 181)" />
<use xlink:href="#home" transform="translate(451 181)" />
<g id="directional-pad" transform="translate(294 306)">
<path d="M-17-62H17Q23-62 23-56V-23H56Q62-23 62-17V17Q62 23 56 23H23V56Q23 62 17 62H-17Q-23 62-23 56V23H-56Q-62 23-62 17V-17Q-62-23-56-23H-23V-56Q-23-62-17-62Z" fill="#152028" />
<path d="M-15-57H15Q18-57 18-54V-23Q18-18 23-18H54Q57-18 57-15V15Q57 18 54 18H23Q18 18 18 23V54Q18 57 15 57H-15Q-18 57-18 54V23Q-18 18-23 18H-54Q-57 18-57 15V-15Q-57-18-54-18H-23Q-18-18-18-23V-54Q-18-57-15-57Z" fill="url(#button)" stroke="#6b7880" stroke-width="1.5" />
<path d="M-4-38L0-46L4-38ZM-4 38L0 46L4 38ZM-38-4L-46 0L-38 4ZM38-4L46 0L38 4Z" fill="#b4bfc5" />
</g>
<g id="player-indicators">
<path d="M356 406H444V416H356Z" fill="#1a252c" />
<rect x="372" y="409" width="8" height="3" rx="1" fill="#99bc76" />
<path d="M388 409H396V412H388ZM404 409H412V412H404ZM420 409H428V412H420Z" fill="#606e76" />
</g>
<g transform="translate(610 138)">
<use xlink:href="#key" />
<path d="M-8-13L8 10M8-13L-8 10" fill="none" stroke="#bdc6cb" stroke-width="2.6" stroke-linejoin="round" />
</g>
<g transform="translate(558 190)">
<use xlink:href="#key" />
<path d="M-9-13L0-1L9-13M0-1V10" fill="none" stroke="#bdc6cb" stroke-width="2.6" stroke-linejoin="round" />
</g>
<g transform="translate(662 190)">
<use xlink:href="#key" />
<path d="M-10 10L0-14L10 10M-6 2H6" fill="none" stroke="#bdc6cb" stroke-width="2.6" stroke-linejoin="round" />
</g>
<g transform="translate(610 242)">
<use xlink:href="#key" />
<path d="M-7-13V10H1C12 10 12-2 1-2H-7M-7-13H1C11-13 11-2 1-2" fill="none" stroke="#bdc6cb" stroke-width="2.6" stroke-linejoin="round" />
</g>
<g id="switch-mark" transform="translate(349 63) scale(.64)" fill="#919da3" opacity=".65">
<g transform="translate(39 0)">
<path d="M36 0H22C9 0 0 10 0 23V59C0 72 9 82 22 82H36ZM29 7V75H22C13 75 7 69 7 59V23C7 13 13 7 22 7Z" fill-rule="evenodd" />
<circle cx="21" cy="25" r="7.8" />
<path d="M46 0H61C74 0 82 10 82 23V59C82 72 74 82 61 82H46ZM64 37A8 8 0 1 0 64 53A8 8 0 1 0 64 37Z" fill-rule="evenodd" />
</g>
<g>
<path aria-label="NINTENDO" d="M26.371 111 21.274 101.994Q21.424 103.305 21.424 104.102V111H19.249V99.304H22.046L27.218 108.385Q27.068 107.132 27.068 106.103V99.304H29.243V111ZM36.526 111V99.304H38.974V111ZM53.371 111 48.274 101.994Q48.424 103.305 48.424 104.102V111H46.249V99.304H49.046L54.218 108.385Q54.068 107.132 54.068 106.103V99.304H56.243V111ZM68.805 101.197V111H66.356V101.197H62.579V99.304H72.59V101.197ZM78.91 111V99.304H88.107V101.197H81.359V104.135H87.601V106.028H81.359V109.107H88.448V111ZM102.371 111 97.274 101.994Q97.424 103.305 97.424 104.102V111H95.249V99.304H98.046L103.218 108.385Q103.068 107.132 103.068 106.103V99.304H105.243V111ZM122.951 105.065Q122.951 106.875 122.242 108.223Q121.532 109.572 120.233 110.286Q118.934 111 117.257 111H112.526V99.304H116.759Q119.714 99.304 121.333 100.794Q122.951 102.284 122.951 105.065ZM120.486 105.065Q120.486 103.181 119.507 102.189Q118.527 101.197 116.709 101.197H114.974V109.107H117.05Q118.627 109.107 119.556 108.02Q120.486 106.933 120.486 105.065ZM141.175 105.098Q141.175 106.924 140.452 108.311Q139.73 109.697 138.385 110.431Q137.041 111.166 135.248 111.166Q132.492 111.166 130.927 109.543Q129.363 107.92 129.363 105.098Q129.363 102.284 130.923 100.707Q132.484 99.13 135.264 99.13Q138.045 99.13 139.61 100.724Q141.175 102.317 141.175 105.098ZM138.676 105.098Q138.676 103.206 137.78 102.131Q136.883 101.056 135.264 101.056Q133.621 101.056 132.724 102.122Q131.828 103.189 131.828 105.098Q131.828 107.024 132.745 108.132Q133.662 109.24 135.248 109.24Q136.891 109.24 137.784 108.161Q138.676 107.082 138.676 105.098Z" />
<path aria-label="SWITCH" d="M33.054 135.26Q33.054 138.696 30.506 140.514Q27.958 142.332 23.027 142.332Q18.528 142.332 15.971 140.738Q13.415 139.145 12.684 135.907L17.416 135.127Q17.897 136.986 19.292 137.825Q20.686 138.663 23.16 138.663Q28.29 138.663 28.29 135.542Q28.29 134.546 27.7 133.898Q27.111 133.251 26.04 132.819Q24.969 132.388 21.931 131.773Q19.308 131.159 18.279 130.786Q17.25 130.412 16.419 129.906Q15.589 129.399 15.008 128.686Q14.427 127.972 14.104 127.009Q13.78 126.046 13.78 124.801Q13.78 121.63 16.162 119.945Q18.544 118.26 23.093 118.26Q27.443 118.26 29.626 119.621Q31.809 120.982 32.44 124.12L27.692 124.768Q27.327 123.257 26.206 122.493Q25.085 121.729 22.994 121.729Q18.544 121.729 18.544 124.519Q18.544 125.432 19.018 126.013Q19.491 126.594 20.42 127Q21.35 127.407 24.189 128.021Q27.559 128.735 29.012 129.341Q30.464 129.947 31.311 130.752Q32.158 131.558 32.606 132.678Q33.054 133.799 33.054 135.26ZM60.897 142H55.086L51.916 128.47Q51.334 126.079 50.936 123.473Q50.538 125.647 50.289 126.785Q50.04 127.922 46.752 142H40.942L34.916 118.608H39.879L43.266 133.716L44.03 137.368Q44.495 135.061 44.935 132.96Q45.375 130.86 48.247 118.608H53.725L56.68 131.06Q57.029 132.454 57.859 137.368L58.274 135.442L59.154 131.624L61.976 118.608H66.94ZM69.748 142V118.608H74.645V142ZM90.252 122.394V142H85.355V122.394H77.801V118.608H97.823V122.394ZM111.886 138.48Q116.319 138.48 118.045 134.031L122.312 135.642Q120.934 139.028 118.27 140.68Q115.605 142.332 111.886 142.332Q106.242 142.332 103.162 139.136Q100.083 135.94 100.083 130.196Q100.083 124.436 103.054 121.348Q106.026 118.26 111.67 118.26Q115.788 118.26 118.377 119.912Q120.967 121.563 122.013 124.768L117.697 125.946Q117.149 124.187 115.547 123.149Q113.945 122.111 111.77 122.111Q108.45 122.111 106.731 124.17Q105.013 126.229 105.013 130.196Q105.013 134.23 106.781 136.355Q108.549 138.48 111.886 138.48ZM141.107 142V131.973H130.914V142H126.016V118.608H130.914V127.922H141.107V118.608H146.004V142Z" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,149 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="540" height="764" viewBox="0 0 540 764" role="img" aria-labelledby="title description">
<title id="title">Wii Remote and Nunchuk — connected white controllers</title>
<desc id="description">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.</desc>
<metadata>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.</metadata>
<defs>
<linearGradient id="white" x1=".1" y1="0" x2=".9" y2="1">
<stop stop-color="#f5f7f9" />
<stop offset="1" stop-color="#d7dee5" />
</linearGradient>
<linearGradient id="side" x1="0" y1="0" x2="1" y2=".6">
<stop stop-color="#e1e7ec" />
<stop offset="1" stop-color="#b9c4cf" />
</linearGradient>
<linearGradient id="button" x1=".2" y1="0" x2=".7" y2="1">
<stop stop-color="#fff" />
<stop offset="1" stop-color="#d3dde6" />
</linearGradient>
<radialGradient id="rubber" cx="45%" cy="30%" r="80%">
<stop stop-color="#eef2f4" />
<stop offset="1" stop-color="#c1cdd5" />
</radialGradient>
<radialGradient id="nunchuk-shell" cx="32%" cy="18%" r="90%">
<stop stop-color="#f5f8fa"/>
<stop offset=".58" stop-color="#dce4ea"/>
<stop offset="1" stop-color="#b3c1cc"/>
</radialGradient>
<linearGradient id="nunchuk-deck" x1=".25" y1="0" x2=".8" y2="1">
<stop stop-color="#ffffff"/>
<stop offset=".65" stop-color="#e9eff3"/>
<stop offset="1" stop-color="#ced9e1"/>
</linearGradient>
<g id="small-button">
<circle cy="1" r="14" fill="#b1bfca" />
<circle cy="-1" r="12.5" fill="url(#button)" stroke="#f9fbfc" stroke-width="1.1" />
</g>
<g id="number-button">
<circle cy="1" r="21" fill="#a5b4c0" />
<circle cy="-1" r="19" fill="url(#button)" stroke="#fbfcfd" stroke-width="1.2" />
</g>
</defs>
<g id="nunchuk-cable" fill="none" stroke-linecap="round">
<path d="M13 504C8 597 43 688 143 718C219 741 321 739 375 718C404 707 420 696 420 676" stroke="#a4b2bd" stroke-width="9"/>
<path d="M13 504C8 597 43 688 143 718C219 741 321 739 375 718C404 707 420 696 420 676" stroke="#e7edf1" stroke-width="6.5"/>
</g>
<g id="expansion-connector">
<path d="M396 615H444V651Q444 664 435 665H405Q396 664 396 651Z" fill="url(#side)" stroke="#96a6b2" stroke-width="1.3" />
<path d="M399 615H441V648Q441 657 434 658H406Q399 657 399 648Z" fill="url(#white)" stroke="#f7f9fb" stroke-width="1.2" />
<path d="M391 629H396V643H391ZM444 629H449V643H444Z" fill="#bac6cf" stroke="#93a4b2" stroke-width="1" />
<path d="M413 663H427V679H413Z" fill="#d6dfe5" stroke="#9cabb7" stroke-width="1" />
<path d="M414 668H426M414 673H426" stroke="#a8b6c1" stroke-width="1" />
</g>
<g id="nunchuk" aria-label="White Nunchuk with a sculpted palm grip and low analog stick" transform="translate(-70 140) scale(.6)">
<g id="rear-buttons" stroke="#9eafbc" stroke-width="1.8">
<path d="M611 257L637 263Q650 266 646 280L641 296Q639 302 631 301L605 292Z" fill="url(#side)"/>
<path d="M591 301L621 309Q636 313 631 327L619 351Q615 360 605 356L581 346Z" fill="url(#side)"/>
<path d="M619 270L638 275M603 315L621 321" fill="none" stroke="#f7fafc" stroke-width="2"/>
</g>
<path d="M154 525C130 511 154 452 193 398C240 331 302 276 365 234C418 198 464 175 506 168C552 160 615 165 643 185C656 196 657 224 649 252C637 295 609 346 588 367C569 371 562 356 541 357C498 357 459 389 430 423C390 470 356 529 307 560C261 589 210 584 178 563C162 553 154 540 154 525Z" fill="url(#nunchuk-shell)" stroke="#9fadb7" stroke-width="2.1"/>
<path d="M154 525C133 507 164 446 198 399C245 334 305 281 369 238C422 202 466 179 508 173C550 167 610 169 639 185C649 191 652 206 649 221C579 242 516 266 457 302C380 348 324 407 263 464C217 507 182 532 154 525Z" fill="url(#nunchuk-deck)" stroke="#c1cdd6" stroke-width="1.5"/>
<path d="M165 512C194 474 218 440 263 397C320 341 382 289 454 254C524 220 581 202 630 196" fill="none" stroke="#fff" stroke-opacity=".65" stroke-width="4" stroke-linecap="round"/>
<path d="M160 531C193 530 225 503 267 465C327 409 384 352 459 308C521 271 584 247 647 228" fill="none" stroke="#aebec9" stroke-width="1.8"/>
<path d="M181 559C218 580 261 579 301 556C337 534 366 499 396 460" fill="none" stroke="#f1f6f9" stroke-opacity=".8" stroke-width="2"/>
<g id="analog-stick" transform="translate(505 178)">
<ellipse cy="2" rx="60" ry="24" fill="#cad5de"/>
<ellipse cy="1" rx="49" ry="19" fill="#a9bac6"/>
<path d="M-27-34H27L29 1C22 14-22 14-29 1Z" fill="url(#white)" stroke="#b4c3cd" stroke-width="1.5"/>
<path d="M-26-30V0Q0 10 26 0" fill="none" stroke="#fff" stroke-width="2"/>
<path d="M-45-42C-44-54-25-58 0-58C25-58 44-54 45-42L49-35C48-22-48-22-49-35Z" fill="url(#side)" stroke="#b1c0cb" stroke-width="1.5"/>
<ellipse cy="-44" rx="43" ry="13" fill="url(#rubber)" stroke="#f8fbfc" stroke-width="1.8"/>
<ellipse cy="-45" rx="34" ry="9" fill="#e1e9ee" stroke="#c4d0d9" stroke-width="1.2"/>
<path d="M-30-49Q0-58 30-49" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/>
</g>
</g>
<g id="nunchuk-strain-relief" transform="translate(22 485) rotate(25)">
<path d="M-10-13H10V15Q10 21 5 21H-5Q-10 21-10 15Z" fill="url(#side)" stroke="#a0afba" stroke-width="1.1"/>
<path d="M-8-5H8M-8 3H8M-7 11H7" stroke="#a6b6c2" stroke-width="1.3"/>
<path d="M-7-10V15" stroke="#f8fafc" stroke-width="1.1"/>
</g>
<g transform="translate(320 10)">
<g id="wii-remote" aria-label="Original white Wii Remote">
<path d="M44 13H156Q176 13 176 37V585Q176 614 150 614H50Q24 614 24 585V37Q24 13 44 13Z" fill="url(#side)" stroke="#8e9da9" stroke-width="1.5" />
<path d="M49 13H151Q164 13 164 22H36Q36 13 49 13Z" fill="#45545f" />
<path d="M46 19H154Q171 19 171 37V582Q171 601 150 603H50Q29 601 29 582V37Q29 19 46 19Z" fill="url(#white)" stroke="#bdc9d2" stroke-width="1.2" />
<path d="M45 23H155Q167 23 167 38M33 41V579Q33 596 51 598" stroke="#fff" stroke-width="1.7" fill="none" />
<path d="M44 606Q100 611 156 606" fill="none" stroke="#a7b5c0" stroke-width="1.2" />
<g id="power-button" transform="translate(56 50)">
<use xlink:href="#small-button" />
<path d="M-4-6A7 7 0 1 0 4-6M0-9V-1" fill="none" stroke="#ce727b" stroke-width="1.8" stroke-linecap="round" />
</g>
<g id="directional-pad" transform="translate(100 128)">
<path d="M-12-39H12Q17-39 17-34V-17H34Q39-17 39-12V12Q39 17 34 17H17V34Q17 39 12 39H-12Q-17 39-17 34V17H-34Q-39 17-39 12V-12Q-39-17-34-17H-17V-34Q-17-39-12-39Z" fill="#adbcc7" />
<path d="M-11-36H11Q14-36 14-33V-17Q14-14 17-14H33Q36-14 36-11V11Q36 14 33 14H17Q14 14 14 17V33Q14 36 11 36H-11Q-14 36-14 33V17Q-14 14-17 14H-33Q-36 14-36 11V-11Q-36-14-33-14H-17Q-14-14-14-17V-33Q-14-36-11-36Z" fill="url(#button)" stroke="#fff" stroke-width="1.2" />
<path d="M0-28V-20M0 20V28M-28 0H-20M20 0H28" fill="none" stroke="#a9b7c2" stroke-width="2.3" stroke-linecap="round" />
</g>
<g id="a-button" transform="translate(100 217)">
<circle cy="1" r="26" fill="#b1bdc9" stroke="#97a7b5" stroke-width="1.2" />
<circle cy="-1" r="23.5" fill="url(#button)" stroke="#fff" stroke-width="1.4" />
<path d="M-7 8L0-12L7 8M-4 1H4" fill="none" stroke="#96a7b6" stroke-width="1.8" stroke-linejoin="round" />
</g>
<g id="minus-button" transform="translate(60 295)">
<use xlink:href="#small-button" />
<path d="M-5-1H5" stroke="#95a6b5" stroke-width="1.8" />
</g>
<g id="home-button" transform="translate(100 295)">
<use xlink:href="#small-button" />
<path d="M-8-2L0-9L8-2H6V6H-6V-2ZM-2 0V4H2V0Z" fill="#68b9d8" fill-rule="evenodd" />
</g>
<g id="plus-button" transform="translate(140 295)">
<use xlink:href="#small-button" />
<path d="M-5-1H5M0-6V4" stroke="#95a6b5" stroke-width="1.8" />
</g>
<g id="one-button" transform="translate(100 446)">
<use xlink:href="#number-button" />
<path d="M-5-7L1-11V9M-5 9H6" fill="none" stroke="#94a5b4" stroke-width="1.8" stroke-linejoin="round" />
</g>
<g id="two-button" transform="translate(100 496)">
<use xlink:href="#number-button" />
<path d="M-6-7C-3-15 10-12 7-4C5 0-2 4-6 9H8" fill="none" stroke="#94a5b4" stroke-width="1.8" stroke-linejoin="round" />
</g>
<g id="player-indicators">
<rect x="54" y="542" width="9" height="9" rx=".8" fill="#64bce8" stroke="#379aca" stroke-width=".7" />
<path d="M82 542H91V551H82ZM110 542H119V551H110ZM138 542H147V551H138Z" fill="#8496a5" />
</g>
<g id="speaker" fill="#8293a1">
<circle cx="89" cy="342" r="1.4" />
<circle cx="100" cy="342" r="1.4" />
<circle cx="111" cy="342" r="1.4" />
<circle cx="89" cy="353" r="2.1" />
<circle cx="100" cy="353" r="2.1" />
<circle cx="111" cy="353" r="2.1" />
<circle cx="89" cy="364" r="2.1" />
<circle cx="100" cy="364" r="2.1" />
<circle cx="111" cy="364" r="2.1" />
<circle cx="89" cy="375" r="2.1" />
<circle cx="100" cy="375" r="2.1" />
<circle cx="111" cy="375" r="2.1" />
<circle cx="89" cy="386" r="2.1" />
<circle cx="100" cy="386" r="2.1" />
<circle cx="111" cy="386" r="2.1" />
<circle cx="89" cy="397" r="1.4" />
<circle cx="100" cy="397" r="1.4" />
<circle cx="111" cy="397" r="1.4" />
</g>
<path aria-label="POWER" fill="#a1aeba" d="M46.87 73.97Q46.87 74.585 46.468 74.948Q46.067 75.311 45.378 75.311H44.104V77H43.517V72.666H45.341Q46.07 72.666 46.47 73.007Q46.87 73.349 46.87 73.97ZM46.279 73.976Q46.279 73.136 45.27 73.136H44.104V74.847H45.295Q46.279 74.847 46.279 73.976ZM52.351 74.813Q52.351 75.493 52.091 76.003Q51.831 76.514 51.345 76.788Q50.859 77.062 50.197 77.062Q49.53 77.062 49.045 76.791Q48.561 76.52 48.306 76.008Q48.05 75.496 48.05 74.813Q48.05 73.773 48.619 73.187Q49.188 72.601 50.204 72.601Q50.865 72.601 51.351 72.864Q51.837 73.127 52.094 73.629Q52.351 74.13 52.351 74.813ZM51.751 74.813Q51.751 74.004 51.346 73.542Q50.942 73.081 50.204 73.081Q49.459 73.081 49.053 73.536Q48.647 73.992 48.647 74.813Q48.647 75.628 49.058 76.106Q49.468 76.585 50.197 76.585Q50.948 76.585 51.349 76.122Q51.751 75.659 51.751 74.813ZM57.85 77H57.149L56.398 74.247Q56.325 73.988 56.183 73.321Q56.103 73.678 56.048 73.918Q55.992 74.158 55.208 77H54.507L53.23 72.666H53.842L54.62 75.419Q54.759 75.936 54.876 76.483Q54.949 76.145 55.046 75.745Q55.143 75.345 55.9 72.666H56.463L57.217 75.363Q57.389 76.025 57.487 76.483L57.515 76.376Q57.598 76.022 57.65 75.799Q57.703 75.576 58.515 72.666H59.127ZM60.215 77V72.666H63.504V73.146H60.803V74.536H63.319V75.01H60.803V76.52H63.63V77ZM68.031 77 66.905 75.2H65.555V77H64.967V72.666H67.007Q67.739 72.666 68.137 72.993Q68.536 73.321 68.536 73.905Q68.536 74.388 68.254 74.717Q67.973 75.047 67.477 75.133L68.708 77ZM67.945 73.912Q67.945 73.533 67.688 73.335Q67.431 73.136 66.948 73.136H65.555V74.736H66.973Q67.437 74.736 67.691 74.519Q67.945 74.302 67.945 73.912Z" />
<path aria-label="HOME" fill="#a1aeba" d="M93.173 324V321.991H90.829V324H90.242V319.666H90.829V321.499H93.173V319.666H93.761V324ZM99.424 321.813Q99.424 322.493 99.164 323.003Q98.904 323.514 98.418 323.788Q97.932 324.062 97.27 324.062Q96.603 324.062 96.118 323.791Q95.634 323.52 95.378 323.008Q95.123 322.496 95.123 321.813Q95.123 320.773 95.692 320.187Q96.261 319.601 97.276 319.601Q97.938 319.601 98.424 319.864Q98.91 320.127 99.167 320.629Q99.424 321.13 99.424 321.813ZM98.824 321.813Q98.824 321.004 98.419 320.542Q98.015 320.081 97.276 320.081Q96.532 320.081 96.126 320.536Q95.72 320.992 95.72 321.813Q95.72 322.628 96.13 323.106Q96.541 323.585 97.27 323.585Q98.021 323.585 98.422 323.122Q98.824 322.659 98.824 321.813ZM104.477 324V321.108Q104.477 320.629 104.505 320.186Q104.354 320.736 104.234 321.047L103.114 324H102.702L101.567 321.047L101.395 320.524L101.293 320.186L101.302 320.527L101.315 321.108V324H100.792V319.666H101.564L102.717 322.671Q102.779 322.853 102.836 323.06Q102.893 323.268 102.911 323.36Q102.936 323.237 103.014 322.986Q103.093 322.736 103.12 322.671L104.252 319.666H105.006V324ZM106.59 324V319.666H109.878V320.146H107.177V321.536H109.694V322.01H107.177V323.52H110.004V324Z" />
<path aria-label="Wii" fill="#9eabb6" d="M101.224 585H98.106L94.771 572.764Q94.442 571.615 93.813 568.648Q93.458 570.234 93.212 571.301Q92.966 572.367 89.479 585H86.362L80.688 565.736H83.409L86.868 577.973Q87.483 580.27 88.003 582.703Q88.331 581.199 88.762 579.422Q89.192 577.645 92.556 565.736H95.058L98.407 577.727Q99.173 580.666 99.61 582.703L99.733 582.225Q100.103 580.652 100.335 579.661Q100.567 578.67 104.177 565.736H106.897ZM108.866 567.062V564.711H111.327V567.062ZM108.866 585V570.207H111.327V585ZM115.087 567.062V564.711H117.548V567.062ZM115.087 585V570.207H117.548V585Z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,95 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="632" viewBox="0 0 200 632" role="img" aria-labelledby="title description">
<title id="title">Wii Remote — original white controller</title>
<desc id="description">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.</desc>
<metadata>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.</metadata>
<defs>
<linearGradient id="white" x1=".1" y1="0" x2=".9" y2="1">
<stop stop-color="#f5f7f9" />
<stop offset="1" stop-color="#d7dee5" />
</linearGradient>
<linearGradient id="side" x1="0" y1="0" x2="1" y2=".6">
<stop stop-color="#e1e7ec" />
<stop offset="1" stop-color="#b9c4cf" />
</linearGradient>
<linearGradient id="button" x1=".2" y1="0" x2=".7" y2="1">
<stop stop-color="#fff" />
<stop offset="1" stop-color="#d3dde6" />
</linearGradient>
<g id="small-button">
<circle cy="1" r="14" fill="#b1bfca" />
<circle cy="-1" r="12.5" fill="url(#button)" stroke="#f9fbfc" stroke-width="1.1" />
</g>
<g id="number-button">
<circle cy="1" r="21" fill="#a5b4c0" />
<circle cy="-1" r="19" fill="url(#button)" stroke="#fbfcfd" stroke-width="1.2" />
</g>
</defs>
<g id="wii-remote" aria-label="Original white Wii Remote">
<path d="M44 13H156Q176 13 176 37V585Q176 614 150 614H50Q24 614 24 585V37Q24 13 44 13Z" fill="url(#side)" stroke="#8e9da9" stroke-width="1.5" />
<path d="M49 13H151Q164 13 164 22H36Q36 13 49 13Z" fill="#45545f" />
<path d="M46 19H154Q171 19 171 37V582Q171 601 150 603H50Q29 601 29 582V37Q29 19 46 19Z" fill="url(#white)" stroke="#bdc9d2" stroke-width="1.2" />
<path d="M45 23H155Q167 23 167 38M33 41V579Q33 596 51 598" stroke="#fff" stroke-width="1.7" fill="none" />
<path d="M44 606Q100 611 156 606" fill="none" stroke="#a7b5c0" stroke-width="1.2" />
<g id="power-button" transform="translate(56 50)">
<use xlink:href="#small-button" />
<path d="M-4-6A7 7 0 1 0 4-6M0-9V-1" fill="none" stroke="#ce727b" stroke-width="1.8" stroke-linecap="round" />
</g>
<g id="directional-pad" transform="translate(100 128)">
<path d="M-12-39H12Q17-39 17-34V-17H34Q39-17 39-12V12Q39 17 34 17H17V34Q17 39 12 39H-12Q-17 39-17 34V17H-34Q-39 17-39 12V-12Q-39-17-34-17H-17V-34Q-17-39-12-39Z" fill="#adbcc7" />
<path d="M-11-36H11Q14-36 14-33V-17Q14-14 17-14H33Q36-14 36-11V11Q36 14 33 14H17Q14 14 14 17V33Q14 36 11 36H-11Q-14 36-14 33V17Q-14 14-17 14H-33Q-36 14-36 11V-11Q-36-14-33-14H-17Q-14-14-14-17V-33Q-14-36-11-36Z" fill="url(#button)" stroke="#fff" stroke-width="1.2" />
<path d="M0-28V-20M0 20V28M-28 0H-20M20 0H28" fill="none" stroke="#a9b7c2" stroke-width="2.3" stroke-linecap="round" />
</g>
<g id="a-button" transform="translate(100 217)">
<circle cy="1" r="26" fill="#b1bdc9" stroke="#97a7b5" stroke-width="1.2" />
<circle cy="-1" r="23.5" fill="url(#button)" stroke="#fff" stroke-width="1.4" />
<path d="M-7 8L0-12L7 8M-4 1H4" fill="none" stroke="#96a7b6" stroke-width="1.8" stroke-linejoin="round" />
</g>
<g id="minus-button" transform="translate(60 295)">
<use xlink:href="#small-button" />
<path d="M-5-1H5" stroke="#95a6b5" stroke-width="1.8" />
</g>
<g id="home-button" transform="translate(100 295)">
<use xlink:href="#small-button" />
<path d="M-8-2L0-9L8-2H6V6H-6V-2ZM-2 0V4H2V0Z" fill="#68b9d8" fill-rule="evenodd" />
</g>
<g id="plus-button" transform="translate(140 295)">
<use xlink:href="#small-button" />
<path d="M-5-1H5M0-6V4" stroke="#95a6b5" stroke-width="1.8" />
</g>
<g id="one-button" transform="translate(100 446)">
<use xlink:href="#number-button" />
<path d="M-5-7L1-11V9M-5 9H6" fill="none" stroke="#94a5b4" stroke-width="1.8" stroke-linejoin="round" />
</g>
<g id="two-button" transform="translate(100 496)">
<use xlink:href="#number-button" />
<path d="M-6-7C-3-15 10-12 7-4C5 0-2 4-6 9H8" fill="none" stroke="#94a5b4" stroke-width="1.8" stroke-linejoin="round" />
</g>
<g id="player-indicators">
<rect x="54" y="542" width="9" height="9" rx=".8" fill="#64bce8" stroke="#379aca" stroke-width=".7" />
<path d="M82 542H91V551H82ZM110 542H119V551H110ZM138 542H147V551H138Z" fill="#8496a5" />
</g>
<g id="speaker" fill="#8293a1">
<circle cx="89" cy="342" r="1.4" />
<circle cx="100" cy="342" r="1.4" />
<circle cx="111" cy="342" r="1.4" />
<circle cx="89" cy="353" r="2.1" />
<circle cx="100" cy="353" r="2.1" />
<circle cx="111" cy="353" r="2.1" />
<circle cx="89" cy="364" r="2.1" />
<circle cx="100" cy="364" r="2.1" />
<circle cx="111" cy="364" r="2.1" />
<circle cx="89" cy="375" r="2.1" />
<circle cx="100" cy="375" r="2.1" />
<circle cx="111" cy="375" r="2.1" />
<circle cx="89" cy="386" r="2.1" />
<circle cx="100" cy="386" r="2.1" />
<circle cx="111" cy="386" r="2.1" />
<circle cx="89" cy="397" r="1.4" />
<circle cx="100" cy="397" r="1.4" />
<circle cx="111" cy="397" r="1.4" />
</g>
<path aria-label="POWER" fill="#a1aeba" d="M46.87 73.97Q46.87 74.585 46.468 74.948Q46.067 75.311 45.378 75.311H44.104V77H43.517V72.666H45.341Q46.07 72.666 46.47 73.007Q46.87 73.349 46.87 73.97ZM46.279 73.976Q46.279 73.136 45.27 73.136H44.104V74.847H45.295Q46.279 74.847 46.279 73.976ZM52.351 74.813Q52.351 75.493 52.091 76.003Q51.831 76.514 51.345 76.788Q50.859 77.062 50.197 77.062Q49.53 77.062 49.045 76.791Q48.561 76.52 48.306 76.008Q48.05 75.496 48.05 74.813Q48.05 73.773 48.619 73.187Q49.188 72.601 50.204 72.601Q50.865 72.601 51.351 72.864Q51.837 73.127 52.094 73.629Q52.351 74.13 52.351 74.813ZM51.751 74.813Q51.751 74.004 51.346 73.542Q50.942 73.081 50.204 73.081Q49.459 73.081 49.053 73.536Q48.647 73.992 48.647 74.813Q48.647 75.628 49.058 76.106Q49.468 76.585 50.197 76.585Q50.948 76.585 51.349 76.122Q51.751 75.659 51.751 74.813ZM57.85 77H57.149L56.398 74.247Q56.325 73.988 56.183 73.321Q56.103 73.678 56.048 73.918Q55.992 74.158 55.208 77H54.507L53.23 72.666H53.842L54.62 75.419Q54.759 75.936 54.876 76.483Q54.949 76.145 55.046 75.745Q55.143 75.345 55.9 72.666H56.463L57.217 75.363Q57.389 76.025 57.487 76.483L57.515 76.376Q57.598 76.022 57.65 75.799Q57.703 75.576 58.515 72.666H59.127ZM60.215 77V72.666H63.504V73.146H60.803V74.536H63.319V75.01H60.803V76.52H63.63V77ZM68.031 77 66.905 75.2H65.555V77H64.967V72.666H67.007Q67.739 72.666 68.137 72.993Q68.536 73.321 68.536 73.905Q68.536 74.388 68.254 74.717Q67.973 75.047 67.477 75.133L68.708 77ZM67.945 73.912Q67.945 73.533 67.688 73.335Q67.431 73.136 66.948 73.136H65.555V74.736H66.973Q67.437 74.736 67.691 74.519Q67.945 74.302 67.945 73.912Z" />
<path aria-label="HOME" fill="#a1aeba" d="M93.173 324V321.991H90.829V324H90.242V319.666H90.829V321.499H93.173V319.666H93.761V324ZM99.424 321.813Q99.424 322.493 99.164 323.003Q98.904 323.514 98.418 323.788Q97.932 324.062 97.27 324.062Q96.603 324.062 96.118 323.791Q95.634 323.52 95.378 323.008Q95.123 322.496 95.123 321.813Q95.123 320.773 95.692 320.187Q96.261 319.601 97.276 319.601Q97.938 319.601 98.424 319.864Q98.91 320.127 99.167 320.629Q99.424 321.13 99.424 321.813ZM98.824 321.813Q98.824 321.004 98.419 320.542Q98.015 320.081 97.276 320.081Q96.532 320.081 96.126 320.536Q95.72 320.992 95.72 321.813Q95.72 322.628 96.13 323.106Q96.541 323.585 97.27 323.585Q98.021 323.585 98.422 323.122Q98.824 322.659 98.824 321.813ZM104.477 324V321.108Q104.477 320.629 104.505 320.186Q104.354 320.736 104.234 321.047L103.114 324H102.702L101.567 321.047L101.395 320.524L101.293 320.186L101.302 320.527L101.315 321.108V324H100.792V319.666H101.564L102.717 322.671Q102.779 322.853 102.836 323.06Q102.893 323.268 102.911 323.36Q102.936 323.237 103.014 322.986Q103.093 322.736 103.12 322.671L104.252 319.666H105.006V324ZM106.59 324V319.666H109.878V320.146H107.177V321.536H109.694V322.01H107.177V323.52H110.004V324Z" />
<path aria-label="Wii" fill="#9eabb6" d="M101.224 585H98.106L94.771 572.764Q94.442 571.615 93.813 568.648Q93.458 570.234 93.212 571.301Q92.966 572.367 89.479 585H86.362L80.688 565.736H83.409L86.868 577.973Q87.483 580.27 88.003 582.703Q88.331 581.199 88.762 579.422Q89.192 577.645 92.556 565.736H95.058L98.407 577.727Q99.173 580.666 99.61 582.703L99.733 582.225Q100.103 580.652 100.335 579.661Q100.567 578.67 104.177 565.736H106.897ZM108.866 567.062V564.711H111.327V567.062ZM108.866 585V570.207H111.327V585ZM115.087 567.062V564.711H117.548V567.062ZM115.087 585V570.207H117.548V585Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

View file

@ -0,0 +1,97 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="552" viewBox="0 0 800 552" role="img" aria-labelledby="title description">
<title id="title">Xbox Wireless Controller — Carbon Black</title>
<desc id="description">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.</desc>
<metadata>Visual reference: https://www.xbox.com/en-US/accessories/controllers/xbox-wireless-controller . Independently drawn vector artwork.</metadata>
<defs>
<linearGradient id="shell" x1="0" y1="0" x2=".8" y2="1">
<stop stop-color="#3c454b" />
<stop offset="1" stop-color="#20272c" />
</linearGradient>
<linearGradient id="face" x1=".2" y1="0" x2=".75" y2="1">
<stop stop-color="#4b555a" />
<stop offset="1" stop-color="#293137" />
</linearGradient>
<linearGradient id="button" x1="0" y1="0" x2=".3" y2="1">
<stop stop-color="#505b62" />
<stop offset="1" stop-color="#303a41" />
</linearGradient>
<radialGradient id="dish" cx="50%" cy="90%" r="100%">
<stop stop-color="#283239" />
<stop offset="1" stop-color="#424f58" />
</radialGradient>
<g id="stick">
<circle cy="2" r="62" fill="#202a31" stroke="#546169" stroke-width="2" />
<circle cy="1" r="52" fill="#141e25" />
<circle cy="-2" r="47" fill="#536169" stroke="#748189" stroke-width="1.5" />
<circle cy="-2" r="39" fill="url(#dish)" stroke="#1b272f" stroke-width="2.5" />
</g>
<g id="key">
<circle cy="2" r="29" fill="#172128" />
<circle cy="-1" r="26.5" fill="url(#button)" stroke="#657179" stroke-width="1.3" />
</g>
<g id="small-key">
<circle cy="1" r="19" fill="#172128" />
<circle cy="-1" r="17" fill="url(#button)" stroke="#657179" stroke-width="1.1" />
</g>
</defs>
<g id="controller">
<g id="shoulders" fill="url(#shell)" stroke="#637078" stroke-width="1.4">
<path d="M130 79C141 46 173 31 216 27L269 49L239 86Z" />
<path d="M670 79C659 46 627 31 584 27L531 49L561 86Z" />
</g>
<path d="M180 47C231 38 312 44 400 45C488 44 569 38 620 47C679 54 697 113 718 176C748 266 776 353 782 418C789 479 770 519 729 534C706 540 676 497 637 453C612 424 598 391 563 390H237C202 391 188 424 163 453C124 497 94 540 71 534C30 519 11 479 18 418C24 353 52 266 82 176C103 113 121 54 180 47Z" fill="url(#face)" stroke="#172128" stroke-width="2" />
<path d="M104 140C121 86 137 59 184 53C242 45 323 49 400 50C477 49 558 45 616 53C663 59 679 86 696 140" fill="none" stroke="#748086" stroke-width="2" />
<path d="M75 532C113 495 151 434 192 405C203 397 217 388 235 387H565C583 388 597 397 608 405C649 434 687 495 725 532" fill="none" stroke="#141e25" stroke-width="3" />
<path d="M82 527C121 484 155 432 195 408M718 527C679 484 645 432 605 408" fill="none" stroke="#58656d" stroke-width="1.2" />
<g id="xbox-button" transform="translate(400 93)">
<circle cy="1" r="32" fill="#19242b" />
<circle r="28" fill="#eff3f3" stroke="#748087" stroke-width="1.2" />
<path d="M-22-17C-15-22-6-19 0-13C6-19 15-22 22-17C13-16 8-11 5-7C14 4 21 16 22 20C11 8 4 0 0-3C-4 0-11 8-22 20C-21 16-14 4-5-7C-8-11-13-16-22-17Z" fill="#303b43" />
</g>
<use xlink:href="#stick" transform="translate(204 179)" />
<use xlink:href="#stick" transform="translate(501 300)" />
<g id="view-button" transform="translate(340 181)">
<use xlink:href="#small-key" />
<path d="M-9-8H3V2H-9ZM-3-3H9V7H-3Z" fill="none" stroke="#a6b1b7" stroke-width="1.6" />
</g>
<g id="menu-button" transform="translate(460 181)">
<use xlink:href="#small-key" />
<path d="M-8-7H8M-8-1H8M-8 5H8" fill="none" stroke="#a6b1b7" stroke-width="1.8" />
</g>
<g id="share-button" transform="translate(400 226)">
<rect x="-24" y="-15" width="48" height="30" rx="14" fill="#162128" />
<rect x="-22" y="-14" width="44" height="26" rx="12" fill="url(#button)" stroke="#66747b" stroke-width="1" />
<path d="M-10-1V6H10V-1M0 1V-9M-5-4L0-9L5-4" fill="none" stroke="#a6b1b7" stroke-width="1.6" stroke-linejoin="round" />
</g>
<g id="hybrid-dpad" transform="translate(295 306)">
<circle cy="2" r="64" fill="#172128" stroke="#627078" stroke-width="1.5" />
<path d="M-24-55H24L55-24V24L24 55H-24L-55 24V-24Z" fill="#343e45" />
<path d="M-24-55H24L19-19H-19Z" fill="#59656c" />
<path d="M24-55L55-24L19-19Z" fill="#3b464e" />
<path d="M55-24V24L19 19V-19Z" fill="#46525a" />
<path d="M55 24L24 55L19 19Z" fill="#29353e" />
<path d="M24 55H-24L-19 19H19Z" fill="#647077" />
<path d="M-24 55L-55 24L-19 19Z" fill="#414d55" />
<path d="M-55 24V-24L-19-19V19Z" fill="#505d65" />
<path d="M-55-24L-24-55L-19-19Z" fill="#414d55" />
<path d="M-19-19H19V19H-19Z" fill="#36424a" />
<path d="M-23-54H23M-54-23V23M-23 54H23" stroke="#859097" stroke-opacity=".5" stroke-width="1.2" fill="none" />
</g>
<g transform="translate(600 132)">
<use xlink:href="#key" />
<path d="M-9-13L0-1L9-13M0-1V10" fill="none" stroke="#e4ce59" stroke-width="2.6" stroke-linejoin="round" />
</g>
<g transform="translate(548 183)">
<use xlink:href="#key" />
<path d="M-8-13L8 10M8-13L-8 10" fill="none" stroke="#55b4e2" stroke-width="2.6" stroke-linejoin="round" />
</g>
<g transform="translate(652 183)">
<use xlink:href="#key" />
<path d="M-7-13V10H1C12 10 12-2 1-2H-7M-7-13H1C11-13 11-2 1-2" fill="none" stroke="#e16d72" stroke-width="2.6" stroke-linejoin="round" />
</g>
<g transform="translate(600 234)">
<use xlink:href="#key" />
<path d="M-10 10L0-14L10 10M-6 2H6" fill="none" stroke="#8ac66e" stroke-width="2.6" stroke-linejoin="round" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

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

View file

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

View file

@ -35,10 +35,34 @@
</div>
<button class="icon-button" id="refreshButton" type="button" title="Refresh controllers" aria-label="Refresh controllers">↻</button>
</div>
<label class="field-label" for="identitySelect">Profile owner</label>
<select id="identitySelect" class="select"></select>
<p class="field-help">The default applies when a controller has no dedicated layout.</p>
<section class="joycon-mode" aria-labelledby="joyconModeTitle">
<p class="eyebrow">Adapter-wide setting</p>
<h3 id="joyconModeTitle">Joy-Con 2 default mode</h3>
<label class="field-label" for="joyconModeSelect">Default arrangement</label>
<select id="joyconModeSelect" class="select" aria-describedby="joyconModeHelp joyconModeStatus" disabled>
<option value="paired">Paired · L + R together</option>
<option value="individual">Individual · separate players</option>
</select>
<p class="field-help">Saved on the adapter; changing it applies live and clears temporary joins/splits. Not an artwork preview.</p>
<details class="joycon-mode-help">
<summary>Controller shortcut &amp; profile banks</summary>
<div id="joyconModeHelp">
<p><strong>Paired:</strong> L + R share one player and a separate L+R profile bank. Unmatched halves stay separate.</p>
<p><strong>Individual:</strong> each half is a sideways player using its own solo bank. Choose the L or R profile owner to edit it.</p>
<p><strong>Join / split this pair:</strong> hold <strong>ZL + Minus</strong> on the left and <strong>ZR + Plus</strong> on the right for <strong>2 seconds</strong>. A short pulse confirms success. Release all four buttons before using it again.</p>
<p>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.</p>
<p>All saved banks and unsaved profile drafts are kept. Your chosen owner does not change automatically.</p>
<p>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.</p>
</div>
</details>
<button class="button button-secondary" id="applyJoyconModeButton" type="button" disabled>Apply default mode</button>
<p class="joycon-mode-status" id="joyconModeStatus" data-state="loading" role="status" aria-live="polite">Reading saved preference…</p>
</section>
<div class="identity-tools">
<label class="field-label" for="controllerAlias">Controller alias</label>
<input class="number-input" id="controllerAlias" maxlength="31" placeholder="Optional nickname">
@ -110,46 +134,45 @@
<div class="controller-mapper">
<div class="controller-canvas" id="controllerCanvas" data-style="generic">
<div class="controller-caption">
<span class="controller-live"><i></i> Connected layout</span>
<span class="controller-live" id="controllerLayoutStatus">Reference layout</span>
<strong id="controllerModel">Generic controller</strong>
</div>
<div class="controller-photo-wrap">
<img
class="controller-photo"
id="controllerImage"
src="/assets/controller-xbox.svg"
alt="Connected controller"
>
<div class="controller-hotspots" id="controllerHotspots">
<button type="button" data-controller-button="left_shoulder" aria-label="Left Shoulder">L</button>
<button type="button" data-controller-button="right_shoulder" aria-label="Right Shoulder">R</button>
<button type="button" data-controller-button="left_trigger" aria-label="Left Trigger">LT</button>
<button type="button" data-controller-button="right_trigger" aria-label="Right Trigger">RT</button>
<button type="button" data-controller-button="dpad_up" aria-label="D-pad Up">▲</button>
<button type="button" data-controller-button="dpad_right" aria-label="D-pad Right">▶</button>
<button type="button" data-controller-button="dpad_down" aria-label="D-pad Down">▼</button>
<button type="button" data-controller-button="dpad_left" aria-label="D-pad Left">◀</button>
<button type="button" data-controller-button="north" aria-label="North button">N</button>
<button type="button" data-controller-button="west" aria-label="West button">W</button>
<button type="button" data-controller-button="east" aria-label="East button">E</button>
<button type="button" data-controller-button="south" aria-label="South button">S</button>
<button type="button" data-controller-button="left_stick" aria-label="Left Stick">L3</button>
<button type="button" data-controller-button="right_stick" aria-label="Right Stick">R3</button>
<button type="button" data-controller-button="select" aria-label="Select">−</button>
<button type="button" data-controller-button="start" aria-label="Start">+</button>
<button type="button" data-controller-button="capture" aria-label="Capture">▣</button>
<button type="button" data-controller-button="system" aria-label="System">⌂</button>
<div class="layout-picker">
<label class="field-label" for="controllerLayoutPreview">Auto / layout preview</label>
<select class="select" id="controllerLayoutPreview" aria-describedby="controllerLayoutHelp">
<option value="auto">Auto · selected owner / live metadata</option>
<option value="xbox">Preview · Xbox</option>
<option value="dualsense">Preview · DualSense</option>
<option value="switch-pro">Preview · original Switch Pro</option>
<option value="switch2-pro">Preview · Switch 2 Pro</option>
<option value="joycon2-pair">Preview · Joy-Con 2 pair</option>
<option value="joycon2-left">Preview · Joy-Con 2 left solo</option>
<option value="joycon2-right">Preview · Joy-Con 2 right solo</option>
<option value="wii-horizontal">Preview · Wii horizontal / accelerometer</option>
<option value="wii-vertical">Preview · Wii vertical</option>
<option value="wii-nunchuk">Preview · Wii Remote + Nunchuk</option>
</select>
<p class="field-help" id="controllerLayoutHelp">Preview changes only this editor's source labels and diagram, never firmware topology or saved mappings.</p>
</div>
<div class="controller-viewport" tabindex="0" role="region" aria-label="Controller diagram; scroll horizontally on narrow screens">
<div class="controller-photo-wrap" id="controllerPhotoWrap">
<div class="controller-stage" id="controllerStage">
<img class="controller-photo" id="controllerImage" src="/assets/xbox-controller-simple.svg" alt="Generic controller reference">
<div class="controller-hotspots" id="controllerHotspots"></div>
</div>
</div>
</div>
<p class="controller-hint">Select a highlighted control on the controller to remap it.</p>
<p class="controller-credit">
Image:
<a id="controllerCredit" href="https://github.com/AL2009man/Gamepad-Asset-Pack" target="_blank" rel="noreferrer">Controller artwork by Al. Lopez · MIT</a>
</p>
<p class="controller-hint">Select a control on the illustration or below it. On narrow screens, swipe sideways or use the Source control menu.</p>
<p class="field-label" id="extraControlsLabel">Off-art controls · rear, rails or unlocated sources</p>
<div class="extra-controls" id="extraControls" role="group" aria-labelledby="extraControlsLabel"></div>
<p class="field-help" id="controllerLayoutNote"></p>
<p class="controller-credit">Original vector artwork supplied for Switch Pico Controller Studio.</p>
</div>
<aside class="mapping-inspector">
<p class="eyebrow">Selected control</p>
<label class="field-label" for="selectedSource">Source control</label>
<select class="select" id="selectedSource" aria-describedby="selectedControlDescription"></select>
<div class="selected-control-mark" id="selectedControlGlyph">S</div>
<h4 id="selectedControlName">South button</h4>
<p id="selectedControlDescription">Physical South currently produces South.</p>
@ -261,6 +284,11 @@
</div>
</article>
</div>
<div class="mapping-playtest">
<p><strong>Raw extra inputs:</strong> <span id="playtestExtraInputs">None</span></p>
<p><strong>Draft button output:</strong> <span id="playtestMappedButtons">None</span></p>
<p class="field-help" id="playtestMappingHelp">Base and Shift mapping preview only; shortcuts, macros and Turbo are not simulated.</p>
</div>
</div>
<div class="analog-grid" id="analogFields"></div>
</section>
@ -431,6 +459,7 @@
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="/playtest.js" defer></script>
<script src="/assets/controller_layouts.js" defer></script>
<script src="/app.js" defer></script>
</body>
</html>

View file

@ -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 `<option value=""${selected === null ? " selected" : ""}>None</option>` +
available.map(control => `<option value="${escapeHtml(control)}"${selected === control ? " selected" : ""}${excluded.includes(control) && control !== selected ? " disabled" : ""}>${escapeHtml(sourceLabel(control))}${sourceAvailable(control) ? "" : " (stored / unavailable)"}</option>`).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 `<option value=""${selected === null ? " selected" : ""}>None</option>` +
state.schema.controls.map((control) =>
`<option value="${control}"${selected === control ? " selected" : ""}${available.includes(control) && !excluded.includes(control) ? "" : " disabled"}>${escapeHtml(controlLabel(control))}${available.includes(control) ? "" : " (unavailable)"}</option>`
).join("");
return sourceOptions(selected, state.schema.controls, excluded);
}
function renderShortcuts() {
@ -628,16 +832,14 @@ function renderShortcuts() {
<div class="control-card">
<label for="shortcut-modifier">Shortcut modifier</label>
<select class="select" id="shortcut-modifier" data-kind="shortcut-modifier" aria-describedby="shortcut-modifier-help">${modifierOptions(shortcuts.modifier, shortcuts.profiles)}</select>
<p class="field-help" id="shortcut-modifier-help">None clears every shortcut. Analog trigger modifiers require a known Xbox or PlayStation owner; unsupported choices remain unavailable.</p>
<p class="field-help" id="shortcut-modifier-help">None clears every shortcut. Switch 2 extra inputs can act as modifiers. Trigger modifiers are available for known Xbox, PlayStation and Switch 2 owners.</p>
</div>`;
elements.shortcuts.innerHTML = shortcuts.profiles.map((selector, index) => `
<div class="control-card">
<label for="shortcut-${index}">Profile ${index + 1}${state.profileNames[index] ? ` · ${escapeHtml(state.profileNames[index])}` : ""}</label>
<select class="select" id="shortcut-${index}" data-kind="shortcut-selector" data-index="${index}"${shortcuts.modifier === null ? " disabled" : ""}>
<option value=""${selector === null ? " selected" : ""}>None</option>
${state.schema.shortcut_selectors.map((button) =>
`<option value="${button}"${selector === button ? " selected" : ""}${button === shortcuts.modifier || shortcuts.profiles.some((item, other) => other !== index && item === button) ? " disabled" : ""}>${escapeHtml(controlLabel(button))}</option>`
).join("")}
${sourceOptions(selector, state.schema.shortcut_selectors,
[shortcuts.modifier, ...shortcuts.profiles.filter((_, other) => other !== index)])}
</select>
</div>`).join("");
}
@ -654,11 +856,14 @@ function renderShift() {
<select class="select" id="shift-modifier" data-kind="shift-modifier"${shift.mode === "off" ? " disabled" : ""} aria-describedby="shift-modifier-help">${modifierOptions(shift.modifier)}</select>
<p class="field-help" id="shift-modifier-help">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.</p>
</div>`;
elements.shiftMap.innerHTML = state.schema.buttons.map((button) => `
<div class="control-card">
<label for="shift-map-${button}">${escapeHtml(controlLabel(button))} → alternate output</label>
<select class="select" id="shift-map-${button}" data-kind="shift-map" data-name="${button}"${shift.mode === "off" || shift.modifier === null ? " disabled" : ""}>${buttonOptions(shift.button_map[button], true, state.schema.buttons)}</select>
</div>`).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 `
<div class="control-card" data-source-card="${button}"${sourceAvailable(button) ? "" : " hidden"}>
<label for="shift-map-${button}"><span data-source-label="${button}">${escapeHtml(sourceLabel(button))}</span> → alternate output</label>
<select class="select" id="shift-map-${button}" data-kind="shift-map" data-name="${button}"${shift.mode === "off" || shift.modifier === null ? " disabled" : ""}>${buttonOptions(map[button], true, state.schema.buttons)}</select>
</div>`;
}).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 `<div class="control-card turbo-card" data-turbo-button="${button}">
<label for="turbo-${button}">${escapeHtml(controlLabel(button))} · physical source</label>
return `<div class="control-card turbo-card" data-turbo-button="${button}" data-source-card="${button}"${sourceAvailable(button) ? "" : " hidden"}>
<label for="turbo-${button}"><span data-source-label="${button}">${escapeHtml(sourceLabel(button))}</span> · physical source</label>
<select class="select" id="turbo-${button}" data-kind="turbo" data-name="${button}">
${modeOptions(state.schema.turbo_modes, state.profile.turbo[button])}
</select>
<label class="checkbox-pill turbo-override">
<input type="checkbox" data-kind="turbo-override" data-name="${button}"${override ? " checked" : ""}${off ? " disabled" : ""}>
<span>Override shared settings for ${escapeHtml(controlLabel(button))}</span>
<span>Override shared settings for <span data-source-label="${button}">${escapeHtml(sourceLabel(button))}</span></span>
</label>
<div class="turbo-setting-grid">${turboSettingFields(override || settings.defaults, button, off || !override)}</div>
<p class="field-help" data-turbo-timing></p>
@ -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 `
<div class="action-card">
@ -994,11 +1311,11 @@ function actionChordCard(action, title, description, selectedButtons, defaults)
</div>
<div class="check-grid controller-choices">
${state.schema.controls.map((button) => `
<label class="checkbox-pill controller-choice">
<label class="checkbox-pill controller-choice"${sourceAvailable(button) || selected.has(button) ? "" : " hidden"}>
<input type="checkbox" data-kind="action-chord" data-action="${action}" data-name="${button}"${selected.has(button) ? " checked" : ""}>
<span data-button="${button}">
<b>${controlGlyph(button, state.identities[state.identityIndex]?.controller?.style || "generic")}</b>
<small>${controlLabel(button)}</small>
<b>${escapeHtml(sourceGlyph(button))}</b>
<small>${escapeHtml(sourceLabel(button))}${sourceAvailable(button) ? "" : " (stored / unavailable)"}</small>
</span>
</label>`).join("")}
</div>
@ -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() {
<div class="control-card">
<label for="macro-cancel">Macro ${state.selectedMacro + 1} cancel control</label>
<select class="select" id="macro-cancel" data-kind="macro-selector" data-field="cancel">
${buttonOptions(macro.cancel, true, state.schema.controls, controllerStyle)}
${sourceOptions(macro.cancel)}
</select>
<div class="macro-playback-fields">
<div class="number-field">
@ -1688,7 +2005,7 @@ function renderMacro() {
elements.macroPreviewTitle.textContent = `Macro ${state.selectedMacro + 1} draft preview`;
elements.macroPreviewButtons.innerHTML = state.schema.buttons.map((button) => `
<span class="preview-button" data-preview-button="${button}">
<b>${escapeHtml(controlLabel(button, controllerStyle))}</b><small>Passthrough</small>
<b data-output-label="${button}">${escapeHtml(controlLabel(button, controllerStyle))}</b><small>Passthrough</small>
</span>`).join("");
renderMacroSteps();
updateMacroPlayback();
@ -1745,7 +2062,7 @@ function renderMacroSteps() {
${state.schema.buttons.map((button) => `
<label class="checkbox-pill">
<input type="checkbox" data-kind="macro-output" data-index="${index}" data-name="${button}"${outputButtons.has(button) ? " checked" : ""}${overrides.has("buttons") ? "" : " disabled"}>
<span>${controlLabel(button, controllerStyle)}</span>
<span data-output-label="${button}">${escapeHtml(controlLabel(button, controllerStyle))}</span>
</label>`).join("")}
</div>
</div>
@ -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 = "";
});

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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<uint8_t>(AdapterRequestedMode::kXInput),
0,
0,
static_cast<uint8_t>(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<JoyConMode>(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<uint8_t>(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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<char>('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;
}

View file

@ -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<uint8_t>(value);
output[1] = static_cast<uint8_t>(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<uint16_t>(size));
write_u16(record + 14, type == 1 ? (version == 1 ? 5 : 6) : 0);
write_u16(record + 14, type == 1
? static_cast<uint16_t>(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<uint8_t>(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<uint8_t>(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<char>('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;
}

View file

@ -0,0 +1,70 @@
#include <assert.h>
#include <stdint.h>
#include "parser/uni_switch2_haptics.h"
static uint64_t unpack(const uint8_t sample[5]) {
uint64_t result = 0;
for (unsigned byte = 0; byte < 5; ++byte) result |= (uint64_t)sample[byte] << (byte * 8);
return result;
}
int main(void) {
uni_switch2_haptics_frame_t frame;
uni_switch2_haptics_silence(&frame);
assert(uni_switch2_haptics_valid(&frame));
assert(uni_switch2_haptics_is_stop(&frame));
uint64_t sample = unpack(frame.sides[0].samples[0]);
assert((sample & 1023) == 385); // Measured160Hz anchor.
assert(((sample >> 20) & 1023) == 481); // Measured320Hz anchor.
assert(((sample >> 10) & 1023) == 0 && ((sample >> 30) & 1023) == 0);
uni_switch2_haptics_encode_sample(frame.sides[0].samples[0], 96, 96, 32767, 16384);
sample = unpack(frame.sides[0].samples[0]);
assert((sample & 1023) == 481);
assert(((sample >> 20) & 1023) == 577); // Upper frequency bit is frequency, not tone mode.
assert(((sample >> 10) & 1023) == 453 && ((sample >> 30) & 1023) == 226);
assert(!uni_switch2_haptics_is_stop(&frame));
frame.sides[0].count = 3;
uni_switch2_haptics_encode_sample(frame.sides[0].samples[1], 32, 64, 8192, 0);
uni_switch2_haptics_encode_sample(frame.sides[0].samples[2], 64, 127, 0, 32767);
uint8_t block[16];
assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 0xab));
assert(block[0] == 0x7b);
assert((unpack(block + 1) & 1023) == 481);
assert((unpack(block + 6) & 1023) == 289);
assert(((unpack(block + 6) >> 10) & 1023) == 113);
assert(((unpack(block + 11) >> 20) & 1023) == 670);
assert(((unpack(block + 11) >> 10) & 1023) == 0);
assert(((unpack(block + 11) >> 30) & 1023) == 453);
frame.sides[0].count = 2;
assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 15));
assert(block[0] == 0x6f);
for (unsigned i = 11; i < 16; ++i) assert(block[i] == 0);
frame.sides[0].count = 1;
assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 16));
assert(block[0] == 0x50);
for (unsigned i = 6; i < 16; ++i) assert(block[i] == 0);
uni_switch2_haptics_encode_sample(frame.sides[0].samples[0], 0, 255, 65535, 65535);
sample = unpack(frame.sides[0].samples[0]);
assert((sample & 1023) == 196 && ((sample >> 20) & 1023) == 670);
assert(((sample >> 10) & 1023) == 453 && ((sample >> 30) & 1023) == 453);
assert(uni_switch2_haptics_valid(&frame));
// A malformed internal native frame cannot bypass the amplitude envelope.
frame.sides[0].samples[0][2] |= 0x0f;
assert(!uni_switch2_haptics_valid(&frame));
uni_switch2_haptics_silence(&frame);
frame.sides[1].count = 0;
assert(uni_switch2_haptics_valid(&frame));
assert(!uni_switch2_haptics_is_stop(&frame)); // A side-only mute must not clear its partner.
frame.sides[0].count = 0;
assert(!uni_switch2_haptics_valid(&frame));
assert(!uni_switch2_haptics_write_block(block, &frame.sides[0], 0));
frame.sides[0].count = 4;
assert(!uni_switch2_haptics_valid(&frame));
assert(!uni_switch2_haptics_write_block(block, &frame.sides[0], 0));
return 0;
}

View file

@ -0,0 +1,8 @@
#pragma once
#include <stdint.h>
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**);

View file

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

View file

@ -0,0 +1,26 @@
#pragma once
#include <btstack.h>
#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];
};

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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("<hhhhhh", payload, 42, *self.playtest_motion)
return bytes(payload), flags
@ -309,7 +314,8 @@ class FakeDevice:
if request == config_manager.OP_RUNTIME_DIAGNOSTICS:
return make_response(
request,
struct.pack("<7I4B", 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1),
struct.pack("<7I4B", 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1)
+ getattr(self, "runtime_diagnostics_tail", b""),
)
if request == config_manager.OP_NATIVE_SWITCH_RUMBLE:
return make_response(
@ -370,7 +376,7 @@ class FakeDevice:
make_response(
request,
self.profiles[self.selected_profile],
schema=config_manager.PROFILE_SCHEMA_VERSION,
schema=struct.unpack_from("<H", self.profiles[self.selected_profile])[0],
generation=self.profile_generation,
)
)
@ -445,16 +451,19 @@ class FakeDevice:
assert (
zlib.crc32(self.transaction_payload) & 0xFFFFFFFF
) == self.transaction_expected_crc
self.configuration = bytes(self.transaction_payload)
self.configuration_generation += 1
self.transaction_status = config_manager.STATUS_OK
if self.fail_configuration_commit_status is not None:
self.transaction_status = self.fail_configuration_commit_status
else:
self.configuration = bytes(self.transaction_payload)
self.configuration_generation += 1
self.transaction_status = config_manager.STATUS_OK
elif request == config_manager.OP_CONFIGURATION_RESET:
self.transaction_id = struct.unpack("<I", payload)[0]
assert 0 < self.transaction_id <= (config_manager.HOST_TRANSACTION_ID_MASK)
self.configuration = struct.pack(
"<HB5x", 60, config_manager.REQUESTED_MODE_AUTO
)
if self.configuration_schema == config_manager.CONFIGURATION_SCHEMA_VERSION:
if self.configuration_schema >= 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("<HBB4x", 90, config_manager.REQUESTED_MODE_XINPUT, len(identities))
struct.pack(
"<HBBB3x", 90, config_manager.REQUESTED_MODE_XINPUT, len(identities), joycon_mode
)
+ b"".join(identity.to_bytes() for identity in identities)
+ bytes((16 - len(identities)) * 14)
)
@ -729,6 +741,7 @@ def test_legacy_configuration_has_no_native_rumble_approval(
configuration = config_manager.read_configuration(device)
assert configuration.pairing_window_seconds == 75
assert configuration.requested_mode == mode
assert configuration.joycon_mode == config_manager.JOYCON_MODE_PAIRED
assert configuration.native_switch_controllers == ()
with pytest.raises(config_manager.ConfigManagerError):
config_manager.set_native_switch_rumble_approval(
@ -745,7 +758,8 @@ def test_legacy_configuration_has_no_native_rumble_approval(
assert not device.out_requests
def test_native_rumble_configuration_canonical_wire_round_trip() -> 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("<H2x", 75)),
(2, struct.pack("<HB5x", 75, config_manager.REQUESTED_MODE_DINPUT)),
(3, native_rumble_configuration((native_rumble_identity(),))),
),
)
def test_joycon_mode_legacy_read_and_write_refusal(
schema: int, payload: bytes,
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> 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("<II", 0, 0xFFFFFFFF)
current = config_manager.read_runtime_diagnostics(device)
assert current.switch2_ingress_drops == 0
assert current.switch2_output_drops == 0xFFFFFFFF
device.runtime_diagnostics_tail = bytes(4)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_runtime_diagnostics(device)
def test_requested_and_active_mode_response_validation() -> None:
device = FakeDevice()
device.configuration = struct.pack(
@ -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("<H", legacy_wire, 0, 6)
legacy_wire[344:] = bytes(40)
legacy_json = json.loads(profile.to_json())
legacy_json["schema_version"] = 6
del legacy_json["extra_button_map"]
del legacy_json["shift"]["extra_button_map"]
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
device = FakeDevice()
device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire)
assert config_manager.read_profile(device, device.stable_identity, 1) == profile
device.profile_aliases[device.stable_identity.to_bytes()] = "Custom controller"
old_listing = config_manager.parse_profile_list(config_manager.parse_response(
make_response(config_manager.OP_PROFILE_LIST, device._profile_list_payload(), schema=6),
config_manager.OP_PROFILE_LIST,
))
assert old_listing[1] == config_manager.ProfileListEntry(device.stable_identity, 1, "Custom controller")
def test_schema7_extra_controls_keep_output_channels_and_wire_layout() -> 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("<HH", 7, 384)
assert encoded[344:351] == bytes((0, 17, 16, 255, 14, 7, 9))
assert encoded[351:358] == bytes((1, 255, 2, 3, 8, 12, 15))
assert encoded[358:364] == bytes((3, 4, 24, 96, 65, 10))
assert encoded[364:] == bytes(20)
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile
assert len(profile.button_map) == len(profile.shift.button_map) == len(profile.turbo_modes) == 16
@pytest.mark.parametrize("path", [
("button_map", "south"),
("extra_button_map", "c"),
("triggers", "left", "output"),
("shift", "extra_button_map", "c"),
])
def test_extra_controls_cannot_be_output_destinations(path: tuple[str, ...]) -> 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("<H", payload, 0, 6)
payload[344:] = bytes(40)
payload[offset] = value
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_bytes(payload)
@pytest.mark.parametrize("offset,value", [
(344, 18), (351, 16), (358, 128), (362, 128), (363, 128), (364, 1),
])
def test_schema7_rejects_extra_map_and_mask_overflow(offset: int, value: int) -> 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("<H", payload, 76, 1 << 6)
struct.pack_into("<H", payload, 98, 1 << 5)
if version == 4:
payload[75] = 0x21
payload[81] = 17
profile = config_manager.ControllerProfile.from_bytes(payload)
assert profile.button_map[0] == 17
assert profile.weak_rumble_scale == 71
assert profile.strong_rumble_scale == 202
assert profile.confirmation_policy == 1
assert profile.switching_chord == (1 << 6) | ((1 << 16) if version == 4 else 0)
assert profile.motion_toggle_chord == (1 << 5) | ((1 << 17) if version == 4 else 0)
assert profile.macros[0].trigger_mask == 3
assert profile.macros[0].cancel_control == (17 if version == 4 else 2)
assert profile.extra_button_map == (255,) * 7
obj = profile.to_json_object()
obj["schema_version"] = version
obj["size"] = 256
macro = obj.pop("macros")[0]
macro.pop("playback")
macro.pop("repeat_count")
macro["steps"].append(config_manager.MacroStep.end().to_json_object())
obj["macro"] = macro
for key in ("shortcuts", "shift", "turbo_settings", "extra_button_map"):
del obj[key]
assert config_manager.ControllerProfile.from_json_object(obj) == profile
assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()) == profile
@pytest.mark.parametrize(
("path", "value"),
@ -1963,6 +2377,47 @@ def test_profile_playtest_decodes_raw_controller_state() -> 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:

View file

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

View file

@ -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"<svg" in response.read()
for path in (
"/assets/controller-switch-pro.svg",
"/assets/controller-dualsense.svg",
"/assets/controller-xbox.svg",
"/assets/GAMEPAD_ASSET_LICENSE.txt",
"/assets/../profile_editor.html",
"/assets/%2e%2e/profile_editor.html",
"/assets/%2e%2e%2fprofile_editor.html",
"/assets/switch-2-joycon-left.svg/../profile_editor.html",
):
with pytest.raises(urllib.error.HTTPError) as error:
urllib.request.urlopen(f"{base_url}{path}", timeout=2)
assert error.value.code == 404
status, schema = request_json(f"{base_url}/api/schema")
assert status == 200
assert "Profile Studio" in page
assert script_size > 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:

View file

@ -0,0 +1,30 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def test_switch2_haptics_encoding(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("cc") or shutil.which("gcc")
assert compiler is not None, "a host C compiler is required"
executable = tmp_path / "switch2_haptics"
subprocess.run(
[
compiler,
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root / 'bluepad32_config'}",
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
str(root / "tests" / "switch2_haptics_test.c"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

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

View file

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

View file

@ -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<uint8_t>(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<uint8_t>(AdapterRequestedMode::kXInput),
"configuration response did not keep requested mode separate");
static_cast<uint8_t>(AdapterRequestedMode::kXInput) &&
control_payload[kResponseHeaderSize + 4] ==
static_cast<uint8_t>(JoyConMode::kIndividual),
"configuration response did not report the saved USB and player modes");
std::vector<uint8_t> 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<int16_t>(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<Bluepad32ControllerLayout>(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;