fix(native-usb): isolate two-pair transport and hand off read status in IRQ

This commit is contained in:
Joey Yakimowich-Payne 2026-09-17 20:24:32 -06:00
commit 9f8678af96
51 changed files with 8159 additions and 815 deletions

View file

@ -448,7 +448,9 @@ if(SWITCH_PICO_SWITCH2_USB_BRIDGE)
target_compile_definitions(switch-pico PRIVATE
SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES=${SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES})
endif()
if((SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB) AND NOT SWITCH2_BRIDGE_FULL_INPUT)
if((SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB) AND
(NOT SWITCH2_BRIDGE_FULL_INPUT OR
(probe_controller_count GREATER 2 AND NOT SWITCH2_BRIDGE_SECOND_SOURCE_AUTO)))
target_compile_definitions(switch-pico PRIVATE
SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS_BYTES=${SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS_BYTES})
endif()

476
README.md
View file

@ -860,6 +860,482 @@ Receive PID state is selected before accepting OUT traffic.
Transmit payloads are prepared outside the bank lock and published by Core 0;
unavailable IN buffers NAK rather than expose another device's packet.
**Live two-pair GAMEPAD mode (0.92):** `SWITCH2_PROBE_PAIR_COUNT=2` with
`SWITCH2_BRIDGE_INPUT=GAMEPAD` and `SWITCH2_PROBE_NEUTRAL_INPUT=OFF` routes two
independent logical controllers to A-R/A-L and B-R/B-L on hub ports 1–4.
`DUALSENSE` supports the same routing with its existing source-type restriction.
The virtual identities and native pairing banks are the same as the neutral
experiment; controller profiles and Bluetooth pairings are not reset.
By default the first two eligible stable controller identities take the two
available pairs. Each identity is reserved for that boot: reconnect restores
its pair, a missing source leaves only that pair neutral, and a third controller
does not take a reserved pair. A power cycle clears automatic reservations.
For assignments stable across power cycles, configure physical Bluetooth
addresses with `SWITCH2_BRIDGE_SOURCE_ADDRESS` (A) and
`SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS` (B). Empty fields select automatic mode.
Explicit selections take precedence, and one logical controller can never drive
both pairs. Conflicting paired Joy-Con halves fail closed rather than duplicate
input. Automatic mode waits for BLE identity resolution. One-pair automatic
builds retain their previous uniquely-eligible-controller rule.
Each pair has its own profile evaluation, Shift/macros, stick routing, motion
integrator, freshness and feedback state. Each half retains its own calibrated
report, counter and delivery token. Disconnecting or remapping one source does
not reset the other, including when physical slot indices are reused. The
existing IMU target mask is side-local and repeats for each pair. Physical
Bluetooth capacity remains four devices: a physical Joy-Con pair uses two links.
With the four private capture sets described below prepared, build separately:
```sh
cmake -S . -B build-switch2-native-two-pair-live \
-C build-switch2-native-two-pair/private-inputs/inputs.cmake \
-DPICO_BOARD=pico2_w -DCMAKE_BUILD_TYPE=Release \
-DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32 -DSWITCH_PICO_BLUETOOTH_MODE=MIXED \
-DSWITCH_PICO_SWITCH2_USB_BRIDGE=ON -DSWITCH2_BRIDGE_INPUT=GAMEPAD \
-DSWITCH2_PROBE_HUB=ON -DSWITCH2_PROBE_PAIR_COUNT=2 \
-DSWITCH2_PROBE_NEUTRAL_INPUT=OFF -DSWITCH2_PROBE_ACK_SETUP04=ON \
-DSWITCH2_PROBE_USB_INIT=ON -DSWITCH2_PROBE_TRACE_NATIVE_INPUT=ON \
-DSWITCH_PICO_HD_RUMBLE=OFF -DSWITCH_PICO_HAPTICS_EXPERIMENT=OFF \
-DSWITCH_PICO_CYW43_PACKET_READ=OFF -DSWITCH_PICO_HCI_CREDIT_BATCH=OFF \
-DSWITCH_PICO_HCI_CREDIT_BUFFER=OFF
cmake --build build-switch2-native-two-pair-live --parallel 4
```
The output is `build-switch2-native-two-pair-live/switch-pico.uf2` (plus ELF).
Full root management and explicit software BOOTSEL remain available. On the
Switch, use real controls mapped to L+R to register each paired layout in
Change Grip/Order; a solo layout instead uses its mapped SL+SR. Neutral reports
cannot complete this player-assignment step. Do not substitute synthesized
presses or mirrored controller input as qualification.
For a PC live-input check, connect both physical sources and deliberately press
buttons and move sticks differently on both throughout the run:
```sh
uv run python tools/native_joycon_hub_check.py \
--build-dir build-switch2-native-two-pair-live --pairs 2 --input-only \
--output build-switch2-native-two-pair-live/live-input-qualification.json \
--timeout 120 --duration 10
```
Omit `--input-only` only when both sources provide fresh IMU and deliberately
move them differently. Qualification requires real activity on all four native
halves and distinct exercised pair evidence; unassigned/neutral pairs cannot
pass. Shared R/L motion is allowed within each full-gamepad pair, not treated as
proof of physical isolation. These checks do not prove Switch gameplay or
physical latency. Host regressions cover two independent L+R inputs, per-pair
profiles/motion/feedback, reconnect and recycled-slot isolation, and compatibility
with one-pair mode.
**0.92 live Switch registration:** the user confirmed both pairs connected on
Change Grip/Order after using a paired DualSense profile (L1+R1) and the Wii's
existing mapped L+R combination (Nunchuk C + Remote 2). UART showed all four
children initialized and active, matching player LED masks within each pair
(3 for A-R/A-L, 1 for B-R/B-L), and continuing native input reports. This confirms
two-pair registration, not independent gameplay or extended stability. The
earlier PC descriptor stress check hit an intermittent read error; successful
button captures and Switch registration do not resolve that separate issue.
The private evidence is `build-switch2-native-two-pair-live/switch-0.92-live-summary.json`.
**0.93 IRQ-safe logging candidate:** the 0.92 capture later stopped all four
USB input streams near 284.34 seconds after boot while Bluetooth callbacks and
USB SOF continued. A root endpoint `0x8f` halt-clear was the final logged control
request. The latched EP0 sequence-error flag had already appeared near startup,
so neither that flag nor the final halt-clear proves the cause by itself.
The native logger did disable IRQs while copying complete diagnostic messages;
the trace measured a 13 microsecond masked interval. USB completion service must
run before the observer can select the next device, so this creates a concrete
missed-token mechanism. Native-hub log producers and the UART consumer are all
Core 0 foreground code; the USB IRQ and Core 1 never access their ring. Version
0.93 enforces that ownership and removes logger-owned IRQ masking, while keeping
message order, whole-message overflow behavior and packet diagnostics. Ring
copies use at most two contiguous spans. Caller-owned critical sections are not
unmasked. Non-hub builds retain their existing synchronization.
The real-logger host regression fails before the fix when a completion arrives
during the copy and the next device token cannot proceed, then passes after it.
It also covers wraparound, overflow, caller IRQ-state preservation and rejection
of ISR/Core 1 producers. Linked native logging contains no IRQ-mask writes, and
the selector, observer and USB IRQ remain in SRAM. The obsolete logger-owned
mask-duration fields were removed from new flight dumps; execution-phase and USB
error/progress diagnostics remain. This fixes the reproduced logging defect,
not a proven end-to-end explanation of the four-minute stall. A user-paced
longer PC/Switch run is still required before calling transport stability fixed;
there is no periodic reset or automatic retry workaround.
**0.94 trace-coverage candidate:** PC checks on 0.93 reproduced a configuration
descriptor read failure on both B-L and B-R. Detailed libusb logging captured
`-EPROTO` (`-71`) with zero host-reported response bytes on B-R after 58 ms,
before the 500 ms request timeout. This is not evidence of a particular failed
wire phase or proof that the PC fault caused the earlier Switch stall.
The recorder had a separate coverage defect: normal host idle after input
froze its live ring throughout a multi-second UART dump, and root management
polling repeatedly rearmed that idle capture. Version 0.94 copies the last 64
published records into an immutable snapshot, excluding the producer's possible
in-flight slot, and immediately resumes recording. IRQs remain enabled during
the copy. Two bounded snapshots retain the current dump and one pending capture;
`HUB_FLIGHT_END lost=` reports cumulative snapshot-queue overflow, including
drops after the final snapshot was enqueued. A full logger returns an admission
failure; snapshot output retries the same line rather than silently skipping it.
An idle episode captures once until actual input completion resumes. Pending
controls capture once per unchanged generation/stage/position. A new child
SETUP also snapshots an incomplete prior request before replacing its state.
`HUB_FLIGHT_FREEZE reason=` distinguishes idle (0), pending control (1) and
superseded child control (2); the historical tag now describes the brief copy
freeze, not a recording pause throughout UART output. `HUB_FLIGHT_CONTROL_CLOCK`
retains the SETUP/completion event-queue cycles and the first IN publication
attempt's cycle, PID and length. Flags distinguish absent events from valid zero
cycle counts. Publication can target a software shadow awaiting bank restoration;
these clocks do not measure physical endpoint readiness or on-wire acceptance.
Post-selection observations add child SETUP and the first observed IN after it
without changing the selector's bank/IRQ guards. Endpoint bits are not decoded
at that decision point, and the early/full address observations can describe
the same token: these are not packet counts or proof of an EP0 ACK. Host
regressions cover ongoing recording during dumps, immutable FIFO snapshots,
overflow/backpressure, one-shot triggers and superseded-request evidence. This
candidate improves diagnosis; it does **not** claim to fix the USB protocol
failure. Flashing and the next hardware capture remain separately readiness-gated.
Additional tracing can change observer timing; SRAM placement and host harness
results are not substitutes for on-hardware qualification.
**0.95 targeted-retention candidate:** the initialized-stream 0.94 run passed
38 configuration reads, then failed a single-packet, 64-byte B-R identity read
with `-EPROTO` and zero host-reported bytes. Its retained snapshots contained
186 routine root records out of 192 total and reported 16 dropped snapshots;
none described that failed identity request. The fault is therefore not limited
to configuration descriptors or multi-packet replies, but its cause is still
unproven.
The traced selection wrapper now retains successful address/owner handovers,
not repeated successful same-owner polls. Rejected selections remain recorded.
Child post-selection observations retain SETUP and the first IN and OUT after
it; they still do not decode endpoint bits or prove physical acceptance. This
reduces routine polling noise without changing the transport's selection guards.
`tools/native_joycon_hub_check.py --capture-trace-on-error` is an explicit,
default-off diagnostic option for trace-enabled native hub builds starting with
0.95. On the first child EP0 transfer error, it sends one root vendor IN to latch
the child's actual current control state **before interface cleanup**. It does
not retry the failed request, initialize streams, change profiles or pairings,
write flash, or reset USB. The marker observes the remaining scenario deadline;
a failed/refused/malformed marker preserves the original transfer error.
It cannot be combined with `--reboot-bootsel`.
The root-only marker is `C0/5e`, value `5452`, index = hub child port (1–2 or
1–4, not a profile identity index), length 16. Its reply contains `NHTR`, version
1, status (0 captured, 1 busy), echoed port, reserved zero, then little-endian
32-bit snapshot time and control generation. Busy replies zero both values;
zero time/generation can also be valid when status is captured. The receipt
matches `HUB_FLIGHT_FREEZE reason=3` and its control header; it confirms snapshot
admission, not completed UART delivery or that the failed SETUP reached the SIE.
The JSON `failure_trace` keeps the host's failed request separately from the
device's captured state, which may describe an earlier request.
The two-snapshot bound is unchanged. A host marker can replace only a waiting
automatic snapshot when full, counting that displacement in `lost=`. It never
rewrites the current dump or a waiting host snapshot; available space can hold
two independently protected host snapshots. Routine automatic triggers cannot
evict either. The existing logger-backpressure behavior remains, so capture
consumers must wait for the matching dump's END before treating it as complete.
Host tests reproduce the old root-poll eviction and verify retained child
evidence, priority admission, receipt matching and original-error preservation.
No on-hardware timing or USB fault fix is claimed by these diagnostics.
**0.96 synchronous EP0 handover candidate:** the 0.95 host failure marker
retained the actual A-R version request: SETUP was processed and a 16-byte
DATA1 reply was prepared, but no first IN completion was recorded. A host-only
reproduction found that alternating root/child polls could repeatedly clear
EP0 availability before the next foreground restoration: none of 200 polls
found a ready reply, even with foreground processing after every poll.
Core 1 now copies the selected device's prepared EP0 IN image into shared DPRAM
and publishes its availability before the selector returns. The address is
committed ahead of that payload copy to preserve the narrow address-routing
path; EP0 IN remains unavailable until the copy finishes. The copy uses aligned
four-word groups and a short tail, bounded by the 64-byte endpoint packet size.
The same-owner fast path and lock, pending-SETUP, buffer-completion and expired
cutoff guards remain. The deferred restoration flag/function and the fixture's
hidden pre-token foreground restoration have been removed.
The USB reset IRQ now revokes all software buffer readiness and the separate
root interrupt buffer before foreground reset processing. This prevents an
inactive bank from republishing pre-reset data during that interval; protocol
reset callbacks and persistent settings remain owned by their existing paths.
Regressions cover that boundary, alternating root/child and child/child replies,
short/full packets, padding ZLPs, status handovers, private endpoint completions,
and interleaved profile readback with its full contents and CRC. The corrected
host reproduction finds a prepared reply ready on all 200 alternating selections;
a separate smoke check delivers exact replies for every length from 0 to 64.
This fixes the reproduced scheduling-dependent liveness defect, not a proven
complete explanation of the hardware `EPROTO` or Switch long-run stall. Linked
selector/IRQ code stays in SRAM without a Core 1 memcpy call or new IRQ masking.
The selector's post-call trace clock and slow-switch count now include synchronous
EP0 preparation; neither is an exact address-write timestamp. Physical address
and SIE-response timing still require readiness-gated hardware qualification.
The existing opt-in host failure marker remains available; there is no automatic
retry/reset workaround or persistent storage-layout change.
**0.97 early address-commit candidate:** the 0.96 marked failure recorded a full
18-byte A-L IN completion with software STATUS_OUT still pending. OUT handovers
entered with 75–79 timer ticks before the router cutoff, while the linked child
path had 119 instructions before the address store. That is a timing concern,
not a measured address-write timestamp or proof of the wire-level failure.
On an owner change, the 0.97 selector cleared hardware buffer controls 0–5,
disabled the old root interrupt endpoint and cleared its stall-arm state before
publishing the new address and owner. Incoming-bank calculation and installation
followed the address write. No old-owner ready buffer was exposed at that commit
point in the host model. The existing lock, pending-SETUP, buffer-status and
expired-cutoff guards remain; rejected selections leave the old bank untouched.
Same-owner polls and address-only updates preserve their established behavior.
Diagnostic hit counting is kept off the successful address-critical path.
Incoming metadata is still written without AVAIL, settled, and published with
the correct endpoint/stall state. Prepared EP0 data is copied synchronously as
in 0.96; the foreground restoration dependency is not reintroduced. The linked
successful owner-change path reaches the address store in 48 instructions,
versus 119 for a child and 104 for root in the compared 0.96 paths. These counts
exclude the wrapper and are not hardware cycle or SIE-response guarantees.
`HUB_FLIGHT commit=` records the most recent Core 1 selector address-write cycle
after the register/owner stores. It is fresh for a recorded wrapper handover;
a same-owner post-selection observation may refer to an earlier write. Failed
selections report zero. This separates address commitment from the existing
post-return clock, but does not prove when the SIE recognized the new address.
`HUB_FLIGHT_STATUS_OUT` adds control/device generation, IN/OUT shadow words,
STATUS_OUT publication-attempt and completion cycles, flags and completion
length. Flags distinguish a successful publication attempt from merely assigning
the STATUS_OUT software stage. Completion evidence follows the existing event
generation/reset checks. Shadow/generation values are individual observations,
not an atomic multiword snapshot. Resetting a control also clears its live
diagnostic watch, while already captured snapshots remain immutable.
Host tests cover early-commit visibility, rejected/same-owner/address-only
selections, status publication and completion, generation invalidation, and
post-reset evidence lifetime. Physical SIE ownership and address/response timing
remain unqualified until the readiness-gated hardware run; software zeroing is
not itself proof that a physical controller transaction was quiescent. No
automatic retry/reset workaround or persistent storage-layout change is added.
**0.98 coherent-bank publication candidate:** the 0.97 hardware run failed during
stream initialization, before descriptor rounds. All four initialization OUTs
completed at the host, but only A-R and A-L reached firmware callbacks; B-L's
first bulk reply timed out. A fresh EP2 receive-sequence error (`0x20`) appeared
during that episode. This implicates receive sequencing/ownership, but does not
identify which transaction or internal SIE event caused the failure.
The selector now installs the incoming PID/length/SEL metadata with AVAIL clear,
endpoint buffer pointers, root EP15 control and stall-arm state **before** the
address/owner write. The metadata-to-AVAIL settling interval remains. Private
buffers are then published; shared EP0 IN data is still copied synchronously
before its AVAIL publication. Lock/completion/SETUP/cutoff guards and the existing
completion-driven PID advancement are unchanged. There is no retry, sequence-error
clearing workaround or persistent-storage change.
The pre-selection trace wrapper is removed. The router calls the selector
directly for every token, then the success/failure posthook. Successful handovers
and child SETUP/first IN/OUT remain observable; repeated same-owner polls are
omitted. Observation bookkeeping advances even while snapshots freeze recording.
New records use `pre=0`: before-clock/address/owner fields are unavailable.
`commit=` remains the last selector address-write clock; a same-owner observation
can still name an earlier commit, and failed selections report zero.
Host regressions latch metadata at the commit-clock access rather than inspecting
only the repaired return-time bank. The 0.97 selector fails this check; 0.98 passes
with two and four children, independent DATA0/DATA1 EP2 transfers, distinct
payloads and exactly-once callbacks. The linked selector reaches the address
store in 86 instructions for a child and 78 for root, versus 0.96's 119/104 and
0.97's incoherent 48-instruction path. Counts exclude caller/wrapper work and
are not hardware cycles or proof of meeting the token deadline. Removed trace
overhead changes that comparison; timing still requires hardware qualification.
The register model does not reproduce physical SIE latching or bad-PID ACKs.
The authorized 0.98 trial passed all 16 initialization exchanges, then failed
A-R's version read with host `EPROTO` and zero transferred bytes. Its matched
marker retained a prepared 16-byte DATA1 reply without a first IN completion;
EP0 sequence error was set and EP2 sequence error was clear at capture. The
bounded trace had no A-R commit after the publication-attempt timestamp, so it
does not establish what happened on the failing IN. No retry/reset followed.
**0.99 publication-observation candidate (diagnostics only):** an IN can arrive
before Core0 prepares its reply, consuming the recorder's first-IN flag. Later
same-owner IN tokens were then omitted even after reply publication. The host
reproduction fails with the 0.98 observer and passes with 0.99; this fixes that
observation gap, **not a proven physical EP0 transport defect**.
Core0 now releases a per-child publication ticket after a successful first EP0
IN arm, outside the bank lock and IRQ-masked region. Core1 retains the first
observed device IN following a new notification, even without a handover.
`HUB_FLIGHT` uses successful `why=20` and `pub=` for this observation;
`HUB_FLIGHT_CONTROL_CLOCK pub=` associates the ticket with the watched control.
Match child slot, ticket and a valid arm flag; inspect control/device generations
for supersession. Tickets survive reset while the per-control watch clears.
Ticket zero is valid after wrap when the observation/arm flags validate it.
Frozen recording still consumes observed notifications, preventing replay after
thaw. Rejected selections and OUT/SETUP observations do not consume them.
Each retained record also includes a non-destructive `rxerr=` observation.
These fields are sequential software observations: the decoder does not identify
the IN endpoint, and a notification can outlive the control that published it.
They prove neither current readiness nor SIE/host acceptance. The existing
16-byte `NHTR` marker response remains version 1 and unchanged.
The linked selector's normalized instructions match 0.98; bank publication,
guards and PID advancement are unchanged. Postselection tracing costs more,
so this is not a physical timing guarantee. The authorized 0.99 run passed all
16 initialization exchanges and three descriptor rounds, then failed B-R's
one-byte version read with host `EPROTO`. Ticket `0x26` matched the failed
control and a one-byte firmware IN completion. STATUS_OUT remained pending:
an OUT observation preceded its publication attempt by about 115 microseconds.
EP0 sequence error was already set before the failed request, not a fresh
transition attributable to it.
**0.100 final-IN status handoff candidate:** pre-approved control reads now arm
zero-length DATA1 STATUS_OUT in the USB IRQ after the final IN completes,
without waiting for the foreground DATA callback. Status is not armed before
the final IN, before a required terminating IN ZLP, for an unexpected completed
length, or over a pending replacement SETUP. The existing settled buffer
publication path is reused; no protocol callback or payload copy runs in IRQ.
`native_hub_control_xfer` takes an explicit `read_status_preapproved` argument.
Opt in only for an IN reply validated during SETUP whose DATA callback cannot
reject status. Standard/class replies and the existing native identity/version
and management reads use the fast handoff; callback-validated reads remain
gated. OUT/write transfers, including BOOTSEL and settings/profile writes,
must pass false and retain their validation-before-status behavior.
The completion event carries the IRQ handoff, so foreground processing never
rearms status that hardware may already have consumed. DATA then ACK callbacks
remain foreground-only and exactly once for completed reads. A replacement
SETUP preserves already queued final-IN/status completion ordering; reset
invalidates it. Foreground DATA ownership is rechecked and claimed with IRQs
masked, then callbacks run unmasked, matching the established ACK claim rule.
Status publication trace evidence now carries the IRQ publication-attempt
timestamp for eligible reads. The IN completion timestamp is the subsequent
event-enqueue observation, not the physical bus completion instant; the status
arm timestamp can therefore precede it. Snapshot/control generation validation
and the existing publication-ticket provenance still apply.
The host reproduction rejects immediate status with 0.99 and accepts it with
0.100 for the root and every child, without a foreground pass. Regressions cover
short/full/multi-packet replies, terminating ZLPs, SETUP/reset invalidation,
malformed completion lengths, rejected DATA callbacks and duplicate prevention.
The linked selector's normalized instructions match 0.99; the expanded IRQ
contains no external calls. This removes a reproduced foreground readiness gap,
but neither host models nor the observed delay prove the cause or resolution of
physical `EPROTO`. The authorized 0.100 deployment preserved the persistent
region byte-for-byte. Its single hardware capture passed all 16 initialization
exchanges and 20 descriptor/isolation rounds (415 control requests), including
one-, seven- and fifteen-byte version reads on all four children. No host error,
retry or reset occurred; sustained traffic and gameplay remain unqualified.
Gameplay rumble is not implemented in this native output path: HID output
reports are logged, while built-in vibration samples use a separate cue path.
**Neutral two-pair transport experiment (0.91):** the standalone probe can expose
four native children, ordered **A-R, A-L, B-R, B-L** on hub ports 1–4. This is an
explicit USB transport experiment, not multi-source GAMEPAD mode. Bluetooth,
live motion, motor cues and BOOTSEL test-button injection are disabled. Reports
remain neutral at the captured stick centers; native USB initialization and
independent pairing persistence still work. This mode is useful for transport
isolation, but cannot register players in Change Grip/Order without real buttons.
Use live GAMEPAD mode for that step.
The private CMake input file must provide `IDENTITY_FILE`, `VERSION_FILE`,
`CONTROLLER_ADDRESS`, `FACTORY_FILE` and `USER_CALIBRATION_FILE` under each of
the `SWITCH2_PROBE`, `SWITCH2_PROBE_SECOND`, `SWITCH2_PROBE_THIRD` and
`SWITCH2_PROBE_FOURTH` prefixes. Advertised addresses and factory identities must
be distinct; each factory image must agree with its identity response. Keep these
private files out of commits. Pair A can retain its existing identities; a new
virtual pair must not reuse Pair A's identity/address.
With SDK/toolchain discovery configured and that private input file prepared,
build without flashing or publishing:
```sh
cmake -S tools/switch2_usb_probe -B build-switch2-native-two-pair \
-C build-switch2-native-two-pair/private-inputs/inputs.cmake \
-DPICO_BOARD=pico2_w -DCMAKE_BUILD_TYPE=Release \
-DSWITCH2_PROBE_HUB=ON -DSWITCH2_PROBE_PAIR_COUNT=2 \
-DSWITCH2_PROBE_NEUTRAL_INPUT=ON -DSWITCH2_PROBE_ACK_SETUP04=ON \
-DSWITCH2_PROBE_USB_INIT=ON -DSWITCH2_PROBE_TRACE_NATIVE_INPUT=ON
cmake --build build-switch2-native-two-pair --parallel 4
```
The outputs are `build-switch2-native-two-pair/switch2-usb-probe.elf` and `.uf2`.
The standalone image uses the proven 240 MHz/1.3 V clock initialization,
flash divider 4 with embedded XIP setup, a 16 KiB Core 0 stack and 4 KiB Core 1
stack. It exposes the existing private software BOOTSEL request, but no full
configuration/profile management interface. The request is validated at DATA,
accepted only after its USB status ACK, then delayed 50 ms before entering ROM.
Malformed, incomplete and superseded requests cannot schedule a reboot.
Software recovery is an explicit operation, separate from qualification, and
works even when none of the children enumerate:
```sh
uv run python tools/native_joycon_hub_check.py --reboot-bootsel \
--output build-switch2-native-two-pair/bootsel-recovery.json --timeout 30
```
It selects the uniquely identified Switch Pico root, sends the standard private
request and confirms ROM BOOTSEL re-enumeration on the same physical port. It
does not require build captures, claim interfaces, initialize controllers, write
pairings, or actuate motors. Recovery success is not a qualification result, and
a failed qualification never triggers recovery automatically. Physical BOOTSEL
remains the fallback if the USB root itself is unresponsive. The normal
configuration CLI still requires the adapter's full management interface.
Original right/left pairing banks retain their offsets. Pair B adds two banks
immediately below them, increasing the total reservation from 16 to 32 KiB;
profiles, adapter settings and Bluetooth storage do not move. Unknown sector
ownership is refused rather than erased. Before an authorized hardware trial,
record/export profiles and settings and take a complete flash backup in BOOTSEL,
including the new reservation. Restore the normal image after the experiment.
After explicitly flashing the experiment, the non-pairing PC transport check is:
```sh
uv run python tools/native_joycon_hub_check.py \
--build-dir build-switch2-native-two-pair --pairs 2 --neutral \
--output build-switch2-native-two-pair/neutral-qualification.json \
--timeout 120 --duration 10
```
This checks all four identities, port ancestry, native descriptors, calibrated
neutral reports, advancing counters and interleaved control/bulk isolation. It
rejects motor requests and cannot qualify live input, IMU, Bluetooth routing or
gameplay. Same-side neutral HID reports with identical calibration centers cannot
by themselves prove source isolation. Default one-pair live checks and
`--input-only` remain separate. Host regressions cover four-child address/endpoint
and reset isolation, interrupted pairing writes, unknown-bank refusal and the
new lower storage boundary. Those tests and SRAM placement checks do **not**
qualify four-child USB timing or Switch enumeration; both require hardware tests.
**0.91 PC hardware trial:** all four children enumerated on ports 1–4 and passed
the short neutral transport check: A-R 446, A-L 446, B-R 440 and B-L 445 valid
reports with advancing counters, 35 interleaved control/bulk rounds and no
checker errors. Software BOOTSEL from the neutral firmware acknowledged the
private request and re-enumerated in ROM on the same physical port. The exact
pre-trial 0.89 program was restored; a verified full-flash read matched all
4,194,304 bytes of the pre-trial backup. The firmware's offline storage decoder
also recovered identical contents, names, aliases and selections for all 80
profiles across ten owners. This is not a maximum-rate, long-run, Switch or
gameplay qualification. Private captures and restoration evidence are in
`build-switch2-native-two-pair/verification.json` and its referenced files.
Avoid concurrent Controller Studio/CLI clients during multi-request profile
exports: the selected-profile device state is shared between USB requests. This
trial's concurrent CLI exports were not used as preservation proof; the raw
flash comparison and offline-decoded exports are authoritative.
**Qualification history:** the earlier RAM-only
probe established three-address EP0 routing, not Joy-Con output. The
`0.65-native-hub-ready` bridge subsequently passed interleaved native descriptor,

View file

@ -485,12 +485,19 @@ void retire_wii_slot(uint8_t slot_index) {
#endif
#if SWITCH2_BRIDGE_FULL_INPUT
bool g_native_explicit_address = false;
uint8_t g_native_address[6]{};
uint8_t g_native_slot = 0xff;
uint32_t g_native_generation = 0;
Bluepad32NativeGamepadSnapshot g_native_snapshot{};
NativeGamepadCue g_native_cues[2]{};
struct NativeGamepadBinding {
bool explicit_address = false;
uint8_t address[6]{};
// A reservation survives disconnect and retains both known pair members.
// Never use a physical index or an unresolved BLE address as this key.
ControllerIdentity reservation{};
uint8_t slot = 0xff;
uint32_t generation = 0;
Bluepad32NativeGamepadSnapshot snapshot{};
};
constexpr uint8_t kNativeChildCount = BLUEPAD32_NATIVE_PAIR_COUNT * 2;
NativeGamepadBinding g_native_bindings[BLUEPAD32_NATIVE_PAIR_COUNT]{};
NativeGamepadCue g_native_cues[kNativeChildCount]{};
uint64_t g_next_native_token = 1;
uni_hid_device_t* g_native_pending_devices[kSlotCount]{};
NativeGamepadReportIngress g_native_reports[kSlotCount]{};
@ -505,15 +512,49 @@ bool native_device_allowed(const uni_hid_device_t* device) {
#endif
}
bool native_address_matches(const uni_hid_device_t* device) {
return device != nullptr && memcmp(device->conn.btaddr, g_native_address, 6) == 0;
bool native_identity_overlaps(const ControllerIdentity& first,
const ControllerIdentity& second) {
if (!first.stable || !second.stable) return false;
if (controller_identity_equal(first, second)) return true;
ControllerIdentity first_members[2];
ControllerIdentity second_members[2];
const bool first_pair = controller_identity_joycon_pair_members(
first, &first_members[0], &first_members[1]);
const bool second_pair = controller_identity_joycon_pair_members(
second, &second_members[0], &second_members[1]);
for (uint8_t a = 0; a < (first_pair ? 2 : 1); ++a)
for (uint8_t b = 0; b < (second_pair ? 2 : 1); ++b)
if (controller_identity_equal(first_pair ? first_members[a] : first,
second_pair ? second_members[b] : second))
return true;
return false;
}
bool native_address_matches(const BackendSlot& slot, const uint8_t address[6]) {
return (slot.device != nullptr && memcmp(slot.device->conn.btaddr, address, 6) == 0) ||
(slot.companion != nullptr && memcmp(slot.companion->conn.btaddr, address, 6) == 0) ||
(slot.identity.stable &&
(memcmp(slot.identity.address, address, 6) == 0 ||
(controller_identity_is_joycon_pair(slot.identity) &&
memcmp(slot.identity.partner_address, address, 6) == 0)));
}
bool eligible_native_gamepad(const BackendSlot& slot) {
return slot.active && native_device_allowed(slot.device) &&
(slot.companion == nullptr || native_device_allowed(slot.companion)) &&
(!g_native_explicit_address || native_address_matches(slot.device) ||
native_address_matches(slot.companion));
(slot.companion == nullptr || native_device_allowed(slot.companion));
}
uint8_t native_pair_for_slot(uint8_t slot) {
for (uint8_t pair = 0; pair < BLUEPAD32_NATIVE_PAIR_COUNT; ++pair)
if (g_native_bindings[pair].slot == slot) return pair;
return 0xff;
}
uint8_t native_unique_slot(uint8_t mask) {
if (mask == 0 || (mask & (mask - 1u)) != 0) return 0xff;
for (uint8_t slot = 0; slot < kSlotCount; ++slot)
if ((mask & (1u << slot)) != 0) return slot;
return 0xff;
}
uni_hid_device_t* native_rumble_target(const BackendSlot& slot, uint8_t side) {
@ -533,38 +574,108 @@ void cancel_native_cue_locked(NativeGamepadCue& cue) {
// The slot's last motor output remains owned until the timer replaces it.
}
void refresh_native_source_locked(bool reselection = false) {
uint8_t selected = 0xff;
for (uint8_t index = 0; index < kSlotCount; ++index) {
if (!eligible_native_gamepad(g_slots[index])) continue;
if (selected != 0xff) {
selected = 0xff; // Never blend or choose by connection order.
void refresh_native_source_locked(uint8_t reselected_pair = 0xff) {
uint8_t candidates[BLUEPAD32_NATIVE_PAIR_COUNT]{};
uint8_t reserved = 0;
uint8_t explicit_reserved = 0;
for (uint8_t pair = 0; pair < BLUEPAD32_NATIVE_PAIR_COUNT; ++pair) {
const NativeGamepadBinding& binding = g_native_bindings[pair];
for (uint8_t index = 0; index < kSlotCount; ++index) {
const BackendSlot& slot = g_slots[index];
if (!eligible_native_gamepad(slot)) continue;
const uint8_t bit = static_cast<uint8_t>(1u << index);
const bool overlaps = BLUEPAD32_NATIVE_PAIR_COUNT > 1 &&
native_identity_overlaps(binding.reservation, slot.identity);
if (overlaps) {
reserved |= bit;
if (binding.explicit_address) explicit_reserved |= bit;
}
if (binding.explicit_address) {
if (native_address_matches(slot, binding.address)) {
candidates[pair] |= bit;
reserved |= bit;
explicit_reserved |= bit;
}
} else if (BLUEPAD32_NATIVE_PAIR_COUNT == 1) {
candidates[pair] |= bit;
} else if (overlaps &&
!(controller_identity_is_joycon_pair(binding.reservation) &&
controller_identity_is_joycon_pair(slot.identity) &&
!controller_identity_equal(binding.reservation, slot.identity))) {
// A missing half may survive alone. A split is ambiguous; a
// different companion must not silently replace a reserved pair.
candidates[pair] |= bit;
}
}
}
for (uint8_t pair = 0; pair < BLUEPAD32_NATIVE_PAIR_COUNT; ++pair) {
const NativeGamepadBinding& binding = g_native_bindings[pair];
if (binding.explicit_address) continue;
candidates[pair] &= static_cast<uint8_t>(~explicit_reserved);
if (BLUEPAD32_NATIVE_PAIR_COUNT == 1 || binding.reservation.stable) continue;
for (uint8_t index = 0; index < kSlotCount; ++index) {
const BackendSlot& slot = g_slots[index];
const uint8_t bit = static_cast<uint8_t>(1u << index);
if ((reserved & bit) != 0 || !eligible_native_gamepad(slot) ||
!slot.identity.stable) continue;
uint8_t matches = 0;
for (uint8_t other = 0; other < kSlotCount; ++other)
if (eligible_native_gamepad(g_slots[other]) &&
native_identity_overlaps(slot.identity, g_slots[other].identity))
matches |= static_cast<uint8_t>(1u << other);
reserved |= matches;
if (native_unique_slot(matches) != index) continue;
candidates[pair] = bit;
break;
}
selected = index;
}
if (!reselection && selected == g_native_slot &&
(selected == 0xff ||
g_slots[selected].connection_generation == g_native_generation)) return;
for (NativeGamepadCue& cue : g_native_cues) cancel_native_cue_locked(cue);
g_native_snapshot = {};
g_native_slot = selected;
g_native_generation = 0;
if (selected != 0xff) {
BackendSlot& slot = g_slots[selected];
g_macro_capture.disconnect(selected, slot.connection_generation, time_us_32());
// A missed inactive snapshot must still retire the adapter's old epoch.
g_native_generation = ++slot.connection_generation;
uint8_t selected[BLUEPAD32_NATIVE_PAIR_COUNT];
bool changed[BLUEPAD32_NATIVE_PAIR_COUNT];
uint8_t retired_slots = 0;
for (uint8_t pair = 0; pair < BLUEPAD32_NATIVE_PAIR_COUNT; ++pair) {
selected[pair] = native_unique_slot(candidates[pair]);
for (uint8_t other = 0; other < BLUEPAD32_NATIVE_PAIR_COUNT; ++other)
if (other != pair && (candidates[pair] & candidates[other]) != 0)
selected[pair] = 0xff;
NativeGamepadBinding& binding = g_native_bindings[pair];
changed[pair] = pair == reselected_pair || selected[pair] != binding.slot ||
(selected[pair] != 0xff &&
g_slots[selected[pair]].connection_generation != binding.generation);
if (!changed[pair]) continue;
for (uint8_t side = 0; side < 2; ++side)
cancel_native_cue_locked(g_native_cues[pair * 2 + side]);
if (binding.slot != 0xff) retired_slots |= static_cast<uint8_t>(1u << binding.slot);
if (selected[pair] != 0xff) retired_slots |= static_cast<uint8_t>(1u << selected[pair]);
binding.snapshot = {};
}
// Retire all changed owners before activating any binding: swapping two
// explicit selections cannot increment one live pair's epoch underneath it.
for (uint8_t index = 0; index < kSlotCount; ++index) {
if ((retired_slots & (1u << index)) == 0) continue;
BackendSlot& slot = g_slots[index];
g_macro_capture.disconnect(index, slot.connection_generation, time_us_32());
++slot.connection_generation;
++slot.state_generation;
slot.native_motion = {};
}
for (uint8_t pair = 0; pair < BLUEPAD32_NATIVE_PAIR_COUNT; ++pair) {
NativeGamepadBinding& binding = g_native_bindings[pair];
if (changed[pair]) {
binding.slot = selected[pair];
binding.generation = selected[pair] == 0xff
? 0 : g_slots[selected[pair]].connection_generation;
}
if (selected[pair] != 0xff && !controller_identity_is_joycon_pair(binding.reservation))
binding.reservation = g_slots[selected[pair]].identity;
}
}
void retire_native_slot(uint8_t index) {
if (g_native_slot == index) {
g_native_slot = 0xff;
g_native_generation = 0;
g_native_snapshot = {};
for (NativeGamepadBinding& binding : g_native_bindings) {
if (binding.slot != index) continue;
binding.slot = 0xff;
binding.generation = 0;
binding.snapshot = {};
}
for (NativeGamepadCue& cue : g_native_cues)
if (cue.slot == index) cue = {};
@ -572,9 +683,11 @@ void retire_native_slot(uint8_t index) {
g_slots[index].native_output = {};
}
bool native_cue_current(const NativeGamepadCue& cue) {
return cue.slot < kSlotCount && cue.slot == g_native_slot &&
cue.connection_generation == g_native_generation &&
bool native_cue_current(uint8_t pair, const NativeGamepadCue& cue) {
if (pair >= BLUEPAD32_NATIVE_PAIR_COUNT || cue.slot >= kSlotCount) return false;
const NativeGamepadBinding& binding = g_native_bindings[pair];
return cue.slot == binding.slot && cue.connection_generation == binding.generation &&
g_slots[cue.slot].active &&
cue.connection_generation == g_slots[cue.slot].connection_generation;
}
#endif
@ -1268,6 +1381,9 @@ void publish_ble_identity(const BleIdentityMapping& mapping) {
}
}
}
#if SWITCH2_BRIDGE_FULL_INPUT
refresh_native_source_locked();
#endif
state_lock_exit();
if (observe_identity) {
profile_service_observe_identity_on_storage_core(
@ -1315,6 +1431,9 @@ void clear_ble_identity_for_handle(hci_con_handle_t connection_handle) {
slot.identity = controller_identity_global();
}
}
#if SWITCH2_BRIDGE_FULL_INPUT
refresh_native_source_locked();
#endif
state_lock_exit();
}
@ -1504,27 +1623,29 @@ void publish_device_state(uint8_t slot, uni_hid_device_t* device,
}
#endif
#if SWITCH2_BRIDGE_FULL_INPUT
if (slot == g_native_slot && target.native_motion.has_report &&
target.connection_generation == g_native_generation) {
const uint8_t pair = native_pair_for_slot(slot);
if (pair != 0xff && target.native_motion.has_report &&
target.connection_generation == g_native_bindings[pair].generation) {
const NativeGamepadIngress& motion = target.native_motion;
g_native_snapshot.slot = slot;
g_native_snapshot.controller = {
Bluepad32NativeGamepadSnapshot& snapshot = g_native_bindings[pair].snapshot;
snapshot.slot = slot;
snapshot.controller = {
target.active, target.connection_generation, target.identity,
target.pre_hotkey_button_mask, target.state,
target.accelerometer, target.nunchuk_accelerometer};
g_native_snapshot.state_generation = target.state_generation;
g_native_snapshot.received_us = motion.received_us;
g_native_snapshot.battery = device->controller.battery;
g_native_snapshot.track_stationary_bias =
snapshot.state_generation = target.state_generation;
snapshot.received_us = motion.received_us;
snapshot.battery = device->controller.battery;
snapshot.track_stationary_bias =
device->controller_type == CONTROLLER_TYPE_WiiController;
g_native_snapshot.accel_valid = motion.accel_valid;
g_native_snapshot.gyro_valid = motion.gyro_valid;
g_native_snapshot.accel_sequence = motion.accel_sequence;
g_native_snapshot.gyro_sequence = motion.gyro_sequence;
g_native_snapshot.accel_received_us = motion.accel_received_us;
g_native_snapshot.gyro_received_us = motion.gyro_received_us;
memcpy(g_native_snapshot.accel_q13, motion.accel_q13, sizeof(motion.accel_q13));
memcpy(g_native_snapshot.gyro_q10, motion.gyro_q10, sizeof(motion.gyro_q10));
snapshot.accel_valid = motion.accel_valid;
snapshot.gyro_valid = motion.gyro_valid;
snapshot.accel_sequence = motion.accel_sequence;
snapshot.gyro_sequence = motion.gyro_sequence;
snapshot.accel_received_us = motion.accel_received_us;
snapshot.gyro_received_us = motion.gyro_received_us;
memcpy(snapshot.accel_q13, motion.accel_q13, sizeof(motion.accel_q13));
memcpy(snapshot.gyro_q10, motion.gyro_q10, sizeof(motion.gyro_q10));
}
#endif
g_macro_capture.observe(slot, target.connection_generation,
@ -2807,6 +2928,7 @@ struct NativeGamepadCueDispatch {
uint16_t duration_ms = 0;
uint8_t magnitude[2]{};
uint8_t slot = 0xff;
uint8_t pair = 0xff;
};
// Source drivers use a shared finite timer (or one per paired half). Recompute
@ -2817,14 +2939,15 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms,
NativeGamepadCueDispatch* command) {
BackendSlot& slot = g_slots[index];
NativeGamepadMotorOutput& previous = slot.native_output;
const uint8_t pair = native_pair_for_slot(index);
bool busy = false;
bool pending = false;
uint16_t duration = UINT16_MAX;
uint8_t magnitude[2]{};
for (uint8_t side = 0; side < 2; ++side) {
NativeGamepadCue& cue = g_native_cues[side];
for (uint8_t side = 0; pair != 0xff && side < 2; ++side) {
NativeGamepadCue& cue = g_native_cues[pair * 2 + side];
if (cue.slot != index) continue;
if (!native_cue_current(cue) ||
if (!native_cue_current(pair, cue) ||
(cue.result == 0 && now_ms - cue.requested_ms >= kNativeGamepadCueDeadlineMs) ||
(cue.active && now_ms - cue.started_ms >= kNativeGamepadCueDeadlineMs))
cancel_native_cue_locked(cue);
@ -2881,8 +3004,9 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms,
command->magnitude[0] = magnitude[0];
command->magnitude[1] = magnitude[1];
command->slot = index;
command->pair = pair;
for (uint8_t side = 0; side < 2; ++side)
if (command->token[side] != 0) g_native_cues[side].in_flight = true;
if (command->token[side] != 0) g_native_cues[pair * 2 + side].in_flight = true;
return true;
}
@ -2912,11 +3036,10 @@ void dispatch_native_cues(const NativeGamepadCueDispatch& command) {
slot.companion == command.companion &&
slot.connection_generation == command.connection_generation;
for (uint8_t side = 0; side < 2; ++side) {
if (paired && side != target) continue;
const NativeGamepadCue& cue = g_native_cues[side];
if (command.token[side] != 0)
current &= cue.token == command.token[side] && cue.in_flight &&
cue.result != -1 && native_cue_current(cue);
if ((paired && side != target) || command.token[side] == 0) continue;
const NativeGamepadCue& cue = g_native_cues[command.pair * 2 + side];
current &= cue.token == command.token[side] && cue.in_flight &&
cue.result != -1 && native_cue_current(command.pair, cue);
}
state_lock_exit();
// No backend lock crosses a driver call. Recheck every real target:
@ -2957,10 +3080,11 @@ void dispatch_native_cues(const NativeGamepadCueDispatch& command) {
}
state_lock_enter();
for (uint8_t side = 0; side < 2; ++side) {
NativeGamepadCue& cue = g_native_cues[side];
if (command.token[side] == 0 || cue.token != command.token[side]) continue;
if (command.token[side] == 0) continue;
NativeGamepadCue& cue = g_native_cues[command.pair * 2 + side];
if (cue.token != command.token[side]) continue;
cue.in_flight = false;
if (!native_cue_current(cue) ||
if (!native_cue_current(command.pair, cue) ||
(cue.result == 0 && dispatch_ms - cue.requested_ms >= kNativeGamepadCueDeadlineMs)) {
cancel_native_cue_locked(cue);
} else if (submitted[side] && cue.result == 0) {
@ -4700,22 +4824,26 @@ void bluepad32_input_backend_snapshot(uint8_t slot_index,
}
#if SWITCH2_BRIDGE_FULL_INPUT
void bluepad32_input_backend_select_native_source(const uint8_t address[6]) {
if (!g_initialized) return;
void bluepad32_input_backend_select_native_source(
uint8_t pair_index, const uint8_t address[6]) {
if (!g_initialized || pair_index >= BLUEPAD32_NATIVE_PAIR_COUNT) return;
state_lock_enter();
g_native_explicit_address = address != nullptr;
if (address != nullptr) memcpy(g_native_address, address, 6);
else memset(g_native_address, 0, sizeof(g_native_address));
refresh_native_source_locked(true);
NativeGamepadBinding& binding = g_native_bindings[pair_index];
binding.explicit_address = address != nullptr;
if (address != nullptr) memcpy(binding.address, address, 6);
else memset(binding.address, 0, sizeof(binding.address));
binding.reservation = {};
refresh_native_source_locked(pair_index);
state_lock_exit();
}
void bluepad32_input_backend_native_snapshot(Bluepad32NativeGamepadSnapshot* output) {
void bluepad32_input_backend_native_snapshot(
uint8_t pair_index, Bluepad32NativeGamepadSnapshot* output) {
if (output == nullptr) return;
*output = {};
if (!g_initialized) return;
if (!g_initialized || pair_index >= BLUEPAD32_NATIVE_PAIR_COUNT) return;
state_lock_enter();
*output = g_native_snapshot;
*output = g_native_bindings[pair_index].snapshot;
state_lock_exit();
}
@ -4723,18 +4851,19 @@ bool bluepad32_input_backend_native_sample_request(
uint8_t instance, uint8_t sample_id, uint64_t* token) {
if (token == nullptr) return false;
*token = 0;
if (!g_initialized || instance >= 2 || sample_id >= 8) return false;
if (!g_initialized || instance >= kNativeChildCount || sample_id >= 8) return false;
state_lock_enter();
NativeGamepadCue& cue = g_native_cues[instance];
const uint8_t index = g_native_slot;
const NativeGamepadBinding& binding = g_native_bindings[instance / 2];
const uint8_t index = binding.slot;
const bool accepted = index < kSlotCount && g_next_native_token != 0 &&
native_rumble_capable(g_slots[index], instance) &&
native_rumble_capable(g_slots[index], instance & 1u) &&
!cue.in_flight && (sample_id == 0 || (cue.result != 0 && !cue.active));
if (accepted) {
cue = {};
cue.token = g_next_native_token++;
cue.slot = index;
cue.connection_generation = g_native_generation;
cue.connection_generation = binding.generation;
cue.requested_ms = btstack_run_loop_get_time_ms();
cue.sample_id = sample_id;
cue.result = 0;
@ -4745,12 +4874,12 @@ bool bluepad32_input_backend_native_sample_request(
}
int bluepad32_input_backend_native_sample_result(uint8_t instance, uint64_t token) {
if (!g_initialized || instance >= 2 || token == 0) return -1;
if (!g_initialized || instance >= kNativeChildCount || token == 0) return -1;
state_lock_enter();
NativeGamepadCue& cue = g_native_cues[instance];
int result = -1;
if (cue.token == token && !cue.consumed) {
if (!native_cue_current(cue) ||
if (!native_cue_current(instance / 2, cue) ||
(cue.result == 0 &&
btstack_run_loop_get_time_ms() - cue.requested_ms >= kNativeGamepadCueDeadlineMs))
cancel_native_cue_locked(cue);
@ -4762,7 +4891,7 @@ int bluepad32_input_backend_native_sample_result(uint8_t instance, uint64_t toke
}
void bluepad32_input_backend_native_sample_cancel(uint8_t instance) {
if (!g_initialized || instance >= 2) return;
if (!g_initialized || instance >= kNativeChildCount) return;
state_lock_enter();
cancel_native_cue_locked(g_native_cues[instance]);
state_lock_exit();

View file

@ -109,6 +109,14 @@ void bluepad32_input_backend_wii_sample_cancel();
#endif
#if SWITCH2_BRIDGE_FULL_INPUT
#ifdef PROBE_CONTROLLER_COUNT
static_assert(PROBE_CONTROLLER_COUNT == 2 || PROBE_CONTROLLER_COUNT == 4);
constexpr uint8_t BLUEPAD32_NATIVE_PAIR_COUNT = PROBE_CONTROLLER_COUNT / 2;
#else
constexpr uint8_t BLUEPAD32_NATIVE_PAIR_COUNT = 1;
#endif
static_assert(BLUEPAD32_NATIVE_PAIR_COUNT == 1 || BLUEPAD32_NATIVE_PAIR_COUNT == 2);
// One logical gamepad, calibrated SDL axes before legacy int16 conversion.
// Sensor receipt times advance independently, only on actual parser ingress.
struct Bluepad32NativeGamepadSnapshot {
@ -128,11 +136,16 @@ struct Bluepad32NativeGamepadSnapshot {
int32_t gyro_q10[3]{};
};
// nullptr selects the uniquely eligible ready logical gamepad; ambiguity fails closed.
// Reselection invalidates input and cue tokens without modifying pairings.
void bluepad32_input_backend_select_native_source(const uint8_t address[6]);
void bluepad32_input_backend_native_snapshot(Bluepad32NativeGamepadSnapshot* output);
// Instance 0 is R, 1 is L. Samples 0..7 are bounded compatibility cues, not HD
// nullptr selects automatic assignment: one pair requires a uniquely eligible
// gamepad; two pairs reserve stable logical identities in first-free order for
// this boot. Explicit member addresses reserve the whole logical controller.
// Conflicts fail closed. Reselection retires only affected input/cue epochs,
// without modifying pairings or saved profiles.
void bluepad32_input_backend_select_native_source(
uint8_t pair_index, const uint8_t address[6]);
void bluepad32_input_backend_native_snapshot(
uint8_t pair_index, Bluepad32NativeGamepadSnapshot* output);
// Instances are A_R, A_L, then B_R, B_L. Samples 0..7 are bounded compatibility cues, not HD
// haptics. A side stop removes only that side's contribution. Mono drivers combine
// both contributions on their one actuator; this does not promise stereo output.
// Result: 0 pending, 1 source-driver dispatch, -1 retired/failed/consumed.

View file

@ -12,7 +12,15 @@ struct SystemClockStatus {
int32_t temperature_millicelsius;
};
#ifdef __cplusplus
extern "C" {
#endif
// Core 0, before board/peripheral initialization and before launching core 1.
void system_clock_initialize();
void system_clock_initialize(void);
// Core 0 only; reads the dedicated on-chip temperature ADC channel.
SystemClockStatus system_clock_status();
struct SystemClockStatus system_clock_status(void);
#ifdef __cplusplus
}
#endif

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,14 @@
extern "C" {
#endif
// Native SIE hub: device slot 0 is the hub; controller instances 0/1 map to
// device slots 1/2 (right/left). The caller owns Bluetooth on Core 0; this
// transport owns Core 1. No external USB wiring is used.
#ifndef PROBE_CONTROLLER_COUNT
#define PROBE_CONTROLLER_COUNT 2u
#endif
// Native SIE hub: slot 0 is the hub; instances map to slots instance+1,
// ordered pair A right/left, then (in the neutral experiment) pair B right/left.
// The caller owns protocols on Core 0; this transport owns Core 1. No external
// USB wiring is used.
// Recover a timed-out test firmware to BOOTSEL instead of rebooting forever.
void native_hub_startup_guard(void);
bool native_hub_init(void);
@ -29,9 +34,12 @@ uint32_t native_hub_vendor_write(uint8_t instance, const void* data, uint32_t le
uint32_t native_hub_vendor_write_flush(uint8_t instance);
// OUT packets are delivered directly and once through tud_vendor_rx_cb;
// there is no second receive FIFO to drain in this backend.
// Pre-approved IN replies may arm STATUS_OUT in IRQ after their final IN.
// Opt in only when SETUP validates the reply and DATA cannot reject status;
// DATA/ACK callbacks still run in foreground. OUT requests must pass false.
bool native_hub_control_xfer(uint8_t device_slot,
const tusb_control_request_t* request,
void* buffer, uint16_t length);
void* buffer, uint16_t length, bool read_status_preapproved);
bool native_hub_control_status(uint8_t device_slot,
const tusb_control_request_t* request);

View file

@ -8,13 +8,24 @@
extern "C" {
#endif
// Core1: selected token wrapper; the original selector owns every hardware decision.
bool native_hub_select_device_traced(uint8_t address, uint8_t owner, uint32_t cutoff, uint8_t pid);
// Trace-build-only root vendor read: C0/5e, value5452, index=child slot,
// length16. Reply: NHTR, version1, status(0 captured/1 busy), slot, reserved0,
// little-endian capture time_us and control generation (both zero when busy).
enum {
NATIVE_HUB_TRACE_REQUEST = 0x5e,
NATIVE_HUB_TRACE_VALUE = 0x5452,
NATIVE_HUB_TRACE_REPLY_SIZE = 16,
};
// Core1: call after EVERY failed selection, including OUT and while frozen.
// Observes the completed decision; never retries or changes the bank.
void native_hub_note_failed_select(uint8_t address, uint8_t owner, uint32_t cutoff, uint8_t pid);
// Core0: call only AFTER restoring the logger's saved interrupt state.
void native_hub_note_log_mask(uint32_t elapsed_us, uint32_t bytes, bool already_masked);
// Core1: call after EVERY successful selection, including while frozen.
// Retains handovers, child SETUP/first IN/OUT, and the first child IN after a
// first-EP0-IN publication notification, even without a handover. Publication
// tickets correlate with a valid CONTROL_CLOCK arm/slot, not necessarily the
// current control. No pre-selection or endpoint/physical acceptance is implied.
void native_hub_note_selected_token(uint8_t address, uint8_t owner, uint32_t cutoff, uint8_t pid);
// Core0 execution tags. Backend lock tags include the source line.
enum {
NATIVE_HUB_TRACE_PHASE_NONE = 0,

View file

@ -192,7 +192,7 @@ size_t encode_transaction(uint8_t* output, size_t output_size) {
size_t encode_info(uint8_t* output, size_t output_size) {
uint8_t payload[8] = {
#if SWITCH2_PROBE_HUB
0, 89, 0, 2,
0, 100, 0, 2,
kNativeHubActiveMode,
USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE |
USB_OUTPUT_CAPABILITY_MOTION,
@ -716,7 +716,8 @@ bool management_control_xfer(uint8_t rhport,
const tusb_control_request_t* request,
void* buffer, uint16_t length) {
#if SWITCH2_PROBE_HUB
return native_hub_control_xfer(rhport, request, buffer, length);
return native_hub_control_xfer(rhport, request, buffer, length,
(request->bmRequestType & 0x80u) != 0);
#else
return tud_control_xfer(rhport, request, buffer, length);
#endif

View file

@ -56,9 +56,9 @@ void report_dualsense(uni_hid_device_t& pad, bool fresh_motion = true) {
platform_on_controller_data(&pad, &pad.controller);
}
Bluepad32NativeGamepadSnapshot bridge_snapshot() {
Bluepad32NativeGamepadSnapshot bridge_snapshot(uint8_t pair = 0) {
Bluepad32NativeGamepadSnapshot result{};
bluepad32_input_backend_native_snapshot(&result);
bluepad32_input_backend_native_snapshot(pair, &result);
return result;
}
@ -72,10 +72,10 @@ void source_isolation() {
#endif
require(platform_on_device_ready(&ordinary) == UNI_ERROR_INVALID_CONTROLLER,
"an ineligible controller must not enter dedicated output slots");
bluepad32_input_backend_select_native_source(ordinary.conn.btaddr);
bluepad32_input_backend_select_native_source(0, ordinary.conn.btaddr);
require(!bridge_snapshot().controller.active, "an ineligible device cannot become the native source");
platform_on_device_disconnected(&ordinary);
bluepad32_input_backend_select_native_source(nullptr);
bluepad32_input_backend_select_native_source(0, nullptr);
auto first = dualsense(0);
auto second = dualsense(1);
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS, "first DS5 must connect");
@ -121,7 +121,7 @@ void source_isolation() {
"a missed ambiguous interval still needs a new adapter epoch");
platform_on_device_connected(&second);
require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "Edge reconnect must succeed");
bluepad32_input_backend_select_native_source(first.conn.btaddr);
bluepad32_input_backend_select_native_source(0, first.conn.btaddr);
report_dualsense(first);
report_dualsense(second);
require(bridge_snapshot().slot == 0, "explicit source must ignore another live PS5");
@ -265,7 +265,7 @@ void cue_races() {
process_rumble_timer(&g_rumble_timer);
require(pad.last_rumble_duration_ms == 0, "in-flight cancellation must retain a bounded stop obligation");
require(bluepad32_input_backend_native_sample_request(1, 1, &token), "reselection race must queue");
during_dualsense_dispatch = [] { bluepad32_input_backend_select_native_source(nullptr); };
during_dualsense_dispatch = [] { bluepad32_input_backend_select_native_source(0, nullptr); };
process_rumble_timer(&g_rumble_timer);
during_dualsense_dispatch = nullptr;
require(bluepad32_input_backend_native_sample_result(1, token) == -1,
@ -329,7 +329,7 @@ void sensorless_admission() {
"unknown-family normal AIO gamepads must not face a native brand whitelist");
report_gamepad(generic);
require(!bridge_snapshot().controller.active, "two logical gamepads are ambiguous");
bluepad32_input_backend_select_native_source(xbox.conn.btaddr);
bluepad32_input_backend_select_native_source(0, xbox.conn.btaddr);
now_ms = 110;
report_gamepad(xbox);
require(bridge_snapshot().controller.active && bridge_snapshot().slot == 0 &&
@ -338,7 +338,7 @@ void sensorless_admission() {
platform_on_device_disconnected(&xbox);
report_gamepad(generic);
require(!bridge_snapshot().controller.active, "explicit selection cannot migrate on disconnect");
bluepad32_input_backend_select_native_source(nullptr);
bluepad32_input_backend_select_native_source(0, nullptr);
generic.controller.gamepad.buttons = BUTTON_B;
report_gamepad(generic);
require(bridge_snapshot().controller.active && bridge_snapshot().controller.state.button_east &&
@ -379,7 +379,7 @@ void independent_motion() {
report_gamepad(ds4);
require(!bridge_snapshot().gyro_valid && bridge_snapshot().accel_valid,
"gyro capability loss must not suppress working acceleration or controls");
bluepad32_input_backend_select_native_source(nullptr);
bluepad32_input_backend_select_native_source(0, nullptr);
++ds.report_sequence;
ds.gyro_valid = true;
report_gamepad(ds4);
@ -458,7 +458,7 @@ void paired_source() {
bridge_snapshot().gyro_received_us == 100000 &&
bridge_snapshot().gyro_q10[2] == initial.gyro_q10[2],
"left controls merge without refreshing or replacing the right motion owner");
bluepad32_input_backend_select_native_source(right.conn.btaddr);
bluepad32_input_backend_select_native_source(0, right.conn.btaddr);
++l.report_sequence;
report_gamepad(left);
require(bridge_snapshot().controller.active && !bridge_snapshot().gyro_valid,
@ -487,12 +487,12 @@ void paired_source() {
require(right.last_rumble_duration_ms == 0 && left.last_rumble_duration_ms == 990 &&
bluepad32_input_backend_native_sample_result(0, stop) == 1,
"stopping one paired side preserves the other side's original finite deadline");
bluepad32_input_backend_select_native_source(nullptr);
bluepad32_input_backend_select_native_source(0, nullptr);
set_runtime_joycon_mode(JoyConMode::kIndividual);
require(!bridge_snapshot().controller.active &&
bluepad32_input_backend_native_sample_result(1, lc) == -1,
"live split retires the pair immediately and fails auto selection closed");
bluepad32_input_backend_select_native_source(right.conn.btaddr);
bluepad32_input_backend_select_native_source(0, right.conn.btaddr);
++r.report_sequence;
++r.accel_sequence;
++r.gyro_sequence;
@ -535,7 +535,7 @@ void pair_cue_races() {
uni_hid_device_t* pad, uint16_t delay, uint16_t duration, uint8_t weak, uint8_t strong) {
play_rumble(pad, delay, duration, weak, strong);
now_ms += 2000;
bluepad32_input_backend_select_native_source(nullptr);
bluepad32_input_backend_select_native_source(0, nullptr);
};
require(bluepad32_input_backend_native_sample_request(0, 1, &rc) &&
bluepad32_input_backend_native_sample_request(1, 1, &lc), "reselection race cues must queue");
@ -624,6 +624,369 @@ extern "C" bool uni_hid_parser_native_motion_snapshot(
return false;
}
namespace {
void two_pair_sources() {
start_pairing_backend();
auto first = dualsense(2);
auto second = dualsense(0);
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&second) == UNI_ERROR_SUCCESS,
"two independent physical pads must be admitted");
auto& a = motion_fixture(first).metadata;
auto& b = motion_fixture(second).metadata;
first.controller.gamepad.buttons = BUTTON_A | BUTTON_SHOULDER_L;
second.controller.gamepad.buttons = BUTTON_B | BUTTON_SHOULDER_R;
a.gyro_q10[2] = 10000;
b.gyro_q10[2] = -20000;
now_ms = 100;
report_gamepad(first);
now_ms = 110;
report_gamepad(second);
const auto initial_a = bridge_snapshot(0);
const auto initial_b = bridge_snapshot(1);
require(initial_a.controller.active && initial_b.controller.active &&
initial_a.slot != initial_b.slot &&
initial_a.controller.state.button_south && !initial_a.controller.state.button_east &&
initial_b.controller.state.button_east && !initial_b.controller.state.button_south &&
initial_a.gyro_q10[2] == 10000 && initial_b.gyro_q10[2] == -20000,
"each pair must publish only its own controls and calibrated motion");
initialize_runtime_profile_storage();
auto profile_a = controller_profile_default(initial_a.controller.identity, 2);
auto profile_b = controller_profile_default(initial_b.controller.identity, 5);
profile_a.confirmation_policy = profile_b.confirmation_policy = ControllerProfileConfirmationPolicy::kNone;
profile_a.button_map[static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth)] =
static_cast<uint8_t>(ControllerProfileLogicalButton::kNorth);
profile_b.button_map[static_cast<uint8_t>(ControllerProfileLogicalButton::kEast)] =
static_cast<uint8_t>(ControllerProfileLogicalButton::kWest);
require(runtime_profile_storage.set(initial_a.controller.identity, 2, profile_a) == ProfileStorageResult::kOk &&
runtime_profile_storage.activate(initial_a.controller.identity, 2) == ProfileStorageResult::kOk &&
runtime_profile_storage.set(initial_b.controller.identity, 5, profile_b) == ProfileStorageResult::kOk &&
runtime_profile_storage.activate(initial_b.controller.identity, 5) == ProfileStorageResult::kOk,
"independent identities must retain distinct active mapping banks");
controller_profile_runtime_reset();
const auto mapped_a = controller_profile_runtime_transform(
initial_a.slot, initial_a.controller, now_ms, AdapterUsbMode::kXInput);
const auto mapped_b = controller_profile_runtime_transform(
initial_b.slot, initial_b.controller, now_ms, AdapterUsbMode::kXInput);
require(mapped_a.state.button_north && !mapped_a.state.button_south &&
mapped_b.state.button_west && !mapped_b.state.button_east,
"each published source must use its own saved profile mapping");
now_ms = 120;
++a.report_sequence;
++a.accel_sequence;
first.controller.gamepad.buttons = BUTTON_X;
report_gamepad(first);
require(bridge_snapshot(0).controller.state.button_west &&
bridge_snapshot(0).accel_received_us == 120000 &&
bridge_snapshot(0).gyro_received_us == 100000 &&
bridge_snapshot(1).controller.state.button_east &&
bridge_snapshot(1).received_us == 110000 &&
bridge_snapshot(1).gyro_received_us == 110000,
"one source's input and independent sensor clocks must not freshen the other source");
require(bluepad32_input_backend_capture_start(
initial_b.slot, initial_b.controller.connection_generation, CaptureOptions{}),
"Pair B must be recordable while Pair A changes connections");
uint64_t old_a, live_b;
require(bluepad32_input_backend_native_sample_request(0, 1, &old_a) &&
bluepad32_input_backend_native_sample_request(3, 1, &live_b),
"both sources must accept independent pending feedback");
platform_on_device_disconnected(&first);
auto extra = dualsense(1);
require(platform_on_device_ready(&extra) == UNI_ERROR_SUCCESS, "third source may connect without assignment");
report_dualsense(extra);
require(!bridge_snapshot(0).controller.active &&
bridge_snapshot(1).controller.connection_generation == initial_b.controller.connection_generation &&
bluepad32_input_backend_native_sample_result(0, old_a) == -1 &&
bluepad32_input_backend_native_sample_result(3, live_b) == 0,
"a new third pad cannot steal a disconnected reservation or retire the independent pair");
auto reconnected = dualsense(3);
memcpy(reconnected.conn.btaddr, first.conn.btaddr, sizeof(first.conn.btaddr));
reconnected.product_id = first.product_id;
platform_on_device_connected(&reconnected);
require(platform_on_device_ready(&reconnected) == UNI_ERROR_SUCCESS, "reserved source must reconnect");
now_ms = 130;
report_dualsense(reconnected);
require(bridge_snapshot(0).controller.active &&
controller_identity_equal(bridge_snapshot(0).controller.identity, initial_a.controller.identity) &&
bridge_snapshot(0).slot != initial_a.slot &&
bridge_snapshot(1).slot == initial_b.slot &&
bridge_snapshot(1).controller.connection_generation == initial_b.controller.connection_generation,
"stable reservations must restore Pair A across physical and logical slot changes without moving Pair B");
++b.report_sequence;
second.controller.gamepad.buttons = BUTTON_Y;
report_gamepad(second);
Bluepad32CaptureSnapshot capture{};
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kRecording && capture.total_events == 2,
"Pair B capture must keep recording real changes across Pair A's disconnect and rebind");
process_rumble_timer(&g_rumble_timer);
require(extra.rumble_calls == 0 && reconnected.rumble_calls == 0 &&
second.last_high == 0 && second.last_low == 160 &&
bluepad32_input_backend_native_sample_result(3, live_b) == 1,
"pending Pair B work must reach only its original physical source after Pair A reconnects");
const auto restored = bridge_snapshot(0);
const auto remapped = controller_profile_runtime_transform(
restored.slot, restored.controller, now_ms, AdapterUsbMode::kXInput);
require(remapped.state.button_north && !remapped.state.button_south &&
runtime_profile_storage.find(initial_b.controller.identity)->active_profile == 5,
"reconnecting at another logical slot must preserve A's saved mapping and B's active profile");
}
void two_pair_cues() {
start_pairing_backend();
auto first = dualsense(0);
auto second = dualsense(1);
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "both cue sources must connect");
report_dualsense(first);
report_dualsense(second);
const auto before_b = bridge_snapshot(1);
uint64_t ar, al, br, bl;
require(bluepad32_input_backend_native_sample_request(0, 6, &ar) &&
bluepad32_input_backend_native_sample_request(1, 7, &al) &&
bluepad32_input_backend_native_sample_request(2, 3, &br) &&
bluepad32_input_backend_native_sample_request(3, 1, &bl),
"all four virtual sides must accept independent cues");
process_rumble_timer(&g_rumble_timer);
require(first.last_high == 96 && first.last_low == 220 && first.last_rumble_duration_ms == 60 &&
second.last_high == 96 && second.last_low == 160 && second.last_rumble_duration_ms == 25 &&
bluepad32_input_backend_native_sample_result(2, ar) == -1 &&
bluepad32_input_backend_native_sample_result(0, br) == -1,
"R/L contributions and completion tokens must be scoped to their physical pair");
now_ms = 25;
process_rumble_timer(&g_rumble_timer);
require(second.last_high == 0 && second.last_low == 160 && second.last_rumble_duration_ms == 975 &&
first.last_high == 96 && first.last_low == 220,
"Pair B's pulse boundary must not replace Pair A's independently timed motors");
const uint8_t absent[6] = {0xee, 0, 0, 0, 0, 1};
bluepad32_input_backend_select_native_source(0, absent);
process_rumble_timer(&g_rumble_timer);
require(first.last_rumble_duration_ms == 0 && second.last_low == 160 &&
bluepad32_input_backend_native_sample_result(0, ar) == -1 &&
bluepad32_input_backend_native_sample_result(1, al) == -1 &&
bluepad32_input_backend_native_sample_result(2, br) == 1 &&
bluepad32_input_backend_native_sample_result(3, bl) == 1 &&
bridge_snapshot(1).controller.connection_generation == before_b.controller.connection_generation,
"disabling Pair A must stop only A and preserve B's accepted cues and input epoch");
bluepad32_input_backend_native_sample_cancel(2);
now_ms = 40;
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_select_native_source(0, nullptr);
require(bluepad32_input_backend_native_sample_request(0, 1, &ar) &&
bluepad32_input_backend_native_sample_request(2, 6, &br),
"retired sides can accept fresh boot-unique work");
during_dualsense_dispatch = [] {
during_dualsense_dispatch = nullptr;
bluepad32_input_backend_select_native_source(0, nullptr);
};
process_rumble_timer(&g_rumble_timer);
require(bluepad32_input_backend_native_sample_result(0, ar) == -1 &&
bluepad32_input_backend_native_sample_result(2, br) == 1 &&
second.last_high == 96 && second.last_low == 160 && second.last_rumble_duration_ms == 60,
"reselection during A's driver call must reject stale A completion without retiring B's next dispatch");
process_rumble_timer(&g_rumble_timer);
require(first.last_rumble_duration_ms == 0 && second.last_high == 96,
"a raced A submission must be stopped without canceling B's physical timer");
platform_on_device_disconnected(&first);
now_ms = 100;
process_rumble_timer(&g_rumble_timer);
require(second.last_high == 0 && second.last_low == 160 && second.last_rumble_duration_ms == 900,
"B's remaining left pulse must retain its original deadline after A disconnects");
}
void explicit_precedence() {
start_pairing_backend();
auto first = dualsense(0);
auto second = dualsense(1);
bluepad32_input_backend_select_native_source(1, second.conn.btaddr);
require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "explicit Pair B may arrive first");
report_dualsense(second);
require(!bridge_snapshot(0).controller.active && bridge_snapshot(1).controller.active,
"automatic Pair A cannot borrow an explicitly reserved source");
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS, "independent automatic source must connect");
report_dualsense(first);
const auto initial_a = bridge_snapshot(0);
auto duplicate = dualsense(2);
memcpy(duplicate.conn.btaddr, second.conn.btaddr, sizeof(second.conn.btaddr));
duplicate.product_id = second.product_id;
require(platform_on_device_ready(&duplicate) == UNI_ERROR_SUCCESS, "ambiguous-address fixture must connect");
report_dualsense(duplicate);
require(!bridge_snapshot(1).controller.active &&
bridge_snapshot(0).controller.connection_generation == initial_a.controller.connection_generation,
"an ambiguous explicit address must fail only its affected pair closed");
platform_on_device_disconnected(&duplicate);
report_dualsense(second);
require(bridge_snapshot(1).controller.active, "the unique explicit match must resume after ambiguity clears");
bluepad32_input_backend_select_native_source(0, second.conn.btaddr);
report_dualsense(second);
require(!bridge_snapshot(0).controller.active && !bridge_snapshot(1).controller.active,
"two explicit selectors matching one logical pad must never broadcast it");
bluepad32_input_backend_select_native_source(0, nullptr);
report_dualsense(first);
report_dualsense(second);
require(controller_identity_equal(bridge_snapshot(0).controller.identity, identity_for_device(&first)) &&
controller_identity_equal(bridge_snapshot(1).controller.identity, identity_for_device(&second)),
"releasing an explicit conflict restores separate automatic and explicit sources");
}
void paired_explicit_conflict() {
start_pairing_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
bluepad32_input_backend_select_native_source(0, left.conn.btaddr);
bluepad32_input_backend_select_native_source(1, right.conn.btaddr);
ready_switch2(left);
uint64_t old;
require(bluepad32_input_backend_native_sample_request(0, 1, &old), "solo explicit source cue must queue");
ready_switch2(right);
motion_fixture(left);
motion_fixture(right);
report_gamepad(right);
uint64_t rejected;
require(!bridge_snapshot(0).controller.active && !bridge_snapshot(1).controller.active &&
bluepad32_input_backend_native_sample_result(0, old) == -1 &&
!bluepad32_input_backend_native_sample_request(2, 1, &rejected),
"paired physical halves matched by different explicit selectors must retire old work and fail both closed");
bluepad32_input_backend_select_native_source(1, nullptr);
++sensors[right.idx].metadata.report_sequence;
report_gamepad(right);
require(bridge_snapshot(0).controller.active && !bridge_snapshot(1).controller.active,
"an explicit logical pair reserves both halves against automatic assignment");
auto independent = dualsense(2);
require(platform_on_device_ready(&independent) == UNI_ERROR_SUCCESS, "independent second source must connect");
report_dualsense(independent);
const auto before_b = bridge_snapshot(1);
uint64_t rc, lc, bc;
require(bluepad32_input_backend_native_sample_request(0, 6, &rc) &&
bluepad32_input_backend_native_sample_request(1, 1, &lc) &&
bluepad32_input_backend_native_sample_request(3, 7, &bc),
"paired real halves and independent pad must accept separate feedback");
process_rumble_timer(&g_rumble_timer);
require(right.last_high == 96 && left.last_low == 160 && independent.last_low == 220,
"feedback must respect both logical pair and paired physical side");
set_runtime_joycon_mode(JoyConMode::kIndividual);
++sensors[left.idx].metadata.report_sequence;
++sensors[right.idx].metadata.report_sequence;
report_gamepad(left);
report_gamepad(right);
require(bridge_snapshot(0).controller.active &&
controller_identity_equal(bridge_snapshot(0).controller.identity, identity_for_device(&left)) &&
bridge_snapshot(1).controller.connection_generation == before_b.controller.connection_generation &&
bluepad32_input_backend_native_sample_result(0, rc) == -1 &&
bluepad32_input_backend_native_sample_result(1, lc) == -1 &&
bluepad32_input_backend_native_sample_result(3, bc) == 1,
"splitting a physical pair retires only its old cues and cannot duplicate its unselected member into Pair B");
}
void topology_reservations() {
start_pairing_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
ready_switch2(left);
ready_switch2(right);
motion_fixture(left);
motion_fixture(right);
report_gamepad(right);
auto independent = dualsense(2);
require(platform_on_device_ready(&independent) == UNI_ERROR_SUCCESS, "independent automatic source must connect");
report_dualsense(independent);
const auto before_b = bridge_snapshot(1);
const auto pair_identity = bridge_snapshot(0).controller.identity;
set_runtime_joycon_mode(JoyConMode::kIndividual);
++sensors[left.idx].metadata.report_sequence;
++sensors[right.idx].metadata.report_sequence;
report_gamepad(left);
report_gamepad(right);
require(!bridge_snapshot(0).controller.active &&
bridge_snapshot(1).controller.connection_generation == before_b.controller.connection_generation,
"a split remembered pair is ambiguous without moving the independent pair");
set_runtime_joycon_mode(JoyConMode::kPaired);
++sensors[right.idx].metadata.report_sequence;
report_gamepad(right);
require(bridge_snapshot(0).controller.active &&
controller_identity_equal(bridge_snapshot(0).controller.identity, pair_identity) &&
bridge_snapshot(1).controller.connection_generation == before_b.controller.connection_generation,
"remerging the same remembered members must restore only their reserved pair");
platform_on_device_disconnected(&independent);
set_runtime_joycon_mode(JoyConMode::kIndividual);
bluepad32_input_backend_select_native_source(0, left.conn.btaddr);
bluepad32_input_backend_select_native_source(1, right.conn.btaddr);
bluepad32_input_backend_select_native_source(0, nullptr);
bluepad32_input_backend_select_native_source(1, nullptr);
++sensors[left.idx].metadata.report_sequence;
++sensors[right.idx].metadata.report_sequence;
report_gamepad(left);
report_gamepad(right);
require(bridge_snapshot(0).controller.active && bridge_snapshot(1).controller.active,
"individually reserved physical halves must first own separate logical streams");
set_runtime_joycon_mode(JoyConMode::kPaired);
++sensors[right.idx].metadata.report_sequence;
report_gamepad(right);
require(!bridge_snapshot(0).controller.active && !bridge_snapshot(1).controller.active,
"merging two independently reserved sources must fail both closed rather than duplicate the merged pair");
set_runtime_joycon_mode(JoyConMode::kIndividual);
++sensors[left.idx].metadata.report_sequence;
++sensors[right.idx].metadata.report_sequence;
report_gamepad(left);
report_gamepad(right);
require(bridge_snapshot(0).controller.active && bridge_snapshot(1).controller.active &&
bridge_snapshot(0).slot != bridge_snapshot(1).slot,
"splitting conflicting members restores their previous independent reservations");
}
void stable_ble_reservation() {
start_pairing_backend();
auto first = device(0, true, UNI_BT_CONN_PROTOCOL_BLE);
auto independent = dualsense(1);
bluepad32_input_backend_select_native_source(1, independent.conn.btaddr);
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&independent) == UNI_ERROR_SUCCESS,
"an unresolved BLE gamepad may connect beside an explicit stable source");
report_gamepad(first);
report_dualsense(independent);
const auto initial_b = bridge_snapshot(1);
require(!bridge_snapshot(0).controller.active && initial_b.controller.active,
"automatic reservations must not promote an unresolved BLE connection address to a stable identity");
const bd_addr_t identity = {0xc2, 0x10, 0x20, 0x30, 0x40, 0x50};
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, first,
BD_ADDR_TYPE_LE_RANDOM, identity);
first.controller.gamepad.buttons = BUTTON_A;
report_gamepad(first);
const auto initial_a = bridge_snapshot(0);
uint64_t old_a, live_b;
require(initial_a.controller.active && initial_a.controller.state.button_south &&
bluepad32_input_backend_native_sample_request(0, 1, &old_a) &&
bluepad32_input_backend_native_sample_request(3, 1, &live_b),
"resolved identity publication must activate its own stream and feedback without waiting for another connection");
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_STARTED, first,
BD_ADDR_TYPE_LE_RANDOM, identity);
require(!bridge_snapshot(0).controller.active &&
bridge_snapshot(1).controller.connection_generation == initial_b.controller.connection_generation &&
bluepad32_input_backend_native_sample_result(0, old_a) == -1 &&
bluepad32_input_backend_native_sample_result(3, live_b) == 0,
"identity loss must retire only the uncertain source's input and pending work");
platform_on_device_disconnected(&first);
auto reconnect = device(2, true, UNI_BT_CONN_PROTOCOL_BLE);
reconnect.vendor_id = first.vendor_id;
reconnect.product_id = first.product_id;
platform_on_device_connected(&reconnect);
require(platform_on_device_ready(&reconnect) == UNI_ERROR_SUCCESS, "BLE controller must reconnect at a different transport index");
report_gamepad(reconnect);
require(!bridge_snapshot(0).controller.active, "a fresh unresolved BLE address must not steal the remembered stable source");
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, reconnect,
BD_ADDR_TYPE_LE_RANDOM, identity);
reconnect.controller.gamepad.buttons = BUTTON_B;
report_gamepad(reconnect);
require(bridge_snapshot(0).controller.active && bridge_snapshot(0).controller.state.button_east &&
!bridge_snapshot(0).controller.state.button_south &&
controller_identity_equal(bridge_snapshot(0).controller.identity, initial_a.controller.identity) &&
bridge_snapshot(1).controller.connection_generation == initial_b.controller.connection_generation,
"resolving a new BLE connection address must recover the original pair reservation without reviving cached controls");
}
} // namespace
int main(int argc, char** argv) {
require(argc == 2, "scenario required");
const std::string scenario = argv[1];
@ -636,6 +999,12 @@ int main(int argc, char** argv) {
else if (scenario == "paired-source") paired_source();
else if (scenario == "pair-cue-races") pair_cue_races();
else if (scenario == "mono-rumble") mono_rumble();
else if (scenario == "two-pair-sources") two_pair_sources();
else if (scenario == "two-pair-cues") two_pair_cues();
else if (scenario == "explicit-precedence") explicit_precedence();
else if (scenario == "paired-explicit-conflict") paired_explicit_conflict();
else if (scenario == "topology-reservations") topology_reservations();
else if (scenario == "stable-ble-reservation") stable_ble_reservation();
else require(false, "unknown native gamepad scenario");
return 0;
}

104
tests/native_hub_log_test.c Normal file
View file

@ -0,0 +1,104 @@
#include <assert.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "hardware_stub.h"
#include "tusb_config.h"
static unsigned test_core, test_exception;
#define get_core_num() test_core
#define __get_current_exception() test_exception
#define hard_assert(value) assert(value)
#define HID_REPORT_TYPE_INPUT 1
static uint64_t get_absolute_time(void) { return 0; }
static uint32_t to_ms_since_boot(uint64_t value) { return (uint32_t)value; }
static void sleep_us(uint32_t microseconds) { (void)microseconds; }
static void panic(const char* text) { (void)text; abort(); }
#define main unused_probe_firmware_main
#include "../tools/switch2_usb_probe/main.c"
#undef main
uint32_t native_test_interrupt_mask;
static bool pending_completion, inject_completion;
static unsigned completions, missed_tokens;
static uint32_t phase;
static char serial_bytes[2 * LOG_CAPACITY];
static size_t serial_size;
void native_test_service_interrupt(void) {
if (pending_completion && !native_test_interrupt_mask) {
pending_completion = false;
++completions;
}
}
uint32_t native_hub_trace_phase(uint32_t next) {
const uint32_t previous = phase;
phase = next;
if (inject_completion && next == NATIVE_HUB_TRACE_PHASE_LOG_COPY) {
// A completed child packet must be serviced before the next owner's
// token can be selected. This is the same blocking condition checked
// by native_hub_select_device; the real logger runs between both.
pending_completion = true;
native_test_service_interrupt();
if (pending_completion) ++missed_tokens;
}
return previous;
}
bool uart_is_writable(void* uart) { (void)uart; return serial_size < sizeof(serial_bytes); }
void uart_putc_raw(void* uart, char value) { (void)uart; serial_bytes[serial_size++] = value; }
int main(int argc, char** argv) {
if (argc == 2) {
if (strcmp(argv[1], "core") == 0) test_core = 1;
else if (strcmp(argv[1], "irq") == 0) test_exception = 16;
else return 2;
probe_debug_printf("unsafe caller\n");
return 0;
}
// Exercise a wrapped, full-length diagnostic message, not just empty logs.
char message[480];
memset(message, 'x', sizeof(message) - 1);
message[sizeof(message) - 1] = 0;
log_read = log_written = LOG_CAPACITY - 13;
inject_completion = true;
assert(probe_debug_printf("%s", message) == (int)strlen(message));
assert(missed_tokens == 0 && "logging blocked a USB completion and the next device's token");
assert(completions == 1 && !pending_completion);
drain_log();
assert(serial_size == strlen(message));
assert(memcmp(serial_bytes, message, serial_size) == 0);
// Full-ring overflow drops a complete message without corrupting queued data.
inject_completion = false;
serial_size = 0;
log_read = 0; log_written = LOG_CAPACITY;
memset(log_bytes, 'q', sizeof(log_bytes));
const uint32_t drops_before = log_dropped;
assert(probe_debug_printf("discard me") < 0);
drain_log();
assert(serial_size == LOG_CAPACITY);
for (size_t i = 0; i < serial_size; ++i) assert(serial_bytes[i] == 'q');
assert(log_dropped == drops_before + 10);
serial_size = 0;
assert(probe_debug_printf("discard me") == 10);
drain_log();
assert(serial_size == 10 && memcmp(serial_bytes, "discard me", 10) == 0);
// Respect a caller's existing critical section; logging cannot enable IRQs.
serial_size = 0;
inject_completion = true;
native_test_interrupt_mask = 1;
const unsigned completed_before = completions;
probe_debug_printf("caller owns mask");
assert(native_test_interrupt_mask == 1 && completions == completed_before);
restore_interrupts(0);
assert(completions == completed_before + 1);
drain_log();
assert(serial_size == strlen("caller owns mask"));
assert(memcmp(serial_bytes, "caller owns mask", serial_size) == 0);
puts("native logging preserved USB progress, message order and caller IRQ state");
return 0;
}

View file

@ -13,6 +13,8 @@
extern "C" {
void native_test_initialize(void);
void native_test_drain(void);
bool native_test_startup(void);
void native_test_advance(uint32_t);
bool native_test_setup(uint8_t, const tusb_control_request_t*, bool);
bool native_test_out(uint8_t, const uint8_t*, uint16_t, bool);
bool native_test_in(uint8_t, uint8_t*, uint16_t*, bool);
@ -20,6 +22,12 @@ void native_test_bus_reset(bool);
void native_test_hold_abort(bool);
bool native_test_select(uint8_t);
bool native_test_private_in(uint8_t, uint8_t, uint8_t*, uint16_t*);
bool native_test_private_out(uint8_t, uint8_t, const uint8_t*, uint16_t, bool);
extern uint32_t native_test_hid_completions[PROBE_CONTROLLER_COUNT];
extern uint32_t native_test_bulk_completions[PROBE_CONTROLLER_COUNT];
extern uint32_t native_test_received_count[PROBE_CONTROLLER_COUNT][2];
extern uint16_t native_test_received_length[PROBE_CONTROLLER_COUNT][2];
extern uint8_t native_test_received_data[PROBE_CONTROLLER_COUNT][2][64];
extern uint32_t native_test_interrupt_mask;
}
@ -29,8 +37,10 @@ std::array<uint8_t, PROFILE_STORAGE_ARENA_COUNT * PROFILE_STORAGE_ARENA_SIZE> fl
uint32_t programs = 0;
uint32_t erases = 0;
uint32_t bootsel_calls = 0;
std::array<uint8_t, 64> child_identity[2];
std::array<uint8_t, 64> child_identity[PROBE_CONTROLLER_COUNT];
bool interleave_identity_ack = false;
bool synthetic_root_management = false;
uint32_t bootsel_time_ms = 0;
void require(bool condition, const char* message) {
if (!condition) { std::cerr << message << '\n'; std::exit(1); }
@ -155,6 +165,240 @@ void read_child(uint8_t slot) {
"native child identity leaked root or sibling vendor bytes");
}
void assign_address(uint8_t slot, uint8_t address) {
tusb_control_request_t setup{};
setup.bRequest = TUSB_REQ_SET_ADDRESS;
setup.wValue = address;
require(native_test_setup(slot, &setup, true), "SET_ADDRESS stalled");
acknowledge(slot);
}
void configure(uint8_t slot, uint8_t value = 1) {
tusb_control_request_t setup{};
setup.bRequest = TUSB_REQ_SET_CONFIGURATION;
setup.wValue = value;
require(native_test_setup(slot, &setup, true), "SET_CONFIGURATION stalled");
acknowledge(slot);
}
tusb_control_request_t port_feature(uint8_t port, uint16_t feature, bool set) {
tusb_control_request_t setup{};
setup.bmRequestType = 0x23;
setup.bRequest = set ? TUSB_REQ_SET_FEATURE : TUSB_REQ_CLEAR_FEATURE;
setup.wIndex = port;
setup.wValue = feature;
return setup;
}
void change_port(uint8_t port, uint16_t feature, bool set) {
const auto setup = port_feature(port, feature, set);
require(native_test_setup(0, &setup, true), "port feature request stalled");
acknowledge();
}
std::vector<uint8_t> port_status(uint8_t port) {
tusb_control_request_t setup{};
setup.bmRequestType = 0xa3;
setup.bRequest = TUSB_REQ_GET_STATUS;
setup.wIndex = port;
setup.wLength = 4;
require(native_test_setup(0, &setup, true), "port status request stalled");
return receive();
}
void require_hub_change(uint8_t expected) {
uint8_t packet[64]; uint16_t length = 0;
require(native_test_private_in(0, 0x8f, packet, &length) && length == 1 && packet[0] == expected,
"root interrupt endpoint omitted or mixed port change bits");
native_test_drain();
require(!native_test_private_in(0, 0x8f, packet, &length),
"cleared hub port changes did not return to NAK");
}
void test_port_enumeration_and_bounds() {
require(native_test_startup(), "native hub startup failed");
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot)
require(!native_test_select(slot), "startup assigned an address to an unreset child");
assign_address(0, 9);
configure(0);
tusb_control_request_t descriptor{};
descriptor.bmRequestType = 0xa0;
descriptor.bRequest = TUSB_REQ_GET_DESCRIPTOR;
descriptor.wValue = 0x2900;
descriptor.wLength = 64;
require(native_test_setup(0, &descriptor, true), "hub descriptor stalled");
const auto bytes = receive();
require(bytes.size() == 9 && bytes[0] == 9 && bytes[1] == 0x29 &&
bytes[2] == PROBE_CONTROLLER_COUNT && bytes[7] == (1u << (PROBE_CONTROLLER_COUNT + 1u)) - 2u &&
bytes[8] == 0xff, "hub descriptor has incorrect port or non-removable masks");
for (uint8_t port = 1; port <= PROBE_CONTROLLER_COUNT; ++port) {
require(u16(port_status(port), 0) == 0, "unpowered port is not disconnected");
change_port(port, 8, true);
auto status = port_status(port);
require(u16(status, 0) == 0x101 && u16(status, 2) == 1, "port power did not signal connection");
change_port(port, 16, false);
require_hub_change(1u << port);
change_port(port, 4, true);
status = port_status(port);
require(u16(status, 0) == 0x111 && u16(status, 2) == 0, "port reset completed before its deadline");
native_test_advance(10000);
status = port_status(port);
require(u16(status, 0) == 0x103 && u16(status, 2) == 16, "port reset did not enable its child");
assign_address(port, 17u * port);
configure(port);
read_child(port);
change_port(port, 20, false);
require_hub_change(1u << port);
change_port(port, 2, true);
require(native_hub_suspended(port - 1) && !native_hub_hid_ready(port - 1),
"suspended port remained ready for input");
change_port(port, 2, false);
change_port(port, 18, false);
require_hub_change(1u << port);
}
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) read_child(slot);
for (uint8_t port : {uint8_t{0}, uint8_t{PROBE_CONTROLLER_COUNT + 1}}) {
auto setup = port_feature(port, 8, true);
require(!native_test_setup(0, &setup, true), "out-of-range port feature was accepted");
setup.bmRequestType = 0xa3; setup.bRequest = 0; setup.wValue = 0; setup.wLength = 4;
require(!native_test_setup(0, &setup, true), "out-of-range port status was accepted");
}
const uint8_t data = 1;
for (uint8_t instance : {uint8_t{PROBE_CONTROLLER_COUNT}, uint8_t{255}}) {
require(!native_hub_mounted(instance) && native_hub_suspended(instance) &&
!native_hub_hid_ready(instance) && !native_hub_hid_report(instance, 1, &data, 1) &&
native_hub_vendor_write_available(instance) == 0 &&
native_hub_vendor_write(instance, &data, 1) == 0 && native_hub_vendor_write_flush(instance) == 0,
"out-of-range controller instance touched a bank");
require(!native_hub_control_xfer(instance + (instance != 255), &descriptor, nullptr, 0, false) &&
!native_hub_control_status(instance + (instance != 255), &descriptor),
"out-of-range control slot was accepted");
}
configure(0, 0);
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot)
require(!native_hub_mounted(slot - 1) && !native_test_select(slot),
"root deconfiguration retained a child bank or address");
native_test_initialize();
}
void test_child_control_and_receive_isolation() {
native_test_initialize();
tusb_control_request_t identity{};
identity.bmRequestType = 0xc0; identity.bRequest = 3; identity.wLength = 128;
uint8_t packet[64]; uint16_t length;
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
configure(slot);
const uint8_t payload = 0x70 + slot;
require(native_hub_hid_report(slot - 1, 8, &payload, 1) &&
native_test_private_in(slot, 0x81, packet, &length), "HID completion setup failed");
require(native_test_setup(slot, &identity, false), "interleaved child control setup failed");
}
native_test_drain();
for (uint8_t slot = PROBE_CONTROLLER_COUNT; slot; --slot) {
const auto bytes = receive(slot);
require(bytes == std::vector<uint8_t>(child_identity[slot - 1].begin(), child_identity[slot - 1].end()),
"concurrent control transfers shared another child's EP0 data");
require(native_test_hid_completions[slot - 1] == 1 && native_hub_hid_ready(slot - 1),
"new SETUP invalidated an unrelated HID completion");
}
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
for (uint8_t endpoint : {1, 2}) {
const uint8_t payload[] = {slot, endpoint, uint8_t(slot ^ 0x5a)};
require(native_test_private_out(slot, endpoint, payload, sizeof(payload), false),
"private OUT packet was not accepted");
require(!native_test_private_out(slot, endpoint, payload, sizeof(payload), false),
"pending OUT buffer failed to NAK before foreground consumption");
}
}
native_test_drain();
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
for (uint8_t endpoint : {1, 2}) {
const uint8_t payload[] = {slot, endpoint, uint8_t(slot ^ 0x5a)};
require(native_test_received_count[slot - 1][endpoint - 1] == 1 &&
native_test_received_length[slot - 1][endpoint - 1] == sizeof(payload) &&
std::memcmp(native_test_received_data[slot - 1][endpoint - 1], payload, sizeof(payload)) == 0,
"OUT callback received another child's endpoint payload");
}
}
native_test_initialize();
}
void test_port_reset_revokes_only_its_child_events() {
for (uint8_t target = 1; target <= PROBE_CONTROLLER_COUNT; ++target) {
native_test_initialize();
assign_address(0, 9);
tusb_control_request_t identity{};
identity.bmRequestType = 0xc0; identity.bRequest = 3; identity.wLength = 128;
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
change_port(slot, 8, true);
configure(slot);
const uint8_t data = slot;
require(native_hub_hid_report(slot - 1, 8, &data, 1), "reset isolation HID setup failed");
require(native_test_setup(slot, &identity, true), "reset isolation control setup failed");
}
const auto reset = port_feature(target, 4, true);
require(native_test_setup(0, &reset, true), "port reset request failed");
acknowledge(0, false);
uint8_t packet[64]; uint16_t length;
require(native_test_in(target, packet, &length, false) && length == 64,
"could not queue the reset child's old control completion");
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
const uint8_t data = slot;
require(native_test_private_in(slot, 0x81, packet, &length) &&
native_test_private_out(slot, 2, &data, 1, false), "reset isolation completion setup failed");
}
native_test_drain();
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
const unsigned expected = slot == target ? 0 : 1;
require(native_test_hid_completions[slot - 1] == expected &&
native_test_received_count[slot - 1][1] == expected,
"port reset revoked a sibling event or dispatched a stale child event");
if (slot != target) {
const auto bytes = receive(slot);
require(bytes == std::vector<uint8_t>(child_identity[slot - 1].begin(), child_identity[slot - 1].end()),
"port reset corrupted a sibling control transfer");
}
}
const uint8_t other = target == PROBE_CONTROLLER_COUNT ? 1 : target + 1;
const auto concurrent_reset = port_feature(other, 4, true);
require(!native_test_setup(0, &concurrent_reset, true),
"simultaneous port resets created competing address-zero owners");
native_test_advance(10000);
require(!native_test_in(target, packet, &length, true) && !native_hub_mounted(target - 1),
"port reset retained a stale control packet or configuration");
assign_address(target, 17u * target);
configure(target);
read_child(target);
}
native_test_initialize();
}
void require_interleaved_profile(const std::vector<uint8_t>& expected) {
const auto setup = request(Operation::kProfileRead, true, kMaximumResponseSize);
require(native_test_setup(0, &setup, true), "interleaved profile read setup failed");
std::vector<uint8_t> bytes;
const size_t total = kResponseHeaderSize + expected.size();
for (unsigned index = 0; bytes.size() < total; ++index) {
// Every root IN follows another owner's tokens, including the first.
read_child(1u + index % PROBE_CONTROLLER_COUNT);
uint8_t packet[64]; uint16_t length = 0;
require(native_test_in(0, packet, &length, false),
"prepared profile packet required foreground work after selection");
require(length == std::min<size_t>(64, total - bytes.size()),
"address alternation changed the profile packet boundary");
bytes.insert(bytes.end(), packet, packet + length);
native_test_drain(); // Only the completed packet may prepare its successor.
}
read_child(PROBE_CONTROLLER_COUNT);
require(native_test_out(0, nullptr, 0, true), "interleaved profile status OUT failed");
require(std::memcmp(bytes.data(), "SPMG", 4) == 0 &&
bytes[5] == static_cast<uint8_t>(Operation::kProfileRead) &&
bytes[6] == static_cast<uint8_t>(Status::kOk) && u16(bytes, 8) == expected.size() &&
u32(bytes, 16) == configuration_crc32(expected.data(), expected.size()) &&
std::vector<uint8_t>(bytes.begin() + kResponseHeaderSize, bytes.end()) == expected,
"alternating root and child reads mixed profile or identity bytes");
}
void test_profile_transport() {
const uint32_t programs_before = programs, erases_before = erases;
const auto original = encoded_profile(0);
@ -168,9 +412,9 @@ void test_profile_transport() {
auto playtest = read_operation(Operation::kProfilePlaytest);
require(playtest[kResponseHeaderSize] == 0 && playtest[kResponseHeaderSize + 1] == 0xff,
"disconnected playtest fabricated controller input");
require_profile(original);
require_interleaved_profile(original);
require(programs == programs_before && erases == erases_before, "editor reads wrote saved storage");
for (uint8_t slot : {1, 2}) {
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
const auto management = request(Operation::kProfileList, true, kMaximumResponseSize);
require(!native_test_setup(slot, &management, true), "native child accepted regular management");
read_child(slot);
@ -184,7 +428,7 @@ void test_profile_transport() {
const auto chunk = envelope(Operation::kProfileChunk, chunk_payload(1, edited, 0));
const auto setup = request(Operation::kProfileChunk, false, chunk.size());
require(native_test_setup(0, &setup, true) && native_test_out(0, chunk.data(), 64, true), "first full OUT packet failed");
read_child(1); read_child(2);
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) read_child(slot);
const auto child_management = request(Operation::kInfo, true, kMaximumResponseSize);
require(!native_test_setup(1, &child_management, true), "child INFO was accepted during a root write");
require(native_test_out(0, chunk.data() + 64, chunk.size() - 64, true), "interleaved child requests corrupted root OUT tail");
@ -296,13 +540,16 @@ void test_private_transmit_survives_round_robin_tokens() {
tusb_control_request_t configuration{};
configuration.bRequest = TUSB_REQ_SET_CONFIGURATION;
configuration.wValue = 1;
for (uint8_t slot : {1, 2}) {
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
require(native_test_setup(slot, &configuration, true), "child configuration failed");
acknowledge(slot);
}
const uint8_t payloads[2][3] = {{0x11, 0x22, 0x33}, {0x44, 0x55, 0x66}};
for (uint8_t instance : {0, 1}) {
require(native_hub_hid_report(instance, instance ? 7 : 8, payloads[instance], 3),
uint8_t payloads[PROBE_CONTROLLER_COUNT][3];
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
payloads[instance][0] = 0x11u + instance;
payloads[instance][1] = 0x42u + instance;
payloads[instance][2] = 0x83u + instance;
require(native_hub_hid_report(instance, 8u - instance, payloads[instance], 3),
"could not queue HID packet");
require(native_hub_vendor_write(instance, payloads[instance], 3) == 3 &&
native_hub_vendor_write_flush(instance) == 3, "could not queue bulk packet");
@ -310,26 +557,30 @@ void test_private_transmit_survives_round_robin_tokens() {
uint8_t packet[64];
uint16_t length = 0;
for (uint8_t endpoint : {0x81, 0x82}) {
for (uint8_t slot : {1, 2}) {
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
require(native_test_private_in(slot, endpoint, packet, &length),
"queued private IN packet required foreground work after bank selection");
const unsigned prefix = endpoint == 0x81 ? 1 : 0;
require(length == 3 + prefix &&
(!prefix || packet[0] == (slot == 1 ? 8 : 7)) &&
(!prefix || packet[0] == 9u - slot) &&
std::memcmp(packet + prefix, payloads[slot - 1], 3) == 0,
"round-robin IN token received another endpoint's payload");
require(!native_test_private_in(slot, endpoint, packet, &length),
"unarmed endpoint reused another child's IN packet instead of NAK");
}
}
native_test_drain();
require(native_hub_hid_ready(0) && native_hub_hid_ready(1),
"acknowledged HID packets did not release their queues");
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance)
require(native_hub_hid_ready(instance) && native_test_hid_completions[instance] == 1 &&
native_test_bulk_completions[instance] == 1,
"acknowledged packets did not release exactly one completion per endpoint");
require(!native_test_private_in(1, 0x81, packet, &length),
"acknowledged HID packet was retransmitted");
// The idle poll selected R without restoring its shared EP0 image.
// An idle EP0 bank must not block newly queued private endpoint traffic.
require(native_hub_hid_report(0, 8, payloads[0], 3), "could not queue the next HID packet");
require(native_test_private_in(1, 0x81, packet, &length) && length == 4 &&
std::memcmp(packet + 1, payloads[0], 3) == 0,
"pending shared EP0 restoration blocked a newly queued private IN packet");
"idle shared EP0 blocked a newly queued private IN packet");
native_test_drain();
native_test_initialize();
}
@ -338,62 +589,196 @@ void test_masked_irq_completion_handoff() {
tusb_control_request_t configuration{};
configuration.bRequest = TUSB_REQ_SET_CONFIGURATION;
configuration.wValue = 1;
for (uint8_t slot : {1, 2}) {
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
require(native_test_setup(slot, &configuration, true), "child configuration failed");
acknowledge(slot);
}
const uint8_t payloads[2][3] = {{0x12, 0x34, 0x56}, {0x78, 0x9a, 0xbc}};
for (uint8_t instance : {0, 1})
uint8_t payloads[PROBE_CONTROLLER_COUNT][3];
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
payloads[instance][0] = 0x12u + instance;
payloads[instance][1] = 0x34u + instance;
payloads[instance][2] = 0x56u + instance;
require(native_hub_hid_report(instance, 8, payloads[instance], 3),
"could not queue masked-window HID packet");
}
uint8_t packet[64];
uint16_t length = 0;
native_test_interrupt_mask = 1;
require(native_test_private_in(1, 0x81, packet, &length),
"first controller did not complete during masked window");
require(!native_test_select(2),
"pending completion must prevent overwriting the active bank");
native_hub_service_pending_usb();
require(native_test_interrupt_mask == 1,
"SRAM service must preserve the caller's interrupt mask");
require(native_test_private_in(2, 0x81, packet, &length) && length == 4 &&
packet[0] == 8 && std::memcmp(packet + 1, payloads[1], 3) == 0,
"SRAM service did not permit the other controller's real packet");
native_hub_service_pending_usb();
require(!native_hub_hid_ready(0) && !native_hub_hid_ready(1),
"SRAM service must defer protocol callbacks to foreground dispatch");
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
require(native_test_private_in(slot, 0x81, packet, &length) && length == 4 &&
packet[0] == 8 && std::memcmp(packet + 1, payloads[slot - 1], 3) == 0,
"controller did not retain its packet during the masked window");
const uint8_t next = slot == PROBE_CONTROLLER_COUNT ? 1 : slot + 1;
require(!native_test_select(next),
"pending completion must prevent overwriting the active bank");
native_hub_service_pending_usb();
require(native_test_interrupt_mask == 1,
"SRAM service must preserve the caller's interrupt mask");
require(!native_hub_hid_ready(slot - 1) && native_test_hid_completions[slot - 1] == 0,
"SRAM service must defer protocol callbacks to foreground dispatch");
}
native_test_interrupt_mask = 0;
native_test_drain();
require(native_hub_hid_ready(0) && native_hub_hid_ready(1),
"deferred completions did not release both controller queues");
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance)
require(native_hub_hid_ready(instance) && native_test_hid_completions[instance] == 1,
"deferred completions did not release every controller queue exactly once");
require(!native_test_private_in(1, 0x81, packet, &length),
"later IRQ dispatch duplicated a serviced completion");
native_test_initialize();
}
void test_private_bootsel() {
void require_no_bootsel() {
bootsel_time_ms += 100;
probe_bootsel_task(bootsel_time_ms);
probe_bootsel_task(bootsel_time_ms + 50);
require(bootsel_calls == 0, "unauthorized or unacknowledged BOOTSEL rebooted the device");
}
void test_neutral_management_surface() {
require(!synthetic_root_management, "neutral surface must use the production BOOTSEL-only callback");
const uint32_t programs_before = programs, erases_before = erases;
struct WriteRequest { Operation operation; uint16_t payload_size; };
const WriteRequest writes[] = {
{Operation::kModeSet, 5}, {Operation::kReboot, 4},
{Operation::kConfigurationBegin, 12}, {Operation::kConfigurationChunk, 9},
{Operation::kConfigurationCommit, 4}, {Operation::kConfigurationReset, 4},
{Operation::kProfileSelect, 15}, {Operation::kProfileBegin, 28},
{Operation::kProfileChunk, 9}, {Operation::kProfileCommit, 4},
{Operation::kProfileReset, 19}, {Operation::kProfileActivate, 19},
{Operation::kProfileMetadataSet, 20}, {Operation::kProfileIdentify, 14},
{Operation::kWiiOrientation, 19}, {Operation::kPairingRefresh, 0},
{Operation::kPairingClear, 0},
};
for (uint8_t slot = 0; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
for (Operation op : {Operation::kInfo, Operation::kConfigurationRead,
Operation::kTransactionStatus, Operation::kPairingRead,
Operation::kRuntimeDiagnostics, Operation::kProfileList,
Operation::kProfileRead, Operation::kProfilePlaytest,
Operation::kProfileTransactionStatus, Operation::kProfileMetadataRead}) {
const auto setup = request(op, true, kMaximumResponseSize);
require(!native_test_setup(slot, &setup, true), "neutral device exposed full management reads");
}
for (const auto& item : writes) {
const auto setup = request(item.operation, false, kRequestHeaderSize + item.payload_size);
require(!native_test_setup(slot, &setup, true), "neutral device exposed a management mutation");
}
if (slot) read_child(slot);
}
profile_service_task_on_storage_core(5000);
require(programs == programs_before && erases == erases_before,
"neutral management rejection changed saved profiles");
require_no_bootsel();
}
void test_private_bootsel(uint8_t reboot_slot) {
require(!synthetic_root_management, "BOOTSEL must use the production transport callback");
const uint32_t programs_before = programs, erases_before = erases;
const auto bytes = envelope(Operation::kBootselReboot, {});
const auto setup = request(Operation::kBootselReboot, false, bytes.size());
for (uint8_t slot : {0, 1, 2}) {
require(native_test_setup(slot, &setup, true), "private BOOTSEL setup stalled");
require(!native_test_out(slot, bytes.data(), bytes.size() - 1, true), "short BOOTSEL was accepted");
probe_bootsel_task(100); probe_bootsel_task(200);
require(bootsel_calls == 0, "short BOOTSEL rebooted the device");
require(native_test_setup(slot, &setup, true) && native_test_out(slot, bytes.data(), bytes.size(), true),
"valid private BOOTSEL envelope failed");
// An unrelated identity/INFO SETUP cancels an unacknowledged BOOTSEL.
if (slot) read_child(slot); else read_operation(Operation::kInfo);
probe_bootsel_task(300); probe_bootsel_task(400);
require(bootsel_calls == 0, "unacknowledged BOOTSEL rebooted the device");
tusb_control_request_t replacement{};
replacement.bmRequestType = 0x80;
replacement.bRequest = TUSB_REQ_GET_STATUS;
replacement.wLength = 2;
uint8_t packet[64]; uint16_t length;
for (uint8_t slot = 0; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
for (uint16_t size : {uint16_t{0}, uint16_t{kRequestHeaderSize - 1}}) {
require(native_test_setup(slot, &setup, true), "private BOOTSEL setup stalled");
require(!native_test_in(slot, packet, &length, true), "BOOTSEL armed status before receiving its envelope");
require(!native_test_out(slot, bytes.data(), size, true), "short BOOTSEL was accepted");
require(!native_test_in(slot, packet, &length, true), "short BOOTSEL armed a status ACK");
require_no_bootsel();
}
// Every reserved field and CRC byte must be checked by the shared decoder.
for (size_t offset : {0, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}) {
auto malformed = bytes;
malformed[offset] ^= 1;
require(native_test_setup(slot, &setup, true) &&
native_test_out(slot, bytes.data(), bytes.size(), true), "superseded BOOTSEL setup failed");
require(native_test_setup(slot, &setup, true), "malformed BOOTSEL setup stalled");
require(!native_test_out(slot, malformed.data(), malformed.size(), true), "malformed BOOTSEL was accepted");
require(!native_test_in(slot, packet, &length, true), "malformed BOOTSEL reused an earlier authorization");
require_no_bootsel();
}
std::array<tusb_control_request_t, 8> wrong_setup;
wrong_setup.fill(setup);
wrong_setup[0].bmRequestType = 0x41; // Interface recipient.
wrong_setup[1].bmRequestType = 0x42; // Endpoint recipient.
wrong_setup[2].bmRequestType = 0xc0; // Wrong direction.
wrong_setup[3].wValue ^= 1;
wrong_setup[4].wIndex ^= 1;
wrong_setup[5].wLength = 0;
wrong_setup[6].wLength = kRequestHeaderSize - 1;
wrong_setup[7].wLength = kRequestHeaderSize + 1;
for (const auto& invalid : wrong_setup) {
require(native_test_setup(slot, &setup, true) &&
native_test_out(slot, bytes.data(), bytes.size(), true), "interrupted BOOTSEL setup failed");
require(!native_test_setup(slot, &invalid, true), "wrong BOOTSEL setup was accepted");
require(!native_test_in(slot, packet, &length, true) &&
!native_test_out(slot, bytes.data(), bytes.size(), true), "rejected SETUP retained an old BOOTSEL transfer");
require_no_bootsel();
}
// Standard requests do not call the vendor handler: transport ownership
// must still revoke both incomplete DATA and unacknowledged status.
for (bool send_data : {false, true}) {
require(native_test_setup(slot, &setup, true), "interruptible BOOTSEL setup stalled");
if (send_data)
require(native_test_out(slot, bytes.data(), bytes.size(), true), "interruptible BOOTSEL DATA failed");
require(native_test_setup(slot, &replacement, true), "replacement standard request stalled");
require(receive(slot).size() == 2, "replacement standard transfer did not complete");
require(!native_test_in(slot, packet, &length, true) &&
!native_test_out(slot, bytes.data(), bytes.size(), true), "superseded BOOTSEL retained a transfer");
require_no_bootsel();
}
// Reset revokes queued DATA, validated DATA, and even a captured status
// ACK that has not reached the foreground callback yet.
for (unsigned phase : {0, 1, 2}) {
require(native_test_setup(slot, &setup, true) &&
native_test_out(slot, bytes.data(), bytes.size(), phase != 0), "resettable BOOTSEL setup failed");
if (phase == 2) acknowledge(slot, false);
native_test_bus_reset(true);
require(!native_test_in(slot, packet, &length, true), "bus reset retained BOOTSEL status");
require_no_bootsel();
}
}
require(native_test_setup(2, &setup, true) && native_test_out(2, bytes.data(), bytes.size(), true),
"validated child BOOTSEL failed");
acknowledge(2);
probe_bootsel_task(500); probe_bootsel_task(549);
require(bootsel_calls == 0, "BOOTSEL did not retain the post-ACK delay");
probe_bootsel_task(550);
require(bootsel_calls == 1, "validated child BOOTSEL did not reach ROM after the delay");
// Concurrent children must not share the valid envelope or authorization.
for (uint8_t slot : {uint8_t{1}, uint8_t{PROBE_CONTROLLER_COUNT}})
require(native_test_setup(slot, &setup, true), "concurrent BOOTSEL setup failed");
require(native_test_out(1, bytes.data(), bytes.size(), true), "first child's BOOTSEL DATA failed");
auto corrupt = bytes;
corrupt[12] ^= 1;
require(!native_test_out(PROBE_CONTROLLER_COUNT, corrupt.data(), corrupt.size(), true),
"last child inherited its sibling's BOOTSEL authorization");
native_test_bus_reset(true);
require_no_bootsel();
if (reboot_slot == 0) {
require(native_test_startup(), "root-only BOOTSEL startup failed");
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot)
require(!native_test_select(slot), "root-only recovery unexpectedly requires an enumerated child");
} else {
native_test_initialize();
}
require(native_test_setup(reboot_slot, &setup, true), "valid BOOTSEL setup failed");
require_no_bootsel();
require(native_test_out(reboot_slot, bytes.data(), bytes.size(), true), "valid BOOTSEL DATA failed");
require_no_bootsel();
acknowledge(reboot_slot, false);
require_no_bootsel();
// Unlike reset, the next SETUP preserves a genuine, already-captured ACK.
require(native_test_setup(reboot_slot, &replacement, false), "post-ACK SETUP failed");
native_test_drain();
require(receive(reboot_slot).size() == 2, "post-ACK standard transfer failed");
const uint32_t now = bootsel_time_ms + 100;
probe_bootsel_task(now); probe_bootsel_task(now + 49);
require(bootsel_calls == 0, "BOOTSEL did not retain the post-ACK 50ms delay");
probe_bootsel_task(now + 50);
require(bootsel_calls == 1, "validated BOOTSEL did not reach ROM after the delay");
probe_bootsel_task(now + 100);
require(bootsel_calls == 1, "BOOTSEL dispatched more than once");
profile_service_task_on_storage_core(now + 100);
require(programs == programs_before && erases == erases_before,
"private BOOTSEL changed saved profiles");
}
bool flash_read(void*, uint8_t arena, size_t offset, uint8_t* data, size_t size) {
@ -445,7 +830,11 @@ bool bluepad32_input_backend_capture_page(uint32_t, uint16_t, Bluepad32CaptureSn
extern "C" void reset_usb_boot(uint32_t, uint32_t) { ++bootsel_calls; }
extern "C" bool tud_vendor_control_xfer_cb(uint8_t slot, uint8_t stage, const tusb_control_request_t* setup) {
if (probe_management_vendor_control(slot, stage, setup)) return true;
if (slot < 1 || slot > 2 || setup->bmRequestType != 0xc0 ||
// Exercise the full root service over a synthetic four-child transport
// without claiming that the neutral firmware exposes that service.
if (synthetic_root_management && slot == 0 &&
usb_configuration_management_vendor_control(slot, stage, setup)) return true;
if (slot < 1 || slot > PROBE_CONTROLLER_COUNT || setup->bmRequestType != 0xc0 ||
setup->bRequest != 3 || setup->wValue || setup->wIndex) return false;
if (stage == CONTROL_STAGE_ACK && slot == 1 && interleave_identity_ack) {
interleave_identity_ack = false;
@ -456,20 +845,31 @@ extern "C" bool tud_vendor_control_xfer_cb(uint8_t slot, uint8_t stage, const tu
require(native_test_select(0), "read ACK callback blocked servicing the next USB SETUP");
}
return stage != CONTROL_STAGE_SETUP || native_hub_control_xfer(slot, setup,
child_identity[slot - 1].data(), child_identity[slot - 1].size());
child_identity[slot - 1].data(), child_identity[slot - 1].size(), true);
}
int main() {
int main(int argc, char** argv) {
static_assert(sizeof(tusb_control_request_t) == 8);
flash.fill(0xff); child_identity[0].fill(0x31); child_identity[1].fill(0x72);
require(argc == 2 && (std::strcmp(argv[1], "root") == 0 || std::strcmp(argv[1], "child") == 0),
"select the root or last-child BOOTSEL completion scenario");
const uint8_t reboot_slot = std::strcmp(argv[1], "root") == 0 ? 0 : PROBE_CONTROLLER_COUNT;
flash.fill(0xff);
for (unsigned instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance)
child_identity[instance].fill(0x31u + instance * 0x21u);
profile_service_prepare(); profile_service_initialize_on_storage_core();
native_test_initialize();
synthetic_root_management = SWITCH2_PROBE_NEUTRAL_INPUT;
test_profile_transport();
test_interrupted_transactions();
test_pending_control_buffer_ownership();
synthetic_root_management = false;
test_read_ack_allows_usb_progress();
test_private_transmit_survives_round_robin_tokens();
test_masked_irq_completion_handoff();
test_private_bootsel();
std::cout << "native root management packet and persistence regressions passed\n";
test_port_enumeration_and_bounds();
test_child_control_and_receive_isolation();
test_port_reset_revokes_only_its_child_events();
if (SWITCH2_PROBE_NEUTRAL_INPUT) test_neutral_management_surface();
test_private_bootsel(reboot_slot);
std::cout << "native transport, synthetic root management and private BOOTSEL regressions passed\n";
}

View file

@ -0,0 +1,160 @@
#include "hardware_stub.h"
#include <assert.h>
#include <stdio.h>
// The real router tables and token-header decision run on the host. Only the
// clock/pad registers and the SIE bank-selection receiver are modeled here;
// the timing loop is compiled but never run against a simulated USB wire.
#define PICO_RP2350 1
#undef SIO_GPIO_HI_IN_USB_DP_BITS
#undef SIO_GPIO_HI_IN_USB_DM_BITS
#define SIO_GPIO_HI_IN_USB_DP_BITS (1u << 24)
#define SIO_GPIO_HI_IN_USB_DM_BITS (1u << 25)
#define SIO_MTIME_CTRL_EN_BITS 1u
#define SIO_MTIME_CTRL_FULLSPEED_BITS 2u
#define __wfe() ((void)0)
#define __dsb() ((void)0)
#define __isb() ((void)0)
static struct {
volatile uint32_t mtime, mtimeh, mtimecmp, mtimecmph, mtime_ctrl, gpio_hi_in;
} router_test_sio;
#undef sio_hw
#define sio_hw (&router_test_sio)
#include "router.c"
usb_hw_t native_test_usb;
uint32_t native_test_interrupt_mask;
static unsigned selections;
static uint8_t selected_address, selected_owner;
static bool accept_selection = true;
void native_test_service_interrupt(void) {}
bool native_hub_select_device(uint8_t address, uint8_t owner, uint32_t cutoff) {
(void)cutoff;
++selections;
selected_address = address;
selected_owner = owner;
return accept_selection;
}
static const routing_table* current_table(void) {
uint32_t generation;
return acquire_table(&generation);
}
static void expect_route(const routing_table* table, unsigned address, uint8_t owner) {
selections = 0;
raw_packet packet = {0};
route_header(table,address,TOKEN_SETUP_SIGNATURE,127,100,&packet);
uint32_t sequence;
assert(probe_router_setup_slot(&sequence) == owner);
probe_router_stats snapshot;
probe_router_snapshot(&snapshot);
assert(snapshot.last_setup_slot == owner && snapshot.last_setup_sequence == sequence);
if (owner == PROBE_ROUTER_UNASSIGNED) {
assert(selections == 0 && packet.retargets == 0);
} else {
assert(selections == 1 && selected_address == address && selected_owner == owner);
assert(packet.retargets == (address != 127));
}
}
static uint8_t address_wire(unsigned address, unsigned kind) {
// Independent LSB-first NRZI encoder, starting after the token PID's K.
unsigned wire = 0, line = 0, ones = kind ? 3u : 0u, bit_index = 0;
for (unsigned symbol = 0; symbol < 8; ++symbol) {
unsigned bit;
if (ones == 6) {
bit = 0;
} else {
bit = bit_index < 7 ? (address >> bit_index) & 1u : 0u;
++bit_index;
}
if (!bit) line ^= 1u;
wire |= line << symbol;
ones = bit ? ones + 1u : 0u;
}
return wire;
}
static unsigned raw_prefix(uint8_t wire) {
unsigned prefix = 0;
for (unsigned bit = 0; bit < 4; ++bit)
prefix |= ((wire >> bit) & 1u ? LINE_J : LINE_K) << (2u * bit);
return prefix;
}
static void expect_prefixes(const routing_table* table, const uint8_t* addresses) {
for (unsigned kind = 0; kind < 2; ++kind) {
for (uint8_t slot = 0; slot < PROBE_ROUTER_SLOTS; ++slot) {
const uint8_t wire = address_wire(addresses[slot],kind);
const unsigned prefix = raw_prefix(wire);
unsigned matches = 0;
for (uint8_t other = 0; other < PROBE_ROUTER_SLOTS; ++other)
matches += raw_prefix(address_wire(addresses[other],kind)) == prefix;
assert(table->early_address[kind][prefix] ==
(matches == 1 ? addresses[slot] : PROBE_ROUTER_UNASSIGNED));
expect_route(table,address_decoder[kind][wire],slot);
}
}
}
int main(void) {
probe_router_init(FS_CLOCK_HZ);
// Simulate observer readiness, not USB timing; this enables the actual
// routing decision without starting the hardware-bound sampling loop.
counters.ready = 1;
probe_router_enable(true);
const routing_table* table = current_table();
expect_route(table,0,0);
for (unsigned address = 1; address < 128; ++address)
expect_route(table,address,PROBE_ROUTER_UNASSIGNED);
uint8_t addresses[PROBE_ROUTER_SLOTS];
addresses[0] = 9;
for (uint8_t slot = 1; slot < PROBE_ROUTER_SLOTS; ++slot) addresses[slot] = 17u * slot;
probe_router_publish(addresses,PROBE_ROUTER_UNASSIGNED);
table = current_table();
expect_prefixes(table,addresses);
expect_route(table,0,PROBE_ROUTER_UNASSIGNED);
expect_route(table,128,PROBE_ROUTER_UNASSIGNED);
expect_route(table,255,PROBE_ROUTER_UNASSIGNED);
// Every child's address shares the first four symbols. No early owner may
// be guessed, even though the full decoded addresses still route uniquely.
for (uint8_t slot = 1; slot < PROBE_ROUTER_SLOTS; ++slot) addresses[slot] = 1u + 16u * slot;
probe_router_publish(addresses,PROBE_ROUTER_UNASSIGNED);
table = current_table();
expect_prefixes(table,addresses);
// Slot 4 must not collide with the invalid sentinel or sequence carry.
setup_publication = SETUP_SEQUENCE_MASK - 1u;
expect_route(table,addresses[PROBE_ROUTER_SLOTS - 1],PROBE_ROUTER_SLOTS - 1);
uint32_t sequence;
assert(probe_router_setup_slot(&sequence) == PROBE_ROUTER_SLOTS - 1 && sequence == SETUP_SEQUENCE_MASK);
expect_route(table,addresses[PROBE_ROUTER_SLOTS - 1],PROBE_ROUTER_SLOTS - 1);
assert(probe_router_setup_slot(&sequence) == PROBE_ROUTER_SLOTS - 1 && sequence == 0);
expect_route(table,127,PROBE_ROUTER_UNASSIGNED);
assert(probe_router_setup_slot(&sequence) == PROBE_ROUTER_UNASSIGNED && sequence == 0);
accept_selection = false;
selections = 0;
raw_packet packet = {0};
route_header(table,addresses[1],TOKEN_SETUP_SIGNATURE,127,100,&packet);
assert(selections == 1 && packet.retargets == 0 &&
probe_router_setup_slot(&sequence) == PROBE_ROUTER_UNASSIGNED);
accept_selection = true;
addresses[1] = addresses[2];
probe_router_publish(addresses,PROBE_ROUTER_SLOTS - 1);
table = current_table();
expect_route(table,addresses[1],PROBE_ROUTER_UNASSIGNED);
expect_route(table,0,PROBE_ROUTER_SLOTS - 1);
for (unsigned kind = 0; kind < 2; ++kind)
for (unsigned prefix = 0; prefix < 256; ++prefix)
assert(table->early_address[kind][prefix] != addresses[1]);
probe_router_publish(addresses,PROBE_ROUTER_UNASSIGNED);
expect_route(current_table(),0,PROBE_ROUTER_UNASSIGNED);
printf("native router ownership regressions passed for %u slots\n",PROBE_ROUTER_SLOTS);
return 0;
}

View file

@ -0,0 +1,6 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#define uart0 ((void*)0)
bool uart_is_writable(void* uart);
void uart_putc_raw(void* uart, char value);

View file

@ -0,0 +1 @@
#include "hardware_stub.h"

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,7 @@
#include "hardware_stub.h"
#include <stdlib.h>
static uint32_t native_test_time_us = 1000000u;
#define time_us_32() native_test_time_us
#include "usb/native_hub/native_hub.c"
usb_hw_t native_test_usb;
@ -6,6 +9,10 @@ usb_device_dpram_t native_test_dpram;
sio_hw_t native_test_sio;
bool native_test_abort_stuck;
uint32_t native_test_interrupt_mask;
uint32_t native_test_hid_completions[CHILDREN], native_test_bulk_completions[CHILDREN];
uint32_t native_test_received_count[CHILDREN][2];
uint16_t native_test_received_length[CHILDREN][2];
uint8_t native_test_received_data[CHILDREN][2][PACKET];
static bool servicing_interrupt;
void native_test_service_interrupt(void) {
@ -22,7 +29,9 @@ void probe_router_publish(const uint8_t values[PROBE_ROUTER_SLOTS], uint8_t slot
void probe_router_enable(bool enabled) { (void)enabled; }
bool probe_router_set_phase(uint32_t phase) { (void)phase; return true; }
void probe_router_snapshot(probe_router_stats* snapshot) { memset(snapshot,0,sizeof(*snapshot)); snapshot->ready = 1; }
#ifndef NATIVE_TEST_EXTERNAL_LOG
int probe_debug_printf(const char* format, ...) { (void)format; return 0; }
#endif
const uint8_t* native_joycon_device_descriptor(uint8_t instance) { (void)instance; return hub_device; }
const uint8_t* native_joycon_configuration_descriptor(uint8_t instance) { (void)instance; return hub_configuration; }
@ -35,32 +44,66 @@ uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t id, hid_report_type_t t
(void)instance; (void)id; (void)type; (void)data; (void)length; return 0;
}
void tud_hid_set_report_cb(uint8_t instance, uint8_t id, hid_report_type_t type, const uint8_t* data, uint16_t length) {
(void)instance; (void)id; (void)type; (void)data; (void)length;
(void)id; (void)type;
if (instance >= CHILDREN || length > PACKET) abort();
++native_test_received_count[instance][0];
native_test_received_length[instance][0] = length;
if (length) memcpy(native_test_received_data[instance][0],data,length);
}
void tud_hid_report_complete_cb(uint8_t instance, const uint8_t* data, uint16_t length) {
(void)data; (void)length;
if (instance >= CHILDREN) abort();
++native_test_hid_completions[instance];
}
void tud_vendor_rx_cb(uint8_t instance, const uint8_t* data, uint16_t length) {
if (instance >= CHILDREN || length > PACKET) abort();
++native_test_received_count[instance][1];
native_test_received_length[instance][1] = length;
if (length) memcpy(native_test_received_data[instance][1],data,length);
}
void tud_vendor_tx_cb(uint8_t instance, uint32_t length) {
(void)length;
if (instance >= CHILDREN) abort();
++native_test_bulk_completions[instance];
}
void tud_hid_report_complete_cb(uint8_t instance, const uint8_t* data, uint16_t length) { (void)instance; (void)data; (void)length; }
void tud_vendor_rx_cb(uint8_t instance, const uint8_t* data, uint16_t length) { (void)instance; (void)data; (void)length; }
void tud_vendor_tx_cb(uint8_t instance, uint32_t length) { (void)instance; (void)length; }
void native_test_initialize(void) {
memset(devices,0,sizeof(devices));
memset(ports,0,sizeof(ports));
memset(usb_hw,0,sizeof(*usb_hw));
memset(usb_dpram,0,sizeof(*usb_dpram));
memset(native_test_hid_completions,0,sizeof(native_test_hid_completions));
memset(native_test_bulk_completions,0,sizeof(native_test_bulk_completions));
memset(native_test_received_count,0,sizeof(native_test_received_count));
memset(native_test_received_length,0,sizeof(native_test_received_length));
memset(native_test_received_data,0,sizeof(native_test_received_data));
native_test_time_us = 1000000u;
event_head = event_tail = 0;
native_test_abort_stuck = false;
native_test_interrupt_mask = 0;
servicing_interrupt = false;
failed = bus_suspended = bank_restore_pending = false;
failed = bus_suspended = false;
bank_lock = spin_lock_instance(0);
active_device = default_device = 0;
addresses[0] = 0; addresses[1] = 1; addresses[2] = 2;
addresses[0] = 0;
for (unsigned slot = 1; slot < DEVICES; ++slot) addresses[slot] = slot * 17u;
started = root_configured_once = true;
}
bool native_test_startup(void) {
native_test_initialize();
started = root_configured_once = false;
return native_hub_init();
}
void native_test_advance(uint32_t microseconds) {
native_test_time_us += microseconds;
native_hub_task();
}
static bool select_slot(uint8_t slot) {
if (!native_hub_select_device(addresses[slot],slot,UINT32_MAX / 2)) return false;
restore_selected_bank();
return true;
if (slot >= DEVICES || addresses[slot] == NONE) return false;
return native_hub_select_device(addresses[slot],slot,UINT32_MAX / 2);
}
bool native_test_select(uint8_t slot) { return select_slot(slot); }
@ -111,22 +154,42 @@ bool native_test_in(uint8_t slot, uint8_t* data, uint16_t* length, bool drain) {
}
bool native_test_private_in(uint8_t slot, uint8_t endpoint, uint8_t* data, uint16_t* length) {
if (slot < 1 || slot > 2 || (endpoint != 0x81 && endpoint != 0x82)) return false;
if (slot >= DEVICES || addresses[slot] == NONE || (slot == 0 ? endpoint != 0x8f :
(endpoint != 0x81 && endpoint != 0x82))) return false;
// A host token selects the bank, but cannot wait for a foreground task.
if (!native_hub_select_device(addresses[slot],slot,UINT32_MAX / 2)) return false;
unsigned channel = (endpoint & 15u) * 2u;
uint32_t control = endpoint_regs()[channel - 2u];
uint32_t value = buffer_regs()[channel];
unsigned channel = logical_channel(slot,endpoint);
unsigned physical = physical_channel(slot,channel);
uint32_t control = slot == 0 ? usb_dpram->ep_ctrl[14].in : endpoint_regs()[channel - 2u];
uint32_t value = buffer_regs()[physical];
if (!(control & EP_CTRL_ENABLE_BITS) || !(value & USB_BUF_CTRL_AVAIL) ||
!(value & USB_BUF_CTRL_FULL) || (value & USB_BUF_CTRL_STALL)) return false;
*length = value & USB_BUF_CTRL_LEN_MASK;
if (*length > PACKET) return false;
if (*length) copy_from_usb(data,
(const volatile uint8_t*)USBCTRL_DPRAM_BASE + (control & 0xffffu), *length);
buffer_regs()[channel] = value & ~USB_BUF_CTRL_AVAIL;
buffer_regs()[physical] = value & ~USB_BUF_CTRL_AVAIL;
usb_hw->buf_status |= 1u << physical;
usb_hw->ints |= USB_INTS_BUFF_STATUS_BITS;
native_test_service_interrupt();
return !failed;
}
bool native_test_private_out(uint8_t slot, uint8_t endpoint, const uint8_t* data, uint16_t length, bool drain) {
if (slot < 1 || slot >= DEVICES || addresses[slot] == NONE ||
(endpoint != 0x01 && endpoint != 0x02) || length > PACKET) return false;
if (!native_hub_select_device(addresses[slot],slot,UINT32_MAX / 2)) return false;
unsigned channel = logical_channel(slot,endpoint);
uint32_t control = endpoint_regs()[channel - 2u];
uint32_t value = buffer_regs()[channel];
if (!(control & EP_CTRL_ENABLE_BITS) || !(value & USB_BUF_CTRL_AVAIL) ||
(value & USB_BUF_CTRL_STALL)) return false;
if (length) copy_to_usb((volatile uint8_t*)USBCTRL_DPRAM_BASE + (control & 0xffffu),data,length);
buffer_regs()[channel] = (value & ~(USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_LEN_MASK)) | length;
usb_hw->buf_status |= 1u << channel;
usb_hw->ints |= USB_INTS_BUFF_STATUS_BITS;
native_test_service_interrupt();
if (drain) native_hub_task();
return !failed;
}
@ -136,5 +199,6 @@ void native_test_bus_reset(bool drain) {
native_test_service_interrupt();
if (drain) native_hub_task();
// Assign fixture addresses after reset, independently of EP0 state.
addresses[0] = 0; addresses[1] = 1; addresses[2] = 2;
addresses[0] = 0;
for (unsigned slot = 1; slot < DEVICES; ++slot) addresses[slot] = slot * 17u;
}

View file

@ -3,6 +3,7 @@
#include "input/bluepad32_input_backend.h"
#include "input/switch2_mouse_capture.h"
#include "platform/pico/bootsel_pairing_button.h"
#include "platform/pico/system_clock.h"
#include "parser/uni_hid_parser_switch2.h"
#include "pico/stdlib.h"
#include <array>

View file

@ -8,20 +8,42 @@
#include "model.h"
#include "pico/stdlib.h"
#include "platform/pico/bootsel_pairing_button.h"
#include "platform/pico/system_clock.h"
#include "profile/controller_profile_runtime.h"
#include "profile/profile_service.h"
namespace {
uint64_t now_us = 1000000;
uint32_t stage;
Bluepad32NativeGamepadSnapshot source;
ControllerProfile profile;
Bluepad32NativeGamepadSnapshot sources[BLUEPAD32_NATIVE_PAIR_COUNT];
ControllerProfile profiles[BLUEPAD32_NATIVE_PAIR_COUNT];
// Existing single-pair scenarios exercise PairA in both executable configurations.
Bluepad32NativeGamepadSnapshot& source = sources[0];
ControllerProfile& profile = profiles[0];
bool selected[BLUEPAD32_NATIVE_PAIR_COUNT];
uint64_t cue_tokens[PROBE_CONTROLLER_COUNT];
uint64_t next_cue_token;
uint32_t profile_generation = 1;
bool alternating_shortcut;
bool shortcut_phase;
probe_controller_input controls[2];
uint8_t reports[2][63];
bool alternating_shortcuts[BLUEPAD32_NATIVE_PAIR_COUNT];
bool shortcut_phases[BLUEPAD32_NATIVE_PAIR_COUNT];
bool& alternating_shortcut = alternating_shortcuts[0];
bool latching_shortcuts[BLUEPAD32_NATIVE_PAIR_COUNT];
struct SlotShortcut {
bool active = false;
bool latched = false;
uint32_t connection_generation = 0;
};
SlotShortcut slot_shortcuts[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT];
probe_controller_input controls[PROBE_CONTROLLER_COUNT];
uint8_t reports[PROBE_CONTROLLER_COUNT][63];
uint8_t source_pair(uint8_t slot) {
for (uint8_t pair_index = 0; pair_index < BLUEPAD32_NATIVE_PAIR_COUNT; ++pair_index)
if (sources[pair_index].controller.active && sources[pair_index].slot == slot) return pair_index;
assert(false);
return 0;
}
} // namespace
uint32_t time_us_32() { return static_cast<uint32_t>(now_us); }
absolute_time_t get_absolute_time() { return now_us; }
@ -34,25 +56,58 @@ void bluepad32_input_backend_start() { stage = 2; }
void bluepad32_input_backend_poll() {}
void bluepad32_input_backend_diagnostics(Bluepad32BackendDiagnostics* out) { *out = {}; out->initialization_stage = stage; }
void bluepad32_input_backend_open_pairing_window() {}
void bluepad32_input_backend_select_native_source(const uint8_t*) {}
void bluepad32_input_backend_native_snapshot(Bluepad32NativeGamepadSnapshot* out) { *out = source; }
bool bluepad32_input_backend_native_sample_request(uint8_t, uint8_t, uint64_t*) { return false; }
int bluepad32_input_backend_native_sample_result(uint8_t, uint64_t) { return -1; }
void bluepad32_input_backend_native_sample_cancel(uint8_t) {}
void bluepad32_input_backend_select_native_source(uint8_t pair_index, const uint8_t*) {
assert(pair_index < BLUEPAD32_NATIVE_PAIR_COUNT);
selected[pair_index] = true;
}
void bluepad32_input_backend_native_snapshot(uint8_t pair_index, Bluepad32NativeGamepadSnapshot* out) {
assert(pair_index < BLUEPAD32_NATIVE_PAIR_COUNT);
*out = selected[pair_index] ? sources[pair_index] : Bluepad32NativeGamepadSnapshot{};
}
bool bluepad32_input_backend_native_sample_request(uint8_t instance, uint8_t, uint64_t* token) {
if (instance >= PROBE_CONTROLLER_COUNT || !sources[instance / 2].controller.active || !token) return false;
*token = cue_tokens[instance] = ++next_cue_token;
return true;
}
int bluepad32_input_backend_native_sample_result(uint8_t instance, uint64_t token) {
return instance < PROBE_CONTROLLER_COUNT && token && cue_tokens[instance] == token ? 1 : -1;
}
void bluepad32_input_backend_native_sample_cancel(uint8_t instance) {
assert(instance < PROBE_CONTROLLER_COUNT);
cue_tokens[instance] = 0;
}
void bluepad32_input_backend_queue_profile_feedback(uint8_t, uint32_t, uint8_t, ControllerProfileConfirmationPolicy) {}
void controller_profile_runtime_reset() { profile = controller_profile_default(controller_identity_global(), 0); }
void controller_profile_runtime_reset() {
for (ControllerProfile& value : profiles)
value = controller_profile_default(controller_identity_global(), 0);
}
uint32_t profile_service_database_generation() { return profile_generation; }
bool controller_profile_runtime_take_initial_profile_indication(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; }
bool controller_profile_runtime_take_profile_change(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; }
ControllerProfileTransformResult controller_profile_runtime_transform(
uint8_t, const Bluepad32SlotSnapshot& input, uint32_t, AdapterUsbMode) {
if (!input.active) return {};
auto result = controller_profile_transform(input.state, profile);
if (alternating_shortcut) {
uint8_t slot, const Bluepad32SlotSnapshot& input, uint32_t, AdapterUsbMode) {
if (!input.active) {
slot_shortcuts[slot] = {};
return {};
}
const uint8_t pair_index = source_pair(slot);
auto result = controller_profile_transform(input.state, profiles[pair_index]);
if (alternating_shortcuts[pair_index]) {
// Model a runtime synthetic transition spanning the two halves. Two
// evaluations for one paired report would expose contradictory states.
shortcut_phase = !shortcut_phase;
result.state.button_system = result.state.button_capture = shortcut_phase;
shortcut_phases[pair_index] = !shortcut_phases[pair_index];
result.state.button_system = result.state.button_capture = shortcut_phases[pair_index];
}
if (latching_shortcuts[pair_index]) {
// Model a macro/Shift latch owned by a runtime SLOT, not a USB pair.
auto& shortcut = slot_shortcuts[slot];
if (!shortcut.active || shortcut.connection_generation != input.connection_generation) {
shortcut = {};
shortcut.active = true;
shortcut.connection_generation = input.connection_generation;
}
if (input.state.button_select) shortcut.latched = true;
result.state.button_system = result.state.button_capture = shortcut.latched;
}
return result;
}
@ -92,16 +147,21 @@ void quaternion(uint8_t instance, double out[4]) {
out[largest] = 1 / sqrt(norm);
for (unsigned i = 0; i < 3; ++i) out[(largest + i + 1) & 3] = ratios[i] * out[largest];
}
void publish(bool motion = true) {
now_us += 4000;
source.received_us = time_us_32();
++source.state_generation;
void publish_at_current_time(uint8_t pair_index, bool motion) {
Bluepad32NativeGamepadSnapshot& snapshot = sources[pair_index];
snapshot.received_us = time_us_32();
++snapshot.state_generation;
if (motion) {
source.accel_received_us = source.gyro_received_us = time_us_32();
++source.accel_sequence;
++source.gyro_sequence;
snapshot.accel_received_us = snapshot.gyro_received_us = time_us_32();
++snapshot.accel_sequence;
++snapshot.gyro_sequence;
}
}
void publish(bool motion = true, uint8_t pair_index = 0) {
now_us += 4000;
publish_at_current_time(pair_index, motion);
}
uint32_t peek(uint8_t instance) {
probe_controller_input_poll(instance, now_ms(), &controls[instance]);
return probe_controller_input_peek_native_report(instance, now_ms(), reports[instance]);
@ -110,7 +170,7 @@ void consume(uint8_t instance) {
const uint32_t token = peek(instance);
assert(token && probe_controller_input_commit_native_report(instance, token));
}
void pair() { consume(0); consume(1); }
void pair(uint8_t pair_index = 0) { consume(pair_index * 2); consume(pair_index * 2 + 1); }
void no_mouse_or_rails() {
for (unsigned i = 0; i < 2; ++i) {
assert((reports[i][3] & 0xc0) == 0);
@ -673,15 +733,301 @@ void solo_motion_rotates_coherently_and_resets_frame() {
}
}
#if PROBE_CONTROLLER_COUNT == 4
void publish_both(bool motion = true) {
now_us += 4000;
publish_at_current_time(0, motion);
publish_at_current_time(1, motion);
}
void prepare_two_sources(bool motion) {
for (uint8_t pair_index = 0; pair_index < BLUEPAD32_NATIVE_PAIR_COUNT; ++pair_index) {
auto& snapshot = sources[pair_index];
const uint32_t connection_generation = snapshot.controller.connection_generation + 1;
snapshot = {};
snapshot.slot = pair_index;
snapshot.controller.active = true;
snapshot.controller.connection_generation = connection_generation;
snapshot.controller.identity = controller_identity_global();
snapshot.accel_valid = snapshot.gyro_valid = motion;
snapshot.accel_q13[1] = 8192;
profiles[pair_index] = controller_profile_default(controller_identity_global(), 0);
alternating_shortcuts[pair_index] = false;
}
++profile_generation;
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
probe_controller_input_set_native_stream(instance, true);
calibrate(instance, 2048, 2048, 1000, 1000, 1000, 1000);
}
publish_both(motion);
pair(0);
pair(1);
}
void two_pair_controls_and_profile_coherence() {
prepare_two_sources(false);
auto& a = sources[0].controller.state;
auto& b = sources[1].controller.state;
a.button_south = a.dpad_up = true;
a.button_left_shoulder = a.button_right_shoulder = true;
b.button_east = b.dpad_down = true;
sources[0].battery = 255;
sources[1].battery = 0;
publish_both(false); pair(0); pair(1);
assert(reports[0][2] == 0x11 && reports[1][2] == 0x18);
assert(reports[2][2] == 0x02 && reports[3][2] == 0x01);
assert(reports[0][1] == 0x25 && reports[1][1] == 0x25);
assert(reports[2][1] == 0x01 && reports[3][1] == 0x01);
a.button_left_shoulder = a.button_right_shoulder = false;
b.button_left_shoulder = b.button_right_shoulder = true;
publish_both(false); pair(1); pair(0);
assert(reports[0][2] == 0x01 && reports[1][2] == 0x08);
assert(reports[2][2] == 0x12 && reports[3][2] == 0x11); // Real L+R only on PairB.
// Digital mapped-left movement after swapping feeds only A's solo frame.
// B independently inverts its physical left stick, then swaps it to right.
a = {}; b = {};
a.dpad_up = a.button_south = true;
a.left_stick_x = INT16_MAX;
profiles[0].button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
profiles[0].native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
profiles[0].swap_sticks = true;
b.dpad_down = true;
b.left_stick_y = INT16_MAX;
profiles[1].sticks[0].invert_y = true;
profiles[1].swap_sticks = true;
++profile_generation;
publish_both(false);
consume(0); pair(1); inactive_child(1);
assert(reports[0][2] == 0x02 && stick_x(0) == 1048 && stick_y(0) == 2048);
assert(reports[2][2] == 0 && stick_x(2) == 2048 && stick_y(2) == 3048);
assert(reports[3][2] == 0x01 && stick_x(3) == 2048 && stick_y(3) == 2048);
profiles[0].native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
++profile_generation;
pair(1); consume(1); inactive_child(0);
assert(reports[1][2] == 0x04 && stick_x(1) == 3048 && stick_y(1) == 2048);
assert(reports[2][2] == 0 && stick_y(2) == 3048 && reports[3][2] == 0x01);
// A and B may select different solo sides without neutralizing each other.
profiles[1].native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
b.right_stick_x = INT16_MAX;
++profile_generation;
publish_both(false);
consume(2); consume(1); inactive_child(0); inactive_child(3);
assert(stick_x(1) == 3048 && stick_y(1) == 2048);
assert(stick_x(2) == 2048 && stick_y(2) == 3048);
a = {}; b = {};
profiles[0] = profiles[1] = controller_profile_default(controller_identity_global(), 0);
++profile_generation;
alternating_shortcuts[0] = alternating_shortcuts[1] = true;
shortcut_phases[0] = false;
shortcut_phases[1] = true;
for (unsigned round = 0; round < 4; ++round) {
publish_both(false);
consume(0); consume(2); consume(1); consume(3);
assert(reports[0][3] == reports[1][3] && reports[2][3] == reports[3][3]);
assert(reports[0][3] != reports[2][3]);
}
const uint8_t a_before = reports[0][3], b_before = reports[2][3];
// A's same-millisecond publication must re-evaluate A, not B; alternating
// slot-local transitions make both duplicate and missing evaluations visible.
publish_at_current_time(0, false);
consume(0); consume(2); consume(1); consume(3);
assert(reports[0][3] != a_before && reports[0][3] == reports[1][3]);
assert(reports[2][3] == b_before && reports[2][3] == reports[3][3]);
alternating_shortcuts[0] = alternating_shortcuts[1] = false;
}
void two_pair_transport_and_disconnect_isolation() {
prepare_two_sources(true);
sources[0].controller.state.button_south = true;
sources[1].controller.state.button_north = true;
publish_both();
uint32_t pending[PROBE_CONTROLLER_COUNT];
uint64_t cues[PROBE_CONTROLLER_COUNT];
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
pending[instance] = peek(instance);
assert(pending[instance]);
assert(bluepad32_input_backend_native_sample_request(instance, 1, &cues[instance]));
}
assert(!probe_controller_input_commit_native_report(0, pending[2]));
uint8_t saved_b[2][63];
memcpy(saved_b, reports + 2, sizeof(saved_b));
sources[0].controller.active = false;
// Recheck the actual owning source, even before any poll sees its loss.
assert(!probe_controller_input_commit_native_report(0, pending[0]));
assert(!probe_controller_input_commit_native_report(1, pending[1]));
inactive_child(0); inactive_child(1);
for (uint8_t instance = 0; instance < 2; ++instance)
assert(bluepad32_input_backend_native_sample_result(instance, cues[instance]) == -1);
for (uint8_t instance = 2; instance < 4; ++instance) {
assert(bluepad32_input_backend_native_sample_result(instance, cues[instance]) == 1);
assert(peek(instance) == pending[instance]);
assert(memcmp(saved_b[instance - 2], reports[instance], 63) == 0);
assert(probe_controller_input_commit_native_report(instance, pending[instance]));
}
const uint32_t b_pending = peek(2);
sources[0].controller.active = true;
++sources[0].controller.connection_generation;
publish(true, 0); pair(0);
assert(reports[0][2] == 0x01 && reports[2][2] == 0x08);
assert(!probe_controller_input_commit_native_report(0, pending[0]));
assert(probe_controller_input_commit_native_report(2, b_pending));
// Repeated updates on three endpoints must neither consume a blocked
// endpoint's counter nor starve the other source's two endpoints.
publish_both();
const uint32_t blocked_left = peek(1);
const uint8_t left_counter = reports[1][0];
const uint32_t blocked_b = peek(2);
const uint8_t b_counter = reports[2][0];
for (unsigned update = 0; update < 40; ++update) {
sources[1].controller.state.button_east = (update & 1u) != 0;
publish_both(); consume(0); pair(1);
}
assert(!probe_controller_input_commit_native_report(1, blocked_left));
assert(!probe_controller_input_commit_native_report(2, blocked_b));
consume(1);
assert(reports[1][0] == left_counter);
assert(reports[2][0] == static_cast<uint8_t>(b_counter + 39));
assert(reports[2][2] == 0x0a);
probe_controller_input_set_native_stream(0, false);
assert(!peek(0));
publish_both();
const uint32_t left_pending = peek(1);
pair(1);
assert(probe_controller_input_commit_native_report(1, left_pending));
probe_controller_input_set_native_stream(0, true);
consume(0);
assert(reports[0][2] == 0x01);
// USB suspension also remains child-local on PairB.
publish_both();
const uint32_t a_pending = peek(0), b_left_pending = peek(3);
probe_controller_input_set_native_stream(2, false);
assert(!peek(2));
assert(probe_controller_input_commit_native_report(0, a_pending));
assert(probe_controller_input_commit_native_report(3, b_left_pending));
assert(bluepad32_input_backend_native_sample_result(2, cues[2]) == -1);
assert(bluepad32_input_backend_native_sample_result(3, cues[3]) == 1);
probe_controller_input_set_native_stream(2, true);
const uint32_t expires = peek(0);
// B stays live while A's queued report expires, then A's source times out.
for (unsigned update = 0; update < 26; ++update) { publish(true, 1); pair(1); }
assert(!probe_controller_input_commit_native_report(0, expires));
for (unsigned update = 0; update < 100; ++update) { publish(true, 1); pair(1); }
const uint32_t surviving_b = peek(2);
inactive_child(0); inactive_child(1);
assert(probe_controller_input_commit_native_report(2, surviving_b));
assert(controls[2].active && controls[3].active && reports[2][2] == 0x0a);
}
void two_pair_motion_provenance_and_resets() {
prepare_two_sources(true);
const uint8_t side = (SWITCH2_BRIDGE_IMU_TARGET_MASK & 1) ? 0 : 1;
const uint8_t a_imu = side, b_imu = 2 + side;
sources[0].gyro_q10[1] = 90 * 1024;
sources[1].gyro_q10[1] = -45 * 1024;
for (unsigned sample = 0; sample < 250; ++sample) {
publish_both();
consume(0); consume(2); consume(1); consume(3);
}
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
const bool enabled = (SWITCH2_BRIDGE_IMU_TARGET_MASK & (1u << (instance & 1u))) != 0;
assert(imu_length(instance) == (enabled ? 30 : 0));
}
double a[4], b[4];
quaternion(a_imu, a); quaternion(b_imu, b);
assert(fabs(fabs(a[0]) - sqrt(.5)) < .015);
assert(fabs(fabs(b[0]) - cos(3.141592653589793 / 8)) < .015);
assert(a[3] * b[3] < 0); // Opposite physical yaw cannot share one integrator.
if (SWITCH2_BRIDGE_IMU_TARGET_MASK == 3) {
assert(memcmp(reports[0] + probe_model_imu_data_offset(0),
reports[1] + probe_model_imu_data_offset(1), 30) == 0);
assert(memcmp(reports[2] + probe_model_imu_data_offset(2),
reports[3] + probe_model_imu_data_offset(3), 30) == 0);
}
sources[0].gyro_q10[1] = sources[1].gyro_q10[1] = 0;
publish_both(); pair(0); pair(1);
quaternion(b_imu, b);
const uint8_t* b_block = reports[b_imu] + probe_model_imu_data_offset(b_imu);
const uint32_t b_ticks = bits(b_block, 0, 12);
publish(false, 1); pair(1);
publish(true, 0); pair(0);
pair(1);
assert(imu_length(2) == 0 && imu_length(3) == 0); // A cannot manufacture a B sample.
const uint32_t pending_a = peek(a_imu);
sources[0].controller.active = false;
inactive_child(0); inactive_child(1);
sources[0].controller.active = true;
++sources[0].controller.connection_generation;
publish(true, 0); pair(0);
assert(!probe_controller_input_commit_native_report(a_imu, pending_a));
quaternion(a_imu, a);
assert(fabs(fabs(a[0]) - 1) < 1e-6); // Only A reconnects at identity heading.
publish(true, 1); pair(1);
double after[4]; quaternion(b_imu, after);
for (unsigned axis = 0; axis < 4; ++axis) assert(fabs(after[axis] - b[axis]) < 1e-6);
b_block = reports[b_imu] + probe_model_imu_data_offset(b_imu);
assert(bits(b_block, 12, 12) == ((bits(b_block, 0, 12) - b_ticks) & 0xfffu));
// Reframing A to solo must not reset B's heading or in-flight motion.
profiles[0].native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
++profile_generation;
publish_both();
const uint32_t b_pending = peek(b_imu);
uint8_t saved[63]; memcpy(saved, reports[b_imu], sizeof(saved));
consume(1); inactive_child(0);
assert(peek(b_imu) == b_pending && memcmp(saved, reports[b_imu], sizeof(saved)) == 0);
assert(probe_controller_input_commit_native_report(b_imu, b_pending));
quaternion(b_imu, after);
for (unsigned axis = 0; axis < 4; ++axis) assert(fabs(after[axis] - b[axis]) < 1e-6);
}
void recycled_slot_preserves_the_new_pairs_runtime() {
prepare_two_sources(false);
const uint32_t old_a = peek(0);
// A disconnects without another poll. B reconnects into A's recycled
// physical slot and starts a held synthetic action before A sees its loss.
sources[0].controller.active = false;
sources[1].slot = sources[0].slot;
++sources[1].controller.connection_generation;
sources[1].controller.state.button_select = true;
latching_shortcuts[1] = true;
publish(false, 1); pair(1);
assert(reports[2][3] == 1 && reports[3][3] == 1);
sources[1].controller.state.button_select = false;
publish(false, 1); pair(1);
const uint32_t pending_b = peek(2);
inactive_child(0); inactive_child(1);
assert(!probe_controller_input_commit_native_report(0, old_a));
assert(probe_controller_input_commit_native_report(2, pending_b));
// The next evaluation exposes accidental inactive-transform retirement;
// checking only the already-cached report would miss that runtime reset.
publish(false, 1); pair(1);
assert(reports[2][3] == 1 && reports[3][3] == 1);
sources[0].slot = 1;
sources[0].controller.active = true;
++sources[0].controller.connection_generation;
publish(false, 0); pair(0);
publish(false, 1); pair(1);
assert(reports[2][3] == 1 && reports[3][3] == 1);
latching_shortcuts[1] = false;
}
#endif
} // namespace
int main() {
assert(!probe_controller_input_peek_native_report(0, now_ms(), reports[0]));
probe_controller_input_init();
assert(probe_controller_input_start());
probe_controller_input_set_native_stream(0, true);
probe_controller_input_set_native_stream(1, true);
assert(!peek(0) && !peek(1));
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
probe_controller_input_set_native_stream(instance, true);
assert(!peek(instance));
}
mapped_halves_and_calibration();
independent_backpressure_and_resets();
if (SWITCH2_BRIDGE_IMU_TARGET_MASK == 3) real_motion_admission_and_loss();
@ -692,5 +1038,11 @@ int main() {
profile_changes_retire_tokens_without_source_publication();
digital_dpad_reaches_the_mapped_left_stick();
solo_motion_rotates_coherently_and_resets_frame();
#if PROBE_CONTROLLER_COUNT == 4
two_pair_controls_and_profile_coherence();
two_pair_transport_and_disconnect_isolation();
two_pair_motion_provenance_and_resets();
recycled_slot_preserves_the_new_pairs_runtime();
#endif
return 0;
}

View file

@ -41,7 +41,12 @@ static void test_descriptors(void) {
((uint16_t)probe_device_descriptor[11] << 8);
assert(product_id == (SWITCH2_PROBE_JOYCON_LEFT ? 0x2067 : 0x2066));
assert(probe_configuration_descriptor[2] == sizeof(probe_configuration_descriptor));
assert(probe_configuration_descriptor[4] == 2 * PROBE_CONTROLLER_COUNT);
const unsigned functions = SWITCH2_PROBE_COMPOSITE ? 2 : 1;
assert(probe_configuration_descriptor[4] == 2 * functions);
#if SWITCH2_PROBE_HUB
assert((probe_left_device_descriptor[10] |
((uint16_t)probe_left_device_descriptor[11] << 8)) == 0x2067);
#endif
unsigned interface_count = 0, endpoint_count = 0;
unsigned interface = 0, seen_endpoints = 0;
for (size_t offset = 9; offset < sizeof(probe_configuration_descriptor);) {
@ -67,8 +72,8 @@ static void test_descriptors(void) {
}
offset += descriptor[0];
}
assert(interface_count == 2 * PROBE_CONTROLLER_COUNT);
assert(endpoint_count == 4 * PROBE_CONTROLLER_COUNT);
assert(interface_count == 2 * functions);
assert(endpoint_count == 4 * functions);
// Read HID short items as a host would: each function advertises only its
// own native report plus common 05, with sizes matching report generation.
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
@ -91,7 +96,11 @@ static void test_descriptors(void) {
case 0x90: output_bits[report_id] += report_size * report_count; break;
}
}
const bool is_left = SWITCH2_PROBE_COMPOSITE ? instance == 1 : SWITCH2_PROBE_JOYCON_LEFT;
const bool is_left = (SWITCH2_PROBE_COMPOSITE || SWITCH2_PROBE_HUB) ?
(instance & 1u) != 0 : SWITCH2_PROBE_JOYCON_LEFT;
assert(probe_model_is_left(instance) == is_left);
assert(probe_model_pid(instance) == (is_left ? 0x2067 : 0x2066));
assert(probe_model_report_id(instance) == (is_left ? 7 : 8));
probe_protocol_state state;
probe_protocol_reset(&state, is_left);
initialize(&state);
@ -435,43 +444,50 @@ static void test_interleaved_reports_and_features(void) {
}
static void test_interleaved_callbacks_and_pairing(void) {
const uint8_t addresses[2][6] = {
enum { count = PROBE_CONTROLLER_COUNT > 2 ? PROBE_CONTROLLER_COUNT : 2 };
const uint8_t addresses[4][6] = {
{0x64, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
{0x65, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
{0x66, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
{0x67, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
};
controller_context controllers[2] = {
{.expected_sample = 3, .source_available = true,
.source_token = UINT64_C(0x100000001), .storage_available = true},
{.expected_sample = 3, .source_available = false,
.source_token = UINT64_C(0x200000001), .storage_available = false},
};
probe_protocol_state states[2];
for (unsigned side = 0; side < 2; ++side) {
probe_protocol_reset(&states[side], side != 0);
states[side].context = &controllers[side];
states[side].play_sample = play_sample;
states[side].save_pairing = save_pairing;
memcpy(states[side].controller_address, addresses[side], 6);
controller_context controllers[count];
memset(controllers, 0, sizeof(controllers));
probe_protocol_state states[count];
for (unsigned instance = 0; instance < count; ++instance) {
controllers[instance].expected_sample = 3;
controllers[instance].source_available = instance != 1;
controllers[instance].storage_available = instance != 1;
controllers[instance].source_token = ((uint64_t)(instance + 1) << 32) | 1;
probe_protocol_reset(&states[instance], (instance & 1u) != 0);
states[instance].context = &controllers[instance];
states[instance].play_sample = play_sample;
states[instance].save_pairing = save_pairing;
memcpy(states[instance].controller_address, addresses[instance], 6);
}
uint8_t reply[PROBE_REPLY_MAX_SIZE];
uint8_t cue_replies[2][8];
uint64_t tokens[2] = {0, UINT64_MAX};
assert(probe_protocol_command(&states[0], sample_command, sizeof(sample_command),
cue_replies[0], 8, &tokens[0]) == 8);
assert(probe_protocol_command(&states[1], sample_command, sizeof(sample_command),
cue_replies[1], 8, &tokens[1]) == 0);
assert(tokens[0] == UINT64_C(0x100000001) && tokens[1] == 0);
controllers[1].source_available = true;
assert(probe_protocol_command(&states[1], sample_command, sizeof(sample_command),
cue_replies[1], 8, &tokens[1]) == 8);
assert(tokens[0] == UINT64_C(0x100000001) && tokens[1] == UINT64_C(0x200000001));
uint64_t tokens[count];
const uint8_t cue_ack[] = {0x0a, 1, 0, 2, 0, 0xf8, 0, 0};
assert(memcmp(cue_replies[0], cue_ack, 8) == 0);
assert(memcmp(cue_replies[1], cue_ack, 8) == 0);
for (unsigned instance = 0; instance < count; ++instance) {
tokens[instance] = UINT64_MAX;
if (instance == 1) {
assert(probe_protocol_command(&states[instance], sample_command, sizeof(sample_command),
reply, sizeof(reply), &tokens[instance]) == 0);
assert(tokens[instance] == 0);
controllers[instance].source_available = true;
}
assert(probe_protocol_command(&states[instance], sample_command, sizeof(sample_command),
reply, sizeof(reply), &tokens[instance]) == sizeof(cue_ack));
assert(memcmp(reply, cue_ack, sizeof(cue_ack)) == 0);
for (unsigned previous = 0; previous <= instance; ++previous)
assert(tokens[previous] == (((uint64_t)(previous + 1) << 32) | 1));
}
const uint8_t hosts[2][16] = {
const uint8_t hosts[4][16] = {
{0x15, 0x91, 0, 1, 0, 8, 0, 0, 0, 1, 1, 2, 3, 4, 5, 6},
{0x15, 0x91, 0, 1, 0, 8, 0, 0, 0, 1, 7, 8, 9, 10, 11, 12},
{0x15, 0x91, 0, 1, 0, 8, 0, 0, 0, 1, 13, 14, 15, 16, 17, 18},
{0x15, 0x91, 0, 1, 0, 8, 0, 0, 0, 1, 19, 20, 21, 22, 23, 24},
};
const uint8_t device_component[] = {
0x5c, 0xf6, 0xee, 0x79, 0x2c, 0xdf, 0x05, 0xe1,
@ -483,69 +499,69 @@ static void test_interleaved_callbacks_and_pairing(void) {
{0x66, 0xe9, 0x4b, 0xd4, 0xef, 0x8a, 0x2c, 0x3b,
0x88, 0x4c, 0xfa, 0x59, 0xca, 0x34, 0x2b, 0x2e},
};
uint8_t challenges[2][25] = {
{0x15, 0x91, 0, 2, 0, 17, 0, 0, 0},
{0x15, 0x91, 0, 2, 0, 17, 0, 0, 0},
};
uint8_t challenges[count][25];
const uint8_t finalize[] = {0x15, 0x91, 0, 3, 0, 1, 0, 0, 0};
for (unsigned side = 0; side < 2; ++side) {
assert(probe_protocol_command(&states[side], hosts[side], sizeof(hosts[side]),
for (unsigned instance = 0; instance < count; ++instance) {
assert(probe_protocol_command(&states[instance], hosts[instance], sizeof(hosts[instance]),
reply, sizeof(reply), NULL) == 17);
assert(memcmp(reply + 11, addresses[side], 6) == 0);
}
for (unsigned side = 0; side < 2; ++side) {
assert(memcmp(reply + 11, addresses[instance], 6) == 0);
uint8_t key[] = {0x15, 0x91, 0, 4, 0, 17, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
memcpy(challenges[instance], key, sizeof(key));
challenges[instance][3] = 2;
for (unsigned i = 0; i < 16; ++i) {
// R uses AES's 000102...0f / 001122...ff vector; L uses all zeros.
key[9 + i] = device_component[i] ^ (side ? 0 : 15u - i);
challenges[side][9 + i] = side ? 0 : (uint8_t)((15u - i) * 0x11u);
key[9 + i] = device_component[i] ^ ((instance & 1u) ? 0 : 15u - i);
challenges[instance][9 + i] = (instance & 1u) ? 0 : (uint8_t)((15u - i) * 0x11u);
}
assert(probe_protocol_command(&states[side], key, sizeof(key),
assert(probe_protocol_command(&states[instance], key, sizeof(key),
reply, sizeof(reply), NULL) == 25);
}
assert(probe_protocol_command(&states[0], challenges[0], sizeof(challenges[0]),
reply, sizeof(reply), NULL) == 25);
assert(memcmp(reply + 9, ciphertexts[0], 16) == 0);
// Right confirmation cannot authorize the left's finalize.
assert(probe_protocol_command(&states[1], finalize, sizeof(finalize),
reply, sizeof(reply), NULL) == 0);
assert(controllers[0].saves == 0 && controllers[1].saves == 0);
assert(probe_protocol_command(&states[1], challenges[1], sizeof(challenges[1]),
reply, sizeof(reply), NULL) == 25);
assert(memcmp(reply + 9, ciphertexts[1], 16) == 0);
assert(probe_protocol_command(&states[0], finalize, sizeof(finalize),
reply, sizeof(reply), NULL) == 9);
assert(reply[8] == 1);
assert(probe_protocol_command(&states[1], finalize, sizeof(finalize),
reply, sizeof(reply), NULL) == 0);
assert(controllers[0].saves == 1 && controllers[1].saves == 0);
controllers[1].storage_available = true;
assert(probe_protocol_command(&states[1], finalize, sizeof(finalize),
reply, sizeof(reply), NULL) == 9);
assert(reply[8] == 1);
assert(controllers[0].saves == 1 && controllers[1].saves == 1);
for (unsigned instance = 0; instance < count; ++instance) {
assert(probe_protocol_command(&states[instance], challenges[instance], sizeof(challenges[instance]),
reply, sizeof(reply), NULL) == 25);
assert(memcmp(reply + 9, ciphertexts[instance & 1u], 16) == 0);
// A confirmation cannot authorize any sibling, including the same-side
// child in the other pair. A failed durable save cannot be acknowledged.
for (unsigned pending = instance + 1; pending < count; ++pending)
assert(probe_protocol_command(&states[pending], finalize, sizeof(finalize),
reply, sizeof(reply), NULL) == 0);
if (instance == 1) {
assert(probe_protocol_command(&states[instance], finalize, sizeof(finalize),
reply, sizeof(reply), NULL) == 0);
assert(controllers[instance].saves == 0);
controllers[instance].storage_available = true;
}
assert(probe_protocol_command(&states[instance], finalize, sizeof(finalize),
reply, sizeof(reply), NULL) == 9);
assert(reply[8] == 1);
for (unsigned sibling = 0; sibling < count; ++sibling)
assert(controllers[sibling].saves == (unsigned)(sibling <= instance));
}
// After independent resets, each durable record must resume only its own
// challenge association; swapping the two contexts' records is rejected.
for (unsigned side = 0; side < 2; ++side) {
probe_protocol_reset(&states[side], side != 0);
memcpy(states[side].controller_address, addresses[side], 6);
assert(!probe_protocol_restore_pairing(&states[side], controllers[1 - side].pairing_blob,
PROBE_PAIRING_BLOB_SIZE));
assert(probe_protocol_restore_pairing(&states[side], controllers[side].pairing_blob,
// Each durable record resumes only its own identity and host association.
for (unsigned instance = 0; instance < count; ++instance) {
probe_protocol_reset(&states[instance], (instance & 1u) != 0);
memcpy(states[instance].controller_address, addresses[instance], 6);
for (unsigned sibling = 0; sibling < count; ++sibling) {
if (sibling == instance) continue;
assert(!probe_protocol_restore_pairing(&states[instance], controllers[sibling].pairing_blob,
PROBE_PAIRING_BLOB_SIZE));
}
assert(probe_protocol_restore_pairing(&states[instance], controllers[instance].pairing_blob,
PROBE_PAIRING_BLOB_SIZE));
}
for (unsigned side = 0; side < 2; ++side) {
assert(probe_protocol_command(&states[side], hosts[1 - side], sizeof(hosts[0]),
for (unsigned instance = 0; instance < count; ++instance) {
const unsigned sibling = (instance + (count == 4 ? 2 : 1)) % count;
assert(probe_protocol_command(&states[instance], hosts[sibling], sizeof(hosts[sibling]),
reply, sizeof(reply), NULL) == 17);
assert(probe_protocol_command(&states[side], challenges[side], sizeof(challenges[side]),
assert(probe_protocol_command(&states[instance], challenges[instance], sizeof(challenges[instance]),
reply, sizeof(reply), NULL) == 0);
assert(probe_protocol_command(&states[side], hosts[side], sizeof(hosts[side]),
assert(probe_protocol_command(&states[instance], hosts[instance], sizeof(hosts[instance]),
reply, sizeof(reply), NULL) == 17);
assert(probe_protocol_command(&states[side], challenges[side], sizeof(challenges[side]),
assert(probe_protocol_command(&states[instance], challenges[instance], sizeof(challenges[instance]),
reply, sizeof(reply), NULL) == 25);
assert(memcmp(reply + 9, ciphertexts[side], 16) == 0);
assert(memcmp(reply + 9, ciphertexts[instance & 1u], 16) == 0);
}
}
@ -556,39 +572,104 @@ static bool read_memory(void* context, uint32_t address, uint8_t* output, size_t
static void test_indexed_memory(void) {
probe_protocol_state states[PROBE_CONTROLLER_COUNT];
uint8_t instances[PROBE_CONTROLLER_COUNT];
const uint8_t addresses[4][6] = {
{0x64, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
{0x65, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
{0x66, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
{0x67, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
};
const uint8_t versions[4][12] = {
{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},
{13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24},
{25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36},
{37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48},
};
uint8_t reports[PROBE_CONTROLLER_COUNT][PROBE_INPUT_SIZE];
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
instances[instance] = instance;
probe_protocol_reset(&states[instance], probe_model_is_left(instance));
states[instance].context = &instances[instance];
states[instance].read_memory = read_memory;
memcpy(states[instance].controller_address, addresses[instance], 6);
states[instance].firmware_version = versions[instance];
uint8_t calibration[9];
assert(probe_memory_stick_calibration(instance, calibration));
memcpy(states[instance].stick_center, calibration, 3);
initialize(&states[instance]);
set_features(&states[instance], 2, 0x17);
set_features(&states[instance], 4, 0x17);
states[instance].report_counter = 0x21 + instance;
}
const uint8_t calibrations[2][9] = {
{0x10, 0x08, 0x81, 0, 3, 0x30, 0, 4, 0x40}, // Valid user override.
{0, 0x09, 0x90, 0, 3, 0x30, 0, 4, 0x40}, // Invalid user, factory fallback.
};
const uint8_t firmware_query[] = {0x10, 0x91, 0, 1, 0, 0, 0, 0};
const uint8_t address_query[] = {0x15, 0x91, 0, 1, 0, 0, 0, 0};
const uint8_t command[] = {
0x02, 0x91, 0, 4, 0, 8, 0, 0, 9, 0x7e, 0, 0, 0xa8, 0x30, 1, 0,
};
for (unsigned remaining = PROBE_CONTROLLER_COUNT; remaining; --remaining) {
const uint8_t instance = (uint8_t)(remaining - 1);
const bool is_left = SWITCH2_PROBE_COMPOSITE ? instance == 1 : SWITCH2_PROBE_JOYCON_LEFT;
const bool is_left = (SWITCH2_PROBE_COMPOSITE || SWITCH2_PROBE_HUB) ?
(instance & 1u) != 0 : SWITCH2_PROBE_JOYCON_LEFT;
const uint8_t pair = instance / 2;
uint8_t reply[PROBE_REPLY_MAX_SIZE], calibration[9];
assert(probe_memory_stick_calibration(instance, calibration));
assert(memcmp(calibration, calibrations[is_left], sizeof(calibration)) == 0);
const uint8_t expected_calibration[] = {
(uint8_t)((is_left ? 0 : 0x10) + pair * 0x20),
is_left ? 9 : 8, is_left ? 0x90 : 0x81, 0, 3, 0x30, 0, 4, 0x40,
};
assert(memcmp(calibration, expected_calibration, sizeof(calibration)) == 0);
assert(probe_protocol_command(&states[instance], command, sizeof(command),
reply, sizeof(reply), NULL) == 25);
const uint8_t factory[] = {0, is_left ? 9 : 8, is_left ? 0x90 : 0x80, 0, 3, 0x30, 0, 4, 0x40};
const uint8_t factory[] = {
(uint8_t)(pair * 0x20), is_left ? 9 : 8, is_left ? 0x90 : 0x80,
0, 3, 0x30, 0, 4, 0x40,
};
assert(memcmp(reply + 16, factory, sizeof(factory)) == 0);
assert(probe_protocol_command(&states[instance], firmware_query, sizeof(firmware_query),
reply, sizeof(reply), NULL) == 20);
assert(memcmp(reply + 8, versions[instance], 12) == 0);
assert(probe_protocol_command(&states[instance], address_query, sizeof(address_query),
reply, sizeof(reply), NULL) == 17);
assert(memcmp(reply + 11, addresses[instance], 6) == 0);
// No source is present: enabling features must not invent input or cue ACKs.
uint64_t token = UINT64_MAX;
assert(probe_protocol_command(&states[instance], sample_command, sizeof(sample_command),
reply, sizeof(reply), &token) == 0);
assert(token == 0);
uint8_t expected[PROBE_INPUT_SIZE] = {0};
expected[0] = (uint8_t)(0x21 + instance);
expected[1] = 0x25;
expected[4] = 7;
memcpy(expected + 5, expected_calibration, 3);
assert(probe_protocol_report(&states[instance], is_left ? 7 : 8,
reports[instance], PROBE_INPUT_SIZE) == PROBE_INPUT_SIZE);
assert(memcmp(reports[instance], expected, sizeof(expected)) == 0);
const uint32_t ends[] = {0x14fff, 0x1fcfff};
for (unsigned region = 0; region < 2; ++region) {
uint8_t output[2] = {0xa5, 0xa5};
assert(!probe_memory_read(instance, ends[region], output, sizeof(output)));
assert(output[0] == 0xa5 && output[1] == 0xa5);
assert(probe_memory_read(instance, ends[region], output, 1));
assert(output[0] == (uint8_t)((region ? 0xf1 : 0xe1) + is_left));
assert(output[0] == (uint8_t)((region ? 0xf1 : 0xe1) + is_left + pair * 2));
assert(output[1] == 0xa5);
}
}
// Reset each child in turn: the remaining children's complete wire snapshots
// and captured identity queries must remain unchanged, including same-side peers.
for (uint8_t reset = 0; reset < PROBE_CONTROLLER_COUNT; ++reset) {
probe_protocol_reset(&states[reset], probe_model_is_left(reset));
uint8_t output[PROBE_REPLY_MAX_SIZE];
assert(probe_protocol_report(&states[reset], probe_model_report_id(reset),
output, sizeof(output)) == 0);
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
if (instance <= reset) continue;
assert(probe_protocol_report(&states[instance], probe_model_report_id(instance),
output, sizeof(output)) == PROBE_INPUT_SIZE);
assert(memcmp(output, reports[instance], PROBE_INPUT_SIZE) == 0);
assert(probe_protocol_command(&states[instance], address_query, sizeof(address_query),
output, sizeof(output), NULL) == 17);
assert(memcmp(output + 11, addresses[instance], 6) == 0);
}
}
uint8_t output[9];
memset(output, 0xa5, sizeof(output));
const uint8_t invalid[] = {PROBE_CONTROLLER_COUNT, UINT8_MAX};

View file

@ -0,0 +1,11 @@
#pragma once
#include <cstddef>
#include <cstdint>
#define FLASH_SECTOR_SIZE 4096u
#define FLASH_PAGE_SIZE 256u
#define PICO_FLASH_SIZE_BYTES (2u * 1024u * 1024u)
void flash_range_erase(uint32_t offset, size_t count);
void flash_range_program(uint32_t offset, const uint8_t* data, size_t count);

View file

@ -0,0 +1,6 @@
#pragma once
#include "hardware/flash.h"
#define PICO_FLASH_BANK_TOTAL_SIZE (2u * FLASH_SECTOR_SIZE)
#define PICO_FLASH_BANK_STORAGE_OFFSET (PICO_FLASH_SIZE_BYTES - PICO_FLASH_BANK_TOTAL_SIZE)

View file

@ -0,0 +1,7 @@
#pragma once
#include <cstdint>
constexpr int PICO_OK = 0;
int flash_safe_execute(void (*function)(void*), void* parameter,
uint32_t enter_exit_timeout_ms);

View file

@ -0,0 +1,8 @@
#pragma once
#include "hardware/flash.h"
extern "C" {
extern uint8_t probe_test_flash[PICO_FLASH_SIZE_BYTES];
}
#define XIP_BASE (reinterpret_cast<uintptr_t>(probe_test_flash))

View file

@ -0,0 +1,296 @@
#include "storage.h"
#include "protocol.h"
#include "configuration/configuration_storage.h"
#include "profile/profile_storage.h"
#include "hardware/flash.h"
#include "pico/btstack_flash_bank.h"
#include "pico/flash.h"
#include "pico/platform.h"
#include <algorithm>
#include <array>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <limits>
#include <vector>
extern "C" {
alignas(FLASH_SECTOR_SIZE) uint8_t probe_test_flash[PICO_FLASH_SIZE_BYTES];
}
namespace {
constexpr size_t kBankSize = 2 * FLASH_SECTOR_SIZE;
constexpr uint32_t kProfileOffset = PICO_FLASH_BANK_STORAGE_OFFSET -
CONFIGURATION_STORAGE_COPY_COUNT * FLASH_SECTOR_SIZE - PROFILE_STORAGE_TOTAL_SIZE;
constexpr uint32_t kReservedOffset = kProfileOffset -
(PROBE_CONTROLLER_COUNT > 2 ? PROBE_CONTROLLER_COUNT : 2) * kBankSize;
using Blob = std::array<uint8_t, PROBE_PAIRING_BLOB_SIZE>;
using Blobs = std::array<Blob, PROBE_CONTROLLER_COUNT>;
using Image = std::vector<uint8_t>;
struct Mutation {
uint32_t offset;
size_t size;
bool erase;
};
std::vector<Mutation> mutations;
int safe_calls;
int fail_at = -1;
size_t torn_bytes;
size_t mutation_limit = std::numeric_limits<size_t>::max();
bool inside_safe;
bool fault_hit;
void reset_fault() {
mutations.clear();
safe_calls = 0;
fail_at = -1;
fault_hit = false;
}
Image image() {
return Image(probe_test_flash, probe_test_flash + sizeof(probe_test_flash));
}
void restore(const Image& saved) {
std::memcpy(probe_test_flash, saved.data(), saved.size());
reset_fault();
}
Blob blob(uint8_t instance, unsigned generation) {
Blob result;
for (size_t i = 0; i < result.size(); ++i)
result[i] = static_cast<uint8_t>(instance * 31 + generation * 83 + i * 7);
return result;
}
void expect_blob(uint8_t instance, const Blob& expected) {
Blob result;
result.fill(0xa5);
assert(probe_storage_load(instance, result.data(), result.size()));
assert(result == expected);
}
void expect_siblings(uint8_t target, const Blobs& expected) {
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance)
if (instance != target) expect_blob(instance, expected[instance]);
}
void expect_outside_unchanged(uint8_t target, const Image& before) {
const uint32_t offset = probe_storage_offset(target);
assert(std::memcmp(probe_test_flash, before.data(), offset) == 0);
assert(std::memcmp(probe_test_flash + offset + kBankSize,
before.data() + offset + kBankSize,
sizeof(probe_test_flash) - offset - kBankSize) == 0);
}
void erase_fixture() {
reset_fault();
// Non-erased sentinels protect firmware, profiles, configuration and BTstack.
std::memset(probe_test_flash, 0xa5, sizeof(probe_test_flash));
std::memset(probe_test_flash + kReservedOffset, 0xff, kProfileOffset - kReservedOffset);
}
Blobs seed() {
erase_fixture();
Blobs expected;
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
expected[instance] = blob(instance, 1);
assert(probe_storage_save(instance, expected[instance].data(), expected[instance].size()));
}
reset_fault();
return expected;
}
void test_offsets_and_isolation() {
// These are the pre-experiment R/L offsets for the 2 MiB stub geometry.
const uint32_t original_offsets[] = {0x1ba000, 0x1b8000, 0x1b6000, 0x1b4000};
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
const unsigned bank = PROBE_CONTROLLER_COUNT == 1 ? SWITCH2_PROBE_JOYCON_LEFT : instance;
assert(probe_storage_offset(instance) == original_offsets[bank]);
}
assert(probe_storage_offset(PROBE_CONTROLLER_COUNT) == UINT32_MAX);
assert(probe_storage_offset(UINT8_MAX) == UINT32_MAX);
auto expected = seed();
for (unsigned generation = 2; generation <= 3; ++generation) {
for (unsigned remaining = PROBE_CONTROLLER_COUNT; remaining; --remaining) {
const uint8_t instance = static_cast<uint8_t>(remaining - 1);
const auto before = image();
expected[instance] = blob(instance, generation);
assert(probe_storage_save(instance, expected[instance].data(), expected[instance].size()));
expect_blob(instance, expected[instance]);
expect_siblings(instance, expected);
expect_outside_unchanged(instance, before);
reset_fault();
assert(probe_storage_save(instance, expected[instance].data(), expected[instance].size()));
assert(mutations.empty()); // Identical saves must not wear flash.
}
}
Blob output;
output.fill(0xa5);
const Blob untouched = output;
const auto before = image();
assert(!probe_storage_load(PROBE_CONTROLLER_COUNT, output.data(), output.size()));
assert(!probe_storage_save(PROBE_CONTROLLER_COUNT, output.data(), output.size()));
assert(!probe_storage_load(UINT8_MAX, output.data(), output.size()));
assert(!probe_storage_save(UINT8_MAX, output.data(), output.size()));
assert(!probe_storage_load(0, output.data(), output.size() - 1));
assert(output == untouched);
assert(image() == before);
assert(mutations.empty());
}
void test_interrupted_updates() {
for (uint8_t target = 0; target < PROBE_CONTROLLER_COUNT; ++target) {
// An erased inactive slot needs only programming. A reused inactive slot
// must first erase its old owned record; cover both atomic transitions.
for (bool reuse : {false, true}) {
auto expected = seed();
if (reuse) {
expected[target] = blob(target, 2);
assert(probe_storage_save(target, expected[target].data(), expected[target].size()));
}
const auto before = image();
const Blob replacement = blob(target, 3);
reset_fault();
assert(probe_storage_save(target, replacement.data(), replacement.size()));
const auto successful_mutations = mutations;
assert(!successful_mutations.empty());
for (size_t cut = 0; cut < successful_mutations.size(); ++cut) {
const Mutation interrupted = successful_mutations[cut];
const size_t partials[] = {0, 1, FLASH_PAGE_SIZE / 2, interrupted.size};
for (size_t partial : partials) {
restore(before);
fail_at = static_cast<int>(cut);
torn_bytes = partial;
assert(!probe_storage_save(target, replacement.data(), replacement.size()));
assert(fault_hit);
expect_outside_unchanged(target, before);
expect_siblings(target, expected);
Blob recovered;
assert(probe_storage_load(target, recovered.data(), recovered.size()));
// A fully programmed commit may survive despite an ambiguous
// flash-safe return. Only the complete old OR new blob is legal.
assert(recovered == expected[target] || recovered == replacement);
// A torn owner/erase cannot prove ownership and must refuse
// further writes. Complete ownership allows body/commit recovery.
const bool unknown = interrupted.erase ?
partial != 0 && partial < interrupted.size :
interrupted.offset % FLASH_SECTOR_SIZE == 0 && partial != 0 && partial < 40;
const auto after_failure = image();
reset_fault();
const bool saved = probe_storage_save(target, replacement.data(), replacement.size());
assert(saved != unknown);
if (unknown) {
assert(mutations.empty());
assert(image() == after_failure);
} else {
expect_blob(target, replacement);
}
expect_siblings(target, expected);
expect_outside_unchanged(target, before);
if (unknown && PROBE_CONTROLLER_COUNT > 1) {
const uint8_t sibling = (target + 1) % PROBE_CONTROLLER_COUNT;
const auto before_sibling = image();
const Blob sibling_replacement = blob(sibling, 4);
assert(probe_storage_save(sibling, sibling_replacement.data(), sibling_replacement.size()));
expect_blob(sibling, sibling_replacement);
expect_outside_unchanged(sibling, before_sibling);
}
}
}
}
}
}
void test_unknown_sectors() {
for (uint8_t target = 0; target < PROBE_CONTROLLER_COUNT; ++target) {
for (unsigned slot = 0; slot < 2; ++slot) {
const auto expected = seed();
// Neither an arbitrary sector nor a record copied from a different
// absolute bank may be claimed just because another child owns it.
const uint32_t offset = probe_storage_offset(target) + slot * FLASH_SECTOR_SIZE;
if (slot == 1 && PROBE_CONTROLLER_COUNT > 1) {
const uint8_t sibling = (target + 1) % PROBE_CONTROLLER_COUNT;
std::memcpy(probe_test_flash + offset,
probe_test_flash + probe_storage_offset(sibling), FLASH_SECTOR_SIZE);
} else {
probe_test_flash[offset] ^= 0x55;
}
const auto before = image();
const Blob replacement = blob(target, 2);
assert(!probe_storage_save(target, replacement.data(), replacement.size()));
assert(mutations.empty());
assert(image() == before);
Blob output;
output.fill(0xa5);
const Blob untouched = output;
if (slot == 0) {
assert(!probe_storage_load(target, output.data(), output.size()));
assert(output == untouched);
} else {
expect_blob(target, expected[target]);
}
expect_siblings(target, expected);
}
}
}
void test_reserved_range_overlap() {
erase_fixture();
const auto before = image();
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
Blob output;
output.fill(0xa5);
const Blob untouched = output;
assert(!probe_storage_load(instance, output.data(), output.size()));
assert(output == untouched);
assert(!probe_storage_save(instance, output.data(), output.size()));
}
assert(mutations.empty());
assert(image() == before);
}
} // namespace
void flash_range_erase(uint32_t offset, size_t count) {
assert(inside_safe);
assert(offset % FLASH_SECTOR_SIZE == 0 && count == FLASH_SECTOR_SIZE);
assert(offset <= PICO_FLASH_SIZE_BYTES && count <= PICO_FLASH_SIZE_BYTES - offset);
mutations.push_back({offset, count, true});
std::memset(probe_test_flash + offset, 0xff, std::min(count, mutation_limit));
}
void flash_range_program(uint32_t offset, const uint8_t* data, size_t count) {
assert(inside_safe);
assert(offset % FLASH_PAGE_SIZE == 0 && count == FLASH_PAGE_SIZE);
assert(offset <= PICO_FLASH_SIZE_BYTES && count <= PICO_FLASH_SIZE_BYTES - offset);
mutations.push_back({offset, count, false});
for (size_t i = 0; i < std::min(count, mutation_limit); ++i)
probe_test_flash[offset + i] &= data[i];
}
int flash_safe_execute(void (*function)(void*), void* parameter, uint32_t timeout_ms) {
assert(timeout_ms != 0 && !inside_safe);
const bool fail = safe_calls++ == fail_at;
mutation_limit = fail ? torn_bytes : std::numeric_limits<size_t>::max();
fault_hit |= fail;
inside_safe = true;
function(parameter);
inside_safe = false;
return fail ? -1 : PICO_OK;
}
int main() {
if (PROBE_TEST_STORAGE_OVERLAP) {
test_reserved_range_overlap();
} else {
test_offsets_and_isolation();
test_interrupted_updates();
test_unknown_sectors();
}
std::puts("switch2 probe pairing storage tests passed");
return 0;
}

View file

@ -8,7 +8,10 @@ import pytest
@pytest.mark.parametrize("source", ("GAMEPAD", "DUALSENSE"))
def test_native_gamepad_backend_native(tmp_path: Path, source: str) -> None:
@pytest.mark.parametrize("controller_count", (2, 4))
def test_native_gamepad_backend_native(
tmp_path: Path, source: str, controller_count: int
) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
@ -39,6 +42,7 @@ def test_native_gamepad_backend_native(tmp_path: Path, source: str) -> None:
"-DSWITCH_PICO_ENABLE_CLASSIC=1",
"-DSWITCH2_BRIDGE_FULL_INPUT=1",
f"-DSWITCH2_BRIDGE_{source}_INPUT=1",
f"-DPROBE_CONTROLLER_COUNT={controller_count}",
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
f"-I{firmware}",
f"-I{root / 'bluepad32_config'}",
@ -49,19 +53,22 @@ def test_native_gamepad_backend_native(tmp_path: Path, source: str) -> None:
check=True,
cwd=root,
)
for scenario in (
"stable-logical-slot",
"source-isolation",
"cue-lifetime",
"cue-races",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)
scenarios = ["stable-logical-slot", "cue-lifetime", "cue-races"]
if controller_count == 2:
scenarios.append("source-isolation")
else:
scenarios.extend(("two-pair-sources", "two-pair-cues", "explicit-precedence"))
if source == "GAMEPAD":
for scenario in (
"sensorless-admission",
"independent-motion",
"paired-source",
"pair-cue-races",
"mono-rumble",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)
scenarios.extend(("paired-source", "pair-cue-races", "mono-rumble"))
if controller_count == 2:
scenarios.extend(("sensorless-admission", "independent-motion"))
else:
scenarios.extend(
(
"paired-explicit-conflict",
"topology-reservations",
"stable-ble-reservation",
)
)
for scenario in scenarios:
subprocess.run([str(executable), scenario], check=True, cwd=root)

View file

@ -0,0 +1,53 @@
from __future__ import annotations
import shutil
import signal
import subprocess
from pathlib import Path
def test_native_logger_keeps_usb_interrupt_progress(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"
(tmp_path / "probe_version.h").write_text(
"static const uint8_t probe_version_replies[PROBE_CONTROLLER_COUNT][16] = {{0}};\n"
"static const uint8_t probe_firmware_versions[PROBE_CONTROLLER_COUNT][12] = {{0}};\n"
)
executable = tmp_path / "native_hub_log_test"
subprocess.run(
[
compiler,
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
"-ffunction-sections",
"-fdata-sections",
"-DSWITCH2_PROBE_HUB=1",
"-DPROBE_CONTROLLER_COUNT=4",
"-DSWITCH2_PROBE_NEUTRAL_INPUT=1",
"-DSWITCH2_PROBE_TRACE_NATIVE_INPUT=1",
"-DSWITCH2_PROBE_USB_INIT=1",
"-DSWITCH2_PROBE_MEMORY=1",
"-DSWITCH2_PROBE_VERSION_REPLY=1",
f"-I{root / 'tests' / 'native_hub_stubs'}",
f"-I{root / 'src' / 'firmware'}",
f"-I{root / 'tools' / 'switch2_usb_probe'}",
f"-I{tmp_path}",
str(root / "tests" / "native_hub_log_test.c"),
"-Wl,--gc-sections",
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)
for caller in ("core", "irq"):
rejected = subprocess.run(
[str(executable), caller], capture_output=True, check=False, cwd=root
)
assert rejected.returncode == -signal.SIGABRT, (
"unsafe concurrent log producer was accepted"
)

View file

@ -2,8 +2,17 @@ import shutil
import subprocess
from pathlib import Path
import pytest
def test_native_hub_management_native(tmp_path: Path) -> None:
@pytest.mark.parametrize(
("controller_count", "neutral_input"),
[(2, False), (2, True), (4, True)],
ids=["native-management", "neutral-one-pair", "neutral-two-pair"],
)
def test_native_hub_management_native(
tmp_path: Path, controller_count: int, neutral_input: bool
) -> None:
root = Path(__file__).resolve().parents[1]
cc = shutil.which("cc") or shutil.which("gcc")
cxx = shutil.which("c++") or shutil.which("g++")
@ -15,7 +24,17 @@ def test_native_hub_management_native(tmp_path: Path) -> None:
f"-I{root / 'tools' / 'pico_usb_address_probe'}",
f"-I{root / 'tools' / 'switch2_usb_probe'}",
]
flags = ["-Wall", "-Wextra", "-Werror", "-pedantic", "-DSWITCH2_PROBE_HUB=1"]
flags = [
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
"-ffunction-sections",
"-fdata-sections",
"-DSWITCH2_PROBE_HUB=1",
f"-DPROBE_CONTROLLER_COUNT={controller_count}",
]
flags.append(f"-DSWITCH2_PROBE_NEUTRAL_INPUT={int(neutral_input)}")
transport = tmp_path / "native_hub_transport.o"
executable = tmp_path / "native_hub_management_test"
subprocess.run(
@ -48,6 +67,7 @@ def test_native_hub_management_native(tmp_path: Path) -> None:
"-std=c++17",
*flags,
*includes,
"-Wl,--gc-sections",
*(str(root / path) for path in sources),
str(transport),
"-o",
@ -56,4 +76,20 @@ def test_native_hub_management_native(tmp_path: Path) -> None:
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)
for reboot_slot in ("root", "child"):
subprocess.run([str(executable), reboot_slot], check=True, cwd=root)
router_executable = tmp_path / "native_hub_router_test"
subprocess.run(
[
cc,
"-std=c11",
*flags,
*includes,
str(root / "tests" / "native_hub_router_test.c"),
"-o",
str(router_executable),
],
check=True,
cwd=root,
)
subprocess.run([str(router_executable)], check=True, cwd=root)

View file

@ -0,0 +1,95 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import pytest
@pytest.mark.parametrize("controller_count", [2, 4], ids=["one-pair", "two-pair"])
def test_native_hub_trace_lifecycle(tmp_path: Path, controller_count: int) -> 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 / "native_hub_trace_test"
subprocess.run(
[
compiler,
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
"-ffunction-sections",
"-fdata-sections",
"-DSWITCH2_PROBE_HUB=1",
"-DSWITCH2_PROBE_TRACE_NATIVE_INPUT=1",
f"-DPROBE_CONTROLLER_COUNT={controller_count}",
f"-I{root / 'tests' / 'native_hub_stubs'}",
f"-I{root / 'src' / 'firmware'}",
f"-I{root / 'tools' / 'pico_usb_address_probe'}",
f"-I{root / 'tools' / 'switch2_usb_probe'}",
str(root / "tests" / "native_hub_trace_test.c"),
"-Wl,--gc-sections",
"-o",
str(executable),
],
check=True,
cwd=root,
)
for scenario in (
"live-wrap",
"root-idle",
"queue-pressure",
"pending",
"superseded",
"immediate-supersession",
"poll-retention",
"selection-history",
"frozen-selection-history",
"delayed-publication",
"publication-isolation",
"publication-wrap-supersession",
"ep0-handover",
"coherent-publication",
"bulk-commit-pids",
"approved-status-handoff",
"approved-status-superseded",
"approved-status-reset",
"approved-status-reset-during-completion",
"approved-status-port-reset-ready",
"approved-status-port-reset-queued",
"approved-status-invalid-length",
"approved-status-watch",
"status-out-rejected-data",
"status-out",
"status-out-superseded",
"status-out-stale",
"status-out-reset",
"status-out-reset-watch",
"status-out-port-reset-watch",
"marker-zero",
"marker-active",
"marker-rejected",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)
for mode in ("waiting", "partial"):
subprocess.run([str(executable), "marker-priority", mode], check=True, cwd=root)
# Identical snapshots must reach the consumer in identical order even when
# every individual header, record, and END is rejected twice by the logger.
outputs = []
for mode in ("open", "full"):
result = subprocess.run(
[str(executable), "backpressure", mode],
capture_output=True,
text=True,
check=True,
cwd=root,
)
outputs.append(result.stdout)
assert outputs[0] == outputs[1], (
"logger backpressure skipped, reordered, or changed dump lines"
)

View file

@ -0,0 +1,245 @@
from __future__ import annotations
import json
import struct
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
@pytest.fixture
def live_rig(monkeypatch, tmp_path):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "tools"))
import native_joycon_hub_check as check
from switch2_native_imu import encode_mode0
rig = SimpleNamespace(check=check, clock=0.0, activity="independent")
monkeypatch.setattr(check, "time", SimpleNamespace(monotonic=lambda: rig.clock))
def configure(pairs=2, mode="GAMEPAD", target="BOTH", neutral=False):
cache = [
f"SWITCH2_PROBE_PAIR_COUNT:STRING={pairs}",
"SWITCH2_PROBE_HUB:BOOL=ON",
"SWITCH2_PROBE_USB_INIT:BOOL=ON",
f"SWITCH2_PROBE_NEUTRAL_INPUT:BOOL={'ON' if neutral else 'OFF'}",
f"SWITCH_PICO_SWITCH2_USB_BRIDGE:BOOL={'OFF' if neutral else 'ON'}",
f"SWITCH2_BRIDGE_INPUT:STRING={mode}",
f"SWITCH2_BRIDGE_IMU_TARGET:STRING={target}",
]
for index, (child, model) in enumerate(check.child_models(pairs).items()):
identity = bytearray(64)
identity[0] = index + 1
struct.pack_into("<HH", identity, 18, check.VID, model["pid"])
version = bytearray(12)
version[3] = int(model["side"] == "R")
factory = identity + bytearray(8192 - len(identity))
for field, contents in (
("IDENTITY_FILE", identity),
("VERSION_FILE", version),
("FACTORY_FILE", factory),
):
path = tmp_path / f"{child}-{field}.bin"
path.write_bytes(contents)
cache.append(f"{model['capture_prefix']}_{field}:FILEPATH={path}")
cache.append(
f"{model['capture_prefix']}_CONTROLLER_ADDRESS:STRING=02:00:00:00:00:{index + 1:02x}"
)
(tmp_path / "CMakeCache.txt").write_text("\n".join(cache))
return tmp_path
class InputPipe:
def __init__(self, scenario, child):
self.scenario = scenario
self.model = scenario.models[child]
self.frame = 0
def read(self, endpoint, length, *, timeout):
rig.clock += 0.02
self.frame += 1
pair = int(self.model["pair"] == "B")
cycle = self.frame % 4
missing = pair and (
rig.activity == "missing"
or (
rig.activity == "disconnect"
and self.scenario.current_stage == "active_input_and_read_isolation"
)
)
payload = bytearray(63)
payload[0] = self.frame % 256
payload[5:8] = b"\x00\x08\x80"
if not missing:
controls_pair = 0 if rig.activity == "mirrored" else pair
payload[2] = (1 << (cycle + controls_pair * 4)) if cycle else 0
payload[5] = controls_pair * 32 + cycle
if not self.scenario.args.input_only:
motion_pair = 0 if rig.activity == "mirrored" else pair
motion_cycle = 0 if rig.activity == "static" else cycle
block = encode_mode0(
self.frame % 4096,
4,
[1.0, 0.0, 0.0, 0.0],
[motion_pair + motion_cycle / 8, 0.0, 1.0],
25,
)
offset = 14 if self.model["side"] == "L" else 15
payload[offset] = len(block)
payload[offset + 1 : offset + 1 + len(block)] = block
return bytes((self.model["report"],)) + payload
def discover(scenario):
scenario.devices = {
child: InputPipe(scenario, child) for child in scenario.children
}
monkeypatch.setattr(check.Check, "discover", discover)
# Only the physical USB boundary is replaced; reference parsing, packet
# decoding, readiness, active evidence, failure handling and JSON all run.
for method in (
"permissions",
"claim",
"descriptors",
"identities",
"initialize",
"queries",
"cleanup",
):
monkeypatch.setattr(check.Check, method, lambda *args, **kwargs: None)
def run(*, pairs=2, mode="GAMEPAD", input_only=False, activity="independent"):
rig.clock = 0.0
rig.activity = activity
capture = tmp_path / "qualification.json"
monkeypatch.setattr(
sys,
"argv",
[
"native_joycon_hub_check.py",
"--build-dir",
str(configure(pairs, mode)),
"--output",
str(capture),
"--duration",
"2",
*(["--pairs", str(pairs)] if pairs != 1 else []),
*(["--input-only"] if input_only else []),
],
)
status = check.main()
return status, json.loads(capture.read_text())
rig.configure = configure
rig.run = run
return rig
@pytest.mark.parametrize(
("mode", "input_only"), [("GAMEPAD", True), ("DUALSENSE", False)]
)
def test_two_live_pairs_qualify_distinct_activity_on_all_children(
live_rig, mode, input_only
):
status, audit = live_rig.run(mode=mode, input_only=input_only)
assert status == 0, audit.get("failure", audit["errors"])
children = ("A_R", "A_L", "B_R", "B_L")
assert tuple(audit["child_results"]) == children
assert not audit["gameplay_proven"]
assert not audit["physical_latency_proven"]
assert not audit["physical_source_isolation_proven"]
for child in children:
result = audit["child_results"][child]
assert result["qualified"] and result["live_input_proven"]
assert result["live_imu_proven"] is not input_only
assert result["last_sample"]["packet_hex"].startswith(
"08" if child.endswith("R") else "07"
)
assert f"{child}=" in audit["summary"]
if not input_only:
for pair in ("A", "B"):
evidence = audit["imu_isolation"]["pairs"][pair]
assert evidence["policy"] == "shared_physical_source"
assert evidence["identical_blocks_seen_on_both_sides"] >= 2
@pytest.mark.parametrize(
("input_only", "activity"), [(True, "missing"), (False, "disconnect")]
)
def test_unassigned_or_disconnected_second_pair_cannot_qualify(
live_rig, input_only, activity
):
status, audit = live_rig.run(input_only=input_only, activity=activity)
assert status == 2
assert not audit["success"]
assert all(
not child["live_input_proven"] for child in audit["child_results"].values()
)
if activity == "missing":
assert audit["streams"]["B_R"]["buttons_nonzero"] == 0
assert audit["streams"]["B_L"]["control_changes"] == 0
else:
assert {error["side"] for error in audit["errors"]} >= {"B_R", "B_L"}
@pytest.mark.parametrize(
("input_only", "activity"),
[(True, "mirrored"), (False, "mirrored"), (False, "static")],
)
def test_equal_or_static_pair_evidence_is_not_independent_activity(
live_rig, input_only, activity
):
status, audit = live_rig.run(input_only=input_only, activity=activity)
assert status == 2
assert not audit["physical_source_isolation_proven"]
assert {error["side"] for error in audit["errors"]} >= {"A_R", "A_L", "B_R", "B_L"}
def test_default_one_pair_keeps_right_left_capture_ids(live_rig):
status, audit = live_rig.run(pairs=1, input_only=True)
assert status == 0, audit["errors"]
assert tuple(audit["child_results"]) == ("R", "L")
assert [child["port"] for child in audit["child_results"].values()] == [1, 2]
def test_two_pair_input_only_accepts_side_target_but_imu_requires_both(live_rig):
build = live_rig.configure(target="LEFT")
assert tuple(
live_rig.check.model_references(build, pairs=2, require_imu=False)
) == ("A_R", "A_L", "B_R", "B_L")
with pytest.raises(ValueError):
live_rig.check.model_references(build, pairs=2, require_imu=True)
def test_two_pair_live_rejects_donor_only_and_neutral_builds(live_rig):
for settings in ({"mode": "JOYCON2"}, {"neutral": True}):
build = live_rig.configure(**settings)
with pytest.raises(ValueError):
live_rig.check.model_references(build, pairs=2, require_imu=False)
def test_neutral_and_input_only_are_exclusive_before_capture(
live_rig, monkeypatch, tmp_path
):
capture = tmp_path / "incompatible.json"
monkeypatch.setattr(
sys,
"argv",
[
"native_joycon_hub_check.py",
"--pairs",
"2",
"--neutral",
"--input-only",
"--output",
str(capture),
],
)
with pytest.raises(SystemExit) as error:
live_rig.check.main()
assert error.value.code == 2
assert not capture.exists()

View file

@ -0,0 +1,315 @@
from __future__ import annotations
import json
import os
import struct
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
import usb.core
import usb.util
class Root:
idVendor = 0x057E
idProduct = 0x2068
bus = 2
def __init__(self, address=7, ports=(3, 4), serial="switch-pico-test"):
self.address = address
self.port_numbers = ports
self.serial = self.cached_serial = serial
self.transfers = []
self.bootsel_requested = False
self.write_result = 16
def ctrl_transfer(self, request_type, request, value, index, data, *, timeout):
self.transfers.append((request_type, request, value, index, data))
assert timeout > 0
if request_type == 0x80:
assert request == 6, "recovery must only read root identity descriptors"
if value == 0x0100:
assert index == 0 and data == 18
descriptor = bytearray(18)
descriptor[:2] = b"\x12\x01"
descriptor[4] = 9
descriptor[16] = 3
struct.pack_into("<HH", descriptor, 8, self.idVendor, self.idProduct)
return descriptor
if value == 0x0300:
assert index == 0 and data == 255
return b"\x04\x03\x09\x04"
assert value == 0x0303 and index == 0x0409 and data == 255
serial = self.serial.encode("utf-16-le")
return bytes((len(serial) + 2, 3)) + serial
assert (request_type, request, value, index) == (0x40, 4, 0x5350, 1)
assert data == b"SPMG\x01\x04\x00\x00" + bytes(8)
assert not self.bootsel_requested, "recovery must never retry a reboot request"
self.bootsel_requested = True
if isinstance(self.write_result, Exception):
raise self.write_result
return self.write_result
def __getattr__(self, name):
raise AssertionError(f"forbidden USB operation during recovery: {name}")
@pytest.fixture
def recovery_rig(monkeypatch, tmp_path):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "tools"))
import native_joycon_hub_check as check
root = Root()
rom = SimpleNamespace(
bus=root.bus,
address=8,
port_numbers=root.port_numbers,
idVendor=0x2E8A,
idProduct=0x000F,
)
rig = SimpleNamespace(
check=check,
root=root,
rom=rom,
devices=[root],
after_reboot=[rom],
acl=[],
disposed=[],
clock=0.0,
capture=tmp_path / "recovery.json",
)
def sleep(seconds):
rig.clock += seconds
monkeypatch.setattr(
check, "time", SimpleNamespace(monotonic=lambda: rig.clock, sleep=sleep)
)
def find(*, find_all):
assert find_all
assert rig.capture.exists(), "audit capture must precede USB discovery"
return rig.after_reboot if root.bootsel_requested else rig.devices
monkeypatch.setattr(usb.core, "find", find)
original_read_text = Path.read_text
def read_text(path, *args, **kwargs):
if str(path).startswith("/sys/bus/usb/devices/"):
for device in rig.devices:
name = f"{device.bus}-" + ".".join(map(str, device.port_numbers))
if path == Path("/sys/bus/usb/devices") / name / "serial":
return device.cached_serial
raise FileNotFoundError(str(path))
return original_read_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", read_text)
def permissions(command, **kwargs):
assert command == [
"sudo",
"-n",
"setfacl",
"-m",
f"u:{os.getuid()}:rw",
f"/dev/bus/usb/{root.bus:03d}/{root.address:03d}",
], "recovery may grant access only to the verified root"
rig.acl.append(command[-1])
monkeypatch.setattr(check.subprocess, "run", permissions)
def forbidden(*args, **kwargs):
raise AssertionError("recovery attempted qualification or interface management")
monkeypatch.setattr(check, "model_references", forbidden)
monkeypatch.setattr(check, "child_models", forbidden)
monkeypatch.setattr(usb.util, "claim_interface", forbidden)
monkeypatch.setattr(usb.util, "release_interface", forbidden)
def dispose(device):
assert device is root, "unselected devices must receive no resource operations"
rig.disposed.append(device)
monkeypatch.setattr(usb.util, "dispose_resources", dispose)
def run(*options, recovery=True):
monkeypatch.setattr(
sys,
"argv",
[
"native_joycon_hub_check.py",
"--output",
str(rig.capture),
"--timeout",
"0.4" if recovery else "20",
*(["--reboot-bootsel"] if recovery else []),
*options,
],
)
status = check.main()
return status, json.loads(rig.capture.read_text())
rig.run = run
return rig
@pytest.mark.parametrize("options", [(), ("--pairs", "2")])
def test_root_only_recovery_confirms_rom_without_qualification(
recovery_rig, tmp_path, options
):
rig = recovery_rig
# A real Nintendo hub shares VID/PID but must receive no ACL or transfers.
foreign = Root(address=11, ports=(3, 5), serial="Nintendo")
rig.devices.append(foreign)
status, audit = rig.run("--build-dir", str(tmp_path / "not-configured"), *options)
assert status == 0 and audit["recovery_success"]
assert audit["devices"]["root"]["address"] == rig.root.address
assert audit["devices"]["bootsel"]["ports"] == list(rig.root.port_numbers)
assert audit["devices"]["bootsel"]["bus"] == rig.root.bus
assert audit["devices"]["bootsel"]["address"] == rig.rom.address
assert audit["recovery"] == {
"request_attempted": True,
"request_acknowledged": True,
"root_disappeared": True,
"rom_confirmed": True,
}
assert audit["operation"] == "bootsel_recovery"
assert not audit["qualification_success"]
assert not audit["live_input_proven"]
assert not audit["live_imu_proven"]
assert not audit["gameplay_proven"]
assert foreign.transfers == []
assert rig.root.bootsel_requested
def test_ambiguous_picos_are_refused_before_permissions(recovery_rig):
rig = recovery_rig
other = Root(address=11, ports=(3, 5), serial="switch-pico-other")
rig.devices.append(other)
status, audit = rig.run()
assert status == 2 and not audit["recovery_success"]
assert not audit["recovery"]["request_attempted"]
assert rig.acl == []
assert rig.root.transfers == other.transfers == []
def test_foreign_root_is_refused_without_usb_access(recovery_rig):
rig = recovery_rig
rig.root.serial = rig.root.cached_serial = "Nintendo"
status, audit = rig.run()
assert status == 2 and not audit["recovery_success"]
assert not audit["recovery"]["request_attempted"]
assert rig.acl == []
assert rig.root.transfers == []
def test_changed_usb_serial_cannot_receive_reboot(recovery_rig):
rig = recovery_rig
rig.root.serial = "Nintendo"
status, audit = rig.run()
assert status == 2 and not audit["recovery_success"]
assert not audit["recovery"]["request_attempted"]
assert not rig.root.bootsel_requested
assert all(transfer[0] == 0x80 for transfer in rig.root.transfers)
@pytest.mark.parametrize("write_result", [15, usb.core.USBError("disconnected")])
def test_incomplete_control_write_is_not_acknowledged(recovery_rig, write_result):
rig = recovery_rig
rig.root.write_result = write_result
status, audit = rig.run()
assert status == 2 and not audit["recovery_success"]
assert audit["recovery"]["request_attempted"]
assert not audit["recovery"]["request_acknowledged"]
assert not audit["recovery"]["rom_confirmed"]
def test_ack_without_rom_enumeration_is_incomplete(recovery_rig):
rig = recovery_rig
rig.after_reboot = []
status, audit = rig.run()
assert status == 2 and not audit["recovery_success"]
assert audit["recovery"]["request_acknowledged"]
assert audit["recovery"]["root_disappeared"]
assert not audit["recovery"]["rom_confirmed"]
@pytest.mark.parametrize(("bus", "ports"), [(2, (3, 5)), (3, (3, 4))])
def test_rom_on_another_physical_path_does_not_confirm_recovery(
recovery_rig, bus, ports
):
rig = recovery_rig
rig.rom.bus = bus
rig.rom.port_numbers = ports
status, audit = rig.run()
assert status == 2 and not audit["recovery_success"]
assert audit["recovery"]["request_acknowledged"]
assert not audit["recovery"]["rom_confirmed"]
def test_rom_cannot_confirm_while_root_still_enumerates(recovery_rig):
rig = recovery_rig
rig.after_reboot = [rig.root, rig.rom]
status, audit = rig.run()
assert status == 2 and not audit["recovery_success"]
assert audit["recovery"]["request_acknowledged"]
assert not audit["recovery"]["root_disappeared"]
assert not audit["recovery"]["rom_confirmed"]
@pytest.mark.parametrize(
"options",
[
("--input-only",),
("--neutral",),
("--rumble-sample", "0"),
("--capture-trace-on-error",),
],
)
def test_recovery_rejects_qualification_and_motor_options(recovery_rig, options):
rig = recovery_rig
with pytest.raises(SystemExit) as error:
rig.run(*options)
assert error.value.code == 2
assert not rig.capture.exists()
assert rig.acl == []
assert rig.root.transfers == []
def test_qualification_failure_does_not_reboot(monkeypatch, tmp_path):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "tools"))
import native_joycon_hub_check as check
capture = tmp_path / "failed-qualification.json"
monkeypatch.setattr(
sys,
"argv",
[
"native_joycon_hub_check.py",
"--output",
str(capture),
"--build-dir",
str(tmp_path / "absent-build"),
],
)
def forbidden_usb(*args, **kwargs):
raise AssertionError(
"failed qualification must not discover or reboot a device"
)
monkeypatch.setattr(usb.core, "find", forbidden_usb)
assert check.main() == 2
audit = json.loads(capture.read_text())
assert not audit["success"]

View file

@ -0,0 +1,357 @@
from __future__ import annotations
import io
import json
import struct
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
import usb.core
def trace_reply(
*,
magic=b"NHTR",
version=1,
status=0,
slot=4,
reserved=0,
time_us=123456,
generation=17,
):
return struct.pack(
"<4sBBBBII", magic, version, status, slot, reserved, time_us, generation
)
@pytest.fixture
def trace_rig(monkeypatch, tmp_path):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "tools"))
import native_joycon_hub_check as check
rig = SimpleNamespace(
check=check,
clock=0.0,
child_duration=0.25,
events=[],
child_error=usb.core.USBError("child descriptor timed out"),
capture=io.StringIO(),
)
monkeypatch.setattr(check, "time", SimpleNamespace(monotonic=lambda: rig.clock))
args = SimpleNamespace(
pairs=2,
timeout=10.0,
duration=2.0,
usb_timeout_ms=500,
rumble_sample=None,
input_only=True,
neutral=False,
output=tmp_path / "trace.json",
build_dir=tmp_path,
)
# Intentionally omit the new option: existing Namespace callers remain valid.
rig.scenario = check.Check(args, rig.capture)
class ControlPipe:
def __init__(self, owner, response):
self.owner = owner
self.response = response
self.transfers = []
def ctrl_transfer(
self, request_type, request, value, index, length, *, timeout
):
assert request_type & 0x80, "trace tests must never issue USB OUT transfers"
self.transfers.append(
(request_type, request, value, index, length, timeout)
)
rig.events.append(self.owner)
if self.owner != "root":
rig.clock += rig.child_duration
if isinstance(self.response, Exception):
raise self.response
return self.response
def attach_kernel_driver(self, interface):
rig.events.append("reattach")
rig.root = ControlPipe("root", trace_reply())
rig.child = ControlPipe("B_L", rig.child_error)
rig.scenario.devices = {"root": rig.root, "B_L": rig.child}
rig.scenario.util = SimpleNamespace(
release_interface=lambda device, interface: rig.events.append("release"),
dispose_resources=lambda device: rig.events.append("dispose"),
)
rig.scenario.claimed = [("B_L", 0)]
rig.scenario.detached = [("B_L", 0)]
def control(owner="B_L", name="device_descriptor"):
return rig.scenario.control(owner, name, 0x80, 6, 0x0100, 0, 18)
rig.control = control
return rig
@pytest.mark.parametrize("explicit_default", [False, True])
def test_child_error_does_not_mark_without_opt_in(trace_rig, explicit_default):
rig = trace_rig
if explicit_default:
rig.scenario.args.capture_trace_on_error = False
with pytest.raises(usb.core.USBError) as caught:
rig.control()
assert caught.value is rig.child_error
assert rig.root.transfers == []
assert len(rig.child.transfers) == 1
assert rig.scenario.result["failure_trace"] is None
def test_opt_in_captures_before_error_propagation_and_cleanup(trace_rig):
rig = trace_rig
rig.scenario.args.capture_trace_on_error = True
with pytest.raises(usb.core.USBError) as caught:
try:
rig.control()
except usb.core.USBError:
rig.events.append("propagated")
# The receipt is durable before the caller begins resource cleanup.
audit = json.loads(rig.capture.getvalue())
raise
finally:
rig.scenario.cleanup()
assert caught.value is rig.child_error
assert rig.events == [
"B_L",
"root",
"propagated",
"release",
"reattach",
"dispose",
"dispose",
]
assert len(rig.child.transfers) == 1
assert rig.root.transfers == [(0xC0, 0x5E, 0x5452, 4, 16, 500)]
trace = audit["failure_trace"]
assert trace["status"] == "captured" and trace["captured"]
assert trace["request_attempted"]
assert trace["response_hex"] == trace_reply().hex()
assert trace["receipt"] == {
"version": 1,
"status": 0,
"slot": 4,
"time_us": 123456,
"control_generation": 17,
}
assert trace["failed_control"]["setup"] == [0x80, 6, 0x0100, 0, 18]
assert trace["failed_control"]["side"] == "B_L"
assert trace["failed_control"]["error"] == str(rig.child_error)
def test_root_error_does_not_consume_the_single_child_marker(trace_rig):
rig = trace_rig
rig.scenario.args.capture_trace_on_error = True
root_error = usb.core.USBError("root descriptor failed")
rig.root.response = root_error
with pytest.raises(usb.core.USBError) as caught:
rig.control("root")
assert caught.value is root_error
assert len(rig.root.transfers) == 1
assert rig.scenario.result["failure_trace"] is None
rig.root.response = trace_reply()
for name in ("first_child_error", "later_child_error"):
with pytest.raises(usb.core.USBError) as caught:
rig.control(name=name)
assert caught.value is rig.child_error
assert [transfer[1] for transfer in rig.root.transfers] == [6, 0x5E]
audit = json.loads(rig.capture.getvalue())
assert audit["failure_trace"]["failed_control"]["name"] == "first_child_error"
@pytest.mark.parametrize(
"marker_error",
[usb.core.USBError("trace request stalled"), OSError("root disconnected")],
)
def test_failed_marker_preserves_original_error_and_is_not_retried(
trace_rig, marker_error
):
rig = trace_rig
rig.scenario.args.capture_trace_on_error = True
rig.root.response = marker_error
for name in ("first_child_error", "later_child_error"):
with pytest.raises(usb.core.USBError) as caught:
rig.control(name=name)
assert caught.value is rig.child_error
assert len(rig.root.transfers) == 1
trace = json.loads(rig.capture.getvalue())["failure_trace"]
assert trace["status"] == "error" and not trace["captured"]
assert trace["response_hex"] is None and trace["receipt"] is None
assert str(marker_error) in trace["error"]
assert trace["failed_control"]["name"] == "first_child_error"
def test_busy_marker_is_an_explicit_refusal_not_capture(trace_rig):
rig = trace_rig
rig.scenario.args.capture_trace_on_error = True
rig.root.response = trace_reply(status=1, time_us=0, generation=0)
with pytest.raises(usb.core.USBError) as caught:
rig.control()
assert caught.value is rig.child_error
trace = json.loads(rig.capture.getvalue())["failure_trace"]
assert trace["status"] == "busy" and not trace["captured"]
assert trace["response_hex"] == rig.root.response.hex()
assert trace["receipt"]["status"] == 1
assert trace["receipt"]["time_us"] == trace["receipt"]["control_generation"] == 0
@pytest.mark.parametrize(
"response",
[
trace_reply()[:-1],
trace_reply() + b"\x00",
trace_reply(magic=b"NOPE"),
trace_reply(version=2),
trace_reply(slot=3),
trace_reply(reserved=1),
trace_reply(status=2),
trace_reply(status=1, time_us=1, generation=0),
trace_reply(status=1, time_us=0, generation=1),
],
ids=[
"short",
"long",
"magic",
"version",
"slot",
"reserved",
"status",
"busy-time",
"busy-generation",
],
)
def test_malformed_marker_never_qualifies_as_a_capture(trace_rig, response):
rig = trace_rig
rig.scenario.args.capture_trace_on_error = True
rig.root.response = response
with pytest.raises(usb.core.USBError) as caught:
rig.control()
assert caught.value is rig.child_error
trace = json.loads(rig.capture.getvalue())["failure_trace"]
assert trace["status"] == "malformed" and not trace["captured"]
assert trace["response_hex"] == response.hex()
assert trace["receipt"] is None
assert "error" in trace
def test_marker_timeout_uses_only_remaining_deadline(trace_rig):
rig = trace_rig
rig.scenario.args.capture_trace_on_error = True
rig.child_duration = rig.scenario.args.timeout - 0.125
with pytest.raises(usb.core.USBError) as caught:
rig.control()
assert caught.value is rig.child_error
assert rig.root.transfers == [(0xC0, 0x5E, 0x5452, 4, 16, 125)]
def test_expired_deadline_after_child_error_records_without_request(trace_rig):
rig = trace_rig
rig.scenario.args.capture_trace_on_error = True
rig.child_duration = rig.scenario.args.timeout
with pytest.raises(usb.core.USBError) as caught:
rig.control()
assert caught.value is rig.child_error
assert rig.root.transfers == []
trace = json.loads(rig.capture.getvalue())["failure_trace"]
assert trace["status"] == "deadline_expired" and not trace["captured"]
assert not trace["request_attempted"]
assert trace["failed_control"]["error"] == str(rig.child_error)
def test_pretransfer_deadline_does_not_count_as_a_child_usb_failure(trace_rig):
rig = trace_rig
rig.scenario.args.capture_trace_on_error = True
rig.clock = rig.scenario.deadline
with pytest.raises(TimeoutError):
rig.control()
assert rig.child.transfers == rig.root.transfers == []
assert rig.scenario.result["failure_trace"] is None
def test_capture_file_error_cannot_replace_child_transfer_error(trace_rig):
rig = trace_rig
rig.scenario.args.capture_trace_on_error = True
rig.capture.close()
with pytest.raises(usb.core.USBError) as caught:
rig.control()
assert caught.value is rig.child_error
trace = rig.scenario.result["failure_trace"]
assert trace["status"] == "captured"
assert "ValueError" in trace["checkpoint_error"]
def test_capture_option_marks_cli_failure_without_recovering(
trace_rig, monkeypatch, tmp_path
):
rig = trace_rig
capture = tmp_path / "option.json"
rig.child.owner = "R"
rig.root.response = trace_reply(slot=1)
def discover(scenario):
scenario.devices = {"root": rig.root, "R": rig.child, "L": rig.child}
scenario.util = rig.scenario.util
def claim(scenario):
scenario.claimed = [("R", 0)]
scenario.detached = [("R", 0)]
# Replace only private build references and the physical USB boundary. The
# CLI, descriptor control, failure path, audit and cleanup execute normally.
monkeypatch.setattr(
rig.check, "model_references", lambda *args, **kwargs: rig.check.child_models(1)
)
monkeypatch.setattr(rig.check.Check, "discover", discover)
monkeypatch.setattr(rig.check.Check, "permissions", lambda scenario: None)
monkeypatch.setattr(rig.check.Check, "claim", claim)
monkeypatch.setattr(
sys,
"argv",
[
"native_joycon_hub_check.py",
"--capture-trace-on-error",
"--output",
str(capture),
],
)
assert rig.check.main() == 2
audit = json.loads(capture.read_text())
assert not audit["success"]
assert audit["failure"] == str(rig.child_error)
assert audit["failure_trace"]["status"] == "captured"
assert audit["failure_trace"]["failed_control"]["side"] == "R"
assert audit["parameters"]["capture_trace_on_error"]
assert audit["safety"]["trace_marker_requested"]
assert len(rig.child.transfers) == 1
assert rig.root.transfers == [(0xC0, 0x5E, 0x5452, 1, 16, 500)]
assert rig.events[:4] == ["R", "root", "release", "reattach"]

View file

@ -7,10 +7,12 @@ from pathlib import Path
import pytest
@pytest.mark.parametrize("controller_count", [2, 4], ids=["one-pair", "two-pair"])
@pytest.mark.parametrize("imu_target", [1, 2, 3], ids=["right", "left", "both"])
def test_native_gamepad_bridge_mapping_motion_and_backpressure(
tmp_path: Path,
imu_target: int,
controller_count: int,
) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
@ -29,6 +31,7 @@ def test_native_gamepad_bridge_mapping_motion_and_backpressure(
"-DSWITCH2_BRIDGE_FULL_INPUT=1",
"-DSWITCH2_BRIDGE_SOURCE_AUTO=1",
"-DSWITCH2_PROBE_HUB=1",
*(["-DPROBE_CONTROLLER_COUNT=4"] if controller_count == 4 else []),
f"-DSWITCH2_BRIDGE_IMU_TARGET_MASK={imu_target}",
"-DSWITCH_PICO_BLUEPAD32=1",
"-DSWITCH_PICO_ENABLE_CLASSIC=1",

View file

@ -9,15 +9,26 @@ import pytest
@pytest.mark.parametrize(
("left", "composite"),
[(False, False), (True, False), (False, True)],
ids=["right", "left", "composite"],
("left", "composite", "hub", "count"),
[
(False, False, False, 1),
(True, False, False, 1),
(False, True, False, 2),
(False, False, True, 2),
(False, False, True, 4),
],
ids=["right", "left", "composite", "hub-one-pair", "hub-two-pairs"],
)
@pytest.mark.parametrize(
"imu_mode", [None, "OMIT_NATIVE_IMU", "ZERO_NATIVE_IMU_PAYLOAD"]
)
def test_switch2_usb_probe_protocol(
tmp_path: Path, left: bool, composite: bool, imu_mode: str | None
tmp_path: Path,
left: bool,
composite: bool,
hub: bool,
count: int,
imu_mode: str | None,
) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("cc") or shutil.which("gcc")
@ -41,20 +52,28 @@ def test_switch2_usb_probe_protocol(
"Pico SDK mbedTLS required; configure firmware or set PICO_SDK_PATH"
)
probe = root / "tools" / "switch2_usb_probe"
sides = [False, True] if composite else [left]
sides = (
[bool(instance & 1) for instance in range(count)]
if composite or hub
else [left]
)
factory_rows = []
user_rows = []
for is_left in sides:
factory_center = "0x00, 0x09, 0x90" if is_left else "0x00, 0x08, 0x80"
for instance, is_left in enumerate(sides):
# A and B must differ even for the same side: detect side-indexed aliases.
pair = instance // 2
factory_center = (
f"{pair * 0x20}, {9 if is_left else 8}, {0x90 if is_left else 0x80}"
)
factory_rows.append(
f"{{[0xa8] = {factory_center}, 0, 3, 0x30, 0, 4, 0x40,"
f" [8191] = {0xE2 if is_left else 0xE1}}}"
f" [8191] = {(0xE2 if is_left else 0xE1) + pair * 2}}}"
)
# L deliberately has invalid user calibration despite valid magic.
user_center = "0, 0, 0" if is_left else "0x10, 0x08, 0x81"
user_center = "0, 0, 0" if is_left else f"{0x10 + pair * 0x20}, 0x08, 0x81"
user_rows.append(
f"{{[0x40] = 0xb2, 0xa1, {user_center}, 0, 3, 0x30, 0, 4, 0x40,"
f" [4095] = {0xF2 if is_left else 0xF1}}}"
f" [4095] = {(0xF2 if is_left else 0xF1) + pair * 2}}}"
)
(tmp_path / "probe_memory_data.h").write_text(
'#include "model.h"\n'
@ -77,6 +96,9 @@ def test_switch2_usb_probe_protocol(
f'-DMBEDTLS_CONFIG_FILE="{probe / "mbedtls_config.h"}"',
f"-DSWITCH2_PROBE_JOYCON_LEFT={int(left)}",
f"-DSWITCH2_PROBE_COMPOSITE={int(composite)}",
f"-DSWITCH2_PROBE_HUB={int(hub)}",
f"-DPROBE_CONTROLLER_COUNT={count}",
f"-DSWITCH2_PROBE_NEUTRAL_INPUT={int(hub)}",
*([f"-DSWITCH2_PROBE_{imu_mode}=1"] if imu_mode else []),
f"-I{probe}",
f"-I{tmp_path}",

View file

@ -0,0 +1,75 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import pytest
@pytest.mark.parametrize(
("left", "composite", "hub", "count", "overlap"),
[
(False, False, False, 1, False),
(True, False, False, 1, False),
(False, True, False, 2, False),
(False, False, True, 2, False),
(False, False, True, 4, False),
(False, False, True, 4, True),
],
ids=[
"right",
"left",
"composite",
"hub-one-pair",
"hub-two-pairs",
"overlap-pair-b",
],
)
def test_switch2_usb_probe_storage_native(
tmp_path: Path, left: bool, composite: bool, hub: bool, count: int, overlap: bool
) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
probe = root / "tools" / "switch2_usb_probe"
# Stub geometry: 2 MiB flash, 8 KiB BTstack, 8 KiB configuration, 256 KiB
# profiles. Link the SDK's end-of-image symbol at the exact reserved boundary,
# or one byte into pair B while still safely below both original pair A banks.
reserved_start = 0x1BC000 - max(2, count) * 8192
binary_end = reserved_start + int(overlap)
executable = tmp_path / "switch2_usb_probe_storage_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-DSWITCH2_PROBE_JOYCON_LEFT={int(left)}",
f"-DSWITCH2_PROBE_COMPOSITE={int(composite)}",
f"-DSWITCH2_PROBE_HUB={int(hub)}",
f"-DSWITCH2_PROBE_NEUTRAL_INPUT={int(hub)}",
f"-DPROBE_CONTROLLER_COUNT={count}",
f"-DPROBE_TEST_STORAGE_OVERLAP={int(overlap)}",
f"-I{root / 'tests' / 'switch2_usb_probe_storage_native_stubs'}",
f"-I{root / 'src' / 'firmware'}",
f"-I{probe}",
str(root / "tests" / "switch2_usb_probe_storage_test.cpp"),
str(probe / "storage.cpp"),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "configuration_storage.cpp"
),
f"-Wl,--defsym=__flash_binary_end=probe_test_flash+{binary_end}",
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

File diff suppressed because it is too large Load diff

View file

@ -38,14 +38,20 @@ extern bool native_hub_select_device(uint8_t address, uint8_t owner, uint32_t cu
#define TOKEN_IN_SIGNATURE 0x95a6a666u
#define TOKEN_SETUP_SIGNATURE 0x9a56a666u
#define NO_READER 2u
#define SETUP_SEQUENCE_MASK 0x3fffffffu
#define SETUP_SLOT_SHIFT 30u
#define SETUP_INVALID (3u << SETUP_SLOT_SHIFT)
#if PROBE_ROUTER_SLOTS > 3u
#define SETUP_SLOT_BITS 3u
#else
#define SETUP_SLOT_BITS 2u
#endif
#define SETUP_SLOT_SHIFT (32u - SETUP_SLOT_BITS)
#define SETUP_SEQUENCE_MASK ((1u << SETUP_SLOT_SHIFT) - 1u)
#define SETUP_INVALID_OWNER ((1u << SETUP_SLOT_BITS) - 1u)
#define SETUP_INVALID (SETUP_INVALID_OWNER << SETUP_SLOT_SHIFT)
#define RAW_BITS 40u
_Static_assert(SIO_GPIO_HI_IN_USB_DP_BITS == (1u << 24), "SIO USB DP layout");
_Static_assert(SIO_GPIO_HI_IN_USB_DM_BITS == (1u << 25), "SIO USB DM layout");
_Static_assert(PROBE_ROUTER_SLOTS == 3u, "Packed setup owner has three slots");
_Static_assert(PROBE_ROUTER_SLOTS <= SETUP_INVALID_OWNER, "Packed setup owner must reserve an invalid value");
typedef struct {
uint8_t owner[128];
@ -103,7 +109,7 @@ static __force_inline void invalidate_setup(void) {
static __force_inline void publish_setup(uint8_t slot) {
const uint32_t sequence = (atomic_read(&setup_publication) + 1u) & SETUP_SEQUENCE_MASK;
const uint32_t owner = slot < PROBE_ROUTER_SLOTS ? slot : 3u;
const uint32_t owner = slot < PROBE_ROUTER_SLOTS ? slot : SETUP_INVALID_OWNER;
__atomic_store_n(&setup_publication, sequence | (owner << SETUP_SLOT_SHIFT),
__ATOMIC_RELEASE);
}
@ -185,8 +191,9 @@ void probe_router_init(uint32_t system_clock_hz) {
token_words[6] = TOKEN_OUT_SIGNATURE;
token_words[10] = TOKEN_IN_SIGNATURE;
token_words[5] = TOKEN_SETUP_SIGNATURE;
const uint8_t addresses[PROBE_ROUTER_SLOTS] = {0u, PROBE_ROUTER_UNASSIGNED,
PROBE_ROUTER_UNASSIGNED};
uint8_t addresses[PROBE_ROUTER_SLOTS];
memset(addresses, PROBE_ROUTER_UNASSIGNED, sizeof(addresses));
addresses[0] = 0u;
memset(&counters, 0, sizeof(counters));
published_generation = 0u;
reader_index = NO_READER;
@ -313,14 +320,12 @@ static __force_inline void route_header(const routing_table* table, uint32_t add
return;
#if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB
if (atomic_read(&enabled) != 0u) {
const bool selected = native_hub_select_device((uint8_t)address, table->owner[address], cutoff);
#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
// Keep diagnostic PID classification behind the address-critical call.
__asm volatile ("" : "+r"(signature) : : "memory");
const uint8_t pid = signature == TOKEN_OUT_SIGNATURE ? PID_OUT :
signature == TOKEN_IN_SIGNATURE ? PID_IN : PID_SETUP;
const bool selected = (pid == PID_OUT || (pid == PID_IN && table->owner[address] == 0))
? native_hub_select_device_traced((uint8_t)address, table->owner[address], cutoff, pid)
: native_hub_select_device((uint8_t)address, table->owner[address], cutoff);
#else
const bool selected = native_hub_select_device((uint8_t)address, table->owner[address], cutoff);
#endif
if (!selected) {
#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
@ -328,6 +333,9 @@ static __force_inline void route_header(const routing_table* table, uint32_t add
#endif
return;
}
#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
native_hub_note_selected_token((uint8_t)address, table->owner[address], cutoff, pid);
#endif
if (initial_address != address) ++packet->retargets;
}
#else

View file

@ -3,7 +3,11 @@
#include <stdbool.h>
#include <stdint.h>
#if defined(SWITCH2_PROBE_HUB) && SWITCH2_PROBE_HUB && defined(PROBE_CONTROLLER_COUNT)
#define PROBE_ROUTER_SLOTS (PROBE_CONTROLLER_COUNT + 1u)
#else
#define PROBE_ROUTER_SLOTS 3u
#endif
#define PROBE_ROUTER_UNASSIGNED 0xffu
typedef struct {

View file

@ -5,6 +5,14 @@ project(switch2_usb_probe C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
include(${CMAKE_CURRENT_LIST_DIR}/probe_build.cmake)
if(SWITCH2_PROBE_NEUTRAL_INPUT)
# Match the proven hub clock/flash timing without enabling the radio.
add_compile_definitions(
SWITCH_PICO_SYS_CLOCK_MHZ=240 SWITCH_PICO_OVERCLOCK_MV=1300
PICO_FLASH_SPI_CLKDIV=4 PICO_EMBED_XIP_SETUP=1
PICO_STACK_SIZE=16384 PICO_CORE1_STACK_SIZE=4096
CYW43_PIO_CLOCK_DIV_INT=0 CYW43_PIO_CLOCK_DIV_FRAC8=0)
endif()
pico_sdk_init()
add_executable(switch2-usb-probe
../../src/firmware/configuration/configuration_storage.cpp
@ -12,4 +20,11 @@ add_executable(switch2-usb-probe
target_compile_definitions(switch2-usb-probe PRIVATE
PICO_FLASH_ASSUME_CORE1_SAFE=1 PICO_FLASH_ASSERT_ON_UNSAFE=0)
switch2_usb_probe_configure(switch2-usb-probe)
if(SWITCH2_PROBE_NEUTRAL_INPUT)
target_sources(switch2-usb-probe PRIVATE
bootsel.cpp
../../src/firmware/usb/usb_configuration_management.cpp
../../src/firmware/platform/pico/system_clock.cpp)
target_link_libraries(switch2-usb-probe PRIVATE hardware_adc hardware_vreg hardware_powman)
endif()
pico_add_extra_outputs(switch2-usb-probe)

View file

@ -19,7 +19,7 @@ struct BootselTransfer {
bool pending;
bool validated;
};
// Control state is independent even when the two children enumerate together.
// Each root/child control transfer owns its validation state independently.
BootselTransfer bootsel_transfers[PROBE_CONTROLLER_COUNT + 1];
bool bootsel_delay_started;
uint32_t bootsel_deadline_ms;
@ -41,7 +41,7 @@ bool probe_management_vendor_control(uint8_t rhport, uint8_t stage,
request->bRequest != static_cast<uint8_t>(Operation::kBootselReboot) ||
request->wValue != kRequestValue || request->wIndex != kRequestIndex ||
request->wLength != kRequestHeaderSize) {
#if SWITCH2_PROBE_HUB
#if SWITCH2_PROBE_HUB && !SWITCH2_PROBE_NEUTRAL_INPUT
return rhport == 0 &&
usb_configuration_management_vendor_control(rhport, stage, request);
#else
@ -53,7 +53,7 @@ bool probe_management_vendor_control(uint8_t rhport, uint8_t stage,
// Any short OUT leaves nonzero reserved/CRC bytes and fails decoding.
memset(transfer.envelope, 0xff, sizeof(transfer.envelope));
transfer.pending = native_hub_control_xfer(
rhport, request, transfer.envelope, sizeof(transfer.envelope));
rhport, request, transfer.envelope, sizeof(transfer.envelope), false);
return transfer.pending;
}
if (stage == CONTROL_STAGE_DATA) {

View file

@ -9,8 +9,8 @@
extern "C" {
#endif
// Core 0: native root management plus private BOOTSEL on root and children.
// Non-hub probes retain their private BOOTSEL-only management surface.
// Core 0: private BOOTSEL on native root/children, plus full root management
// only outside the neutral experiment. Non-hub probes remain BOOTSEL-only.
bool probe_management_vendor_control(uint8_t rhport, uint8_t stage,
const tusb_control_request_t* request);
// Core 0: service the existing reboot delay only after a validated status ACK.

View file

@ -62,5 +62,9 @@ static const uint8_t probe_hid_report_descriptors[PROBE_CONTROLLER_COUNT][100] =
#if SWITCH2_PROBE_COMPOSITE || SWITCH2_PROBE_HUB
PROBE_HID_DESCRIPTOR(0x07u),
#endif
#if SWITCH2_PROBE_HUB && PROBE_CONTROLLER_COUNT == 4
PROBE_HID_DESCRIPTOR(0x08u),
PROBE_HID_DESCRIPTOR(0x07u),
#endif
};
#undef PROBE_HID_DESCRIPTOR

View file

@ -7,14 +7,23 @@
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "model.h"
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
#if defined(SWITCH_PICO_SWITCH2_USB_BRIDGE) || SWITCH2_PROBE_NEUTRAL_INPUT
#include "bootsel.h"
#endif
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
#include "controller_input.h"
#else
#elif !SWITCH2_PROBE_NEUTRAL_INPUT
#include "platform/pico/bootsel_button_sample.h"
#include "button_test.h"
#endif
#if SWITCH2_PROBE_NEUTRAL_INPUT
#include "platform/pico/system_clock.h"
#if !defined(SWITCH2_PROBE_USB_INIT) || !defined(SWITCH2_PROBE_MEMORY)
#error "Neutral hub requires native USB protocol initialization and captured stick calibration"
#endif
#endif
#include "pico/stdlib.h"
#include "hardware/sync.h"
#include "hardware/uart.h"
@ -75,7 +84,7 @@ typedef struct {
#ifdef SWITCH2_PROBE_TRACE_NATIVE_INPUT
uint32_t last_native_trace_ms;
#endif
#else
#elif !SWITCH2_PROBE_NEUTRAL_INPUT
probe_button_state button_test;
uint32_t last_button_ms;
bool button_sample_error;
@ -116,6 +125,13 @@ static void gate_join_shoulders(uint8_t instance, uint8_t report_id,
#endif
int probe_debug_printf(const char* format, ...) {
#if SWITCH2_PROBE_HUB
// Native-hub producers and the UART consumer all run on Core0 foreground.
// Neither USB IRQ nor the Core1 observer accesses this ring. Masking IRQs
// across a message copy prevents completion service and can lose the next
// address's token; do not turn a diagnostic into USB backpressure.
hard_assert(get_core_num() == 0 && __get_current_exception() == 0);
#endif
char message[512];
va_list args;
va_start(args, format);
@ -123,39 +139,45 @@ int probe_debug_printf(const char* format, ...) {
va_end(args);
if (result <= 0) return result;
const size_t size = (size_t)result < sizeof(message) ? (size_t)result : sizeof(message) - 1;
#if !SWITCH2_PROBE_HUB
const uint32_t interrupts = save_and_disable_interrupts();
#if SWITCH2_PROBE_HUB && defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
const uint32_t mask_started = time_us_32();
#elif defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
const uint32_t trace_parent = native_hub_trace_phase(NATIVE_HUB_TRACE_PHASE_LOG_COPY);
#endif
if (LOG_CAPACITY - (log_written - log_read) >= size) {
for (size_t i = 0; i < size; ++i)
log_bytes[(log_written + i) % LOG_CAPACITY] = message[i];
const bool queued = LOG_CAPACITY - (log_written - log_read) >= size;
if (queued) {
const size_t offset = log_written % LOG_CAPACITY;
const size_t first = size < LOG_CAPACITY - offset ? size : LOG_CAPACITY - offset;
memcpy(log_bytes + offset, message, first);
memcpy(log_bytes, message + first, size - first);
log_written += (uint32_t)size;
log_dropped += (uint32_t)result - (uint32_t)size;
} else {
log_dropped += (uint32_t)result;
}
#if SWITCH2_PROBE_HUB && defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
native_hub_trace_phase(trace_parent);
const uint32_t mask_elapsed = time_us_32() - mask_started;
#endif
#if !SWITCH2_PROBE_HUB
restore_interrupts(interrupts);
#if SWITCH2_PROBE_HUB && defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
native_hub_note_log_mask(mask_elapsed, (uint32_t)size, interrupts != 0);
#elif defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
native_hub_trace_phase(trace_parent);
#endif
return result;
return queued ? result : -1;
}
static void drain_log(void) {
while (uart_is_writable(uart0)) {
#if !SWITCH2_PROBE_HUB
const uint32_t interrupts = save_and_disable_interrupts();
#endif
if (log_read == log_written) {
#if !SWITCH2_PROBE_HUB
restore_interrupts(interrupts);
#endif
break;
}
const char value = log_bytes[log_read++ % LOG_CAPACITY];
#if !SWITCH2_PROBE_HUB
restore_interrupts(interrupts);
#endif
uart_putc_raw(uart0, value);
}
}
@ -427,7 +449,7 @@ static void consume_bulk_packet(probe_usb_controller* controller, const uint8_t*
}
}
#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE
#if !defined(SWITCH_PICO_SWITCH2_USB_BRIDGE) && !SWITCH2_PROBE_NEUTRAL_INPUT
static void button_test_task(probe_usb_controller* controller, uint32_t now) {
probe_protocol_state* protocol = &controller->protocol;
const bool ready = probe_transport_mounted(controller->instance) &&
@ -679,7 +701,7 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
const tusb_control_request_t* request) {
if (stage == CONTROL_STAGE_SETUP)
log_packet("VENDOR_CONTROL", rhport, 0, (const uint8_t*)request, sizeof(*request));
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
#if defined(SWITCH_PICO_SWITCH2_USB_BRIDGE) || SWITCH2_PROBE_NEUTRAL_INPUT
if (probe_management_vendor_control(rhport, stage, request))
return true;
#endif
@ -776,16 +798,30 @@ int main(void) {
#endif
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
probe_controller_input_clock_init();
#elif SWITCH2_PROBE_NEUTRAL_INPUT
system_clock_initialize();
#endif
stdio_init_all();
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
probe_debug_printf("\n[PROBE] " PROBE_JOYCON_PRODUCT " Bluetooth-to-USB controller/native mouse bridge\n");
#elif SWITCH2_PROBE_NEUTRAL_INPUT
probe_debug_printf("\n[PROBE] Neutral native USB hub transport-only experiment: %u pair(s), %u children\n",
PROBE_CONTROLLER_COUNT / 2, PROBE_CONTROLLER_COUNT);
#else
probe_debug_printf("\n[PROBE] " PROBE_JOYCON_PRODUCT " USB enumeration recorder\n");
#endif
#if SWITCH2_PROBE_HUB
#if PROBE_CONTROLLER_COUNT == 4
probe_debug_printf("[PROBE] NATIVE_HUB: two pairs in A_R/A_L/B_R/B_L order; each HID0/vendor1 EP1/2; no shoulder gate\n");
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance)
probe_debug_printf("[PROBE] CHILD slot=%u pair=%c side=%c pid=%04x report=%02x\n",
instance + 1, 'A' + instance / 2,
probe_model_is_left(instance) ? 'L' : 'R',
probe_model_pid(instance), probe_model_report_id(instance));
#else
probe_debug_printf("[PROBE] NATIVE_HUB: device1 right PID2066, device2 left PID2067; each HID0/vendor1 EP1/2; no shoulder gate\n");
#endif
#endif
#if SWITCH2_PROBE_COMPOSITE
#ifdef SWITCH2_PROBE_JOIN_CHORD_GATE
probe_debug_printf("[PROBE] JOIN_CHORD_GATE enabled: physical L+R required; no synthesized presses or USB initialization\n");
@ -804,7 +840,8 @@ int main(void) {
#if SWITCH2_BRIDGE_WII_INPUT
probe_debug_printf("[PROBE] UART0 GP0=TX, 115200 8N1; selected Wii IR/MotionPlus source enabled\n");
#elif SWITCH2_BRIDGE_FULL_INPUT
probe_debug_printf("[PROBE] UART0 GP0=TX, 115200 8N1; one supported gamepad feeds the native R/L pair\n");
probe_debug_printf("[PROBE] UART0 GP0=TX, 115200 8N1; up to %u supported gamepad(s) feed %u native R/L pair(s)\n",
PROBE_CONTROLLER_COUNT / 2, PROBE_CONTROLLER_COUNT / 2);
#else
probe_debug_printf("[PROBE] UART0 GP0=TX, 115200 8N1; %u selected Joy-Con Bluetooth source(s)\n",
PROBE_CONTROLLER_COUNT);
@ -833,12 +870,15 @@ int main(void) {
probe_debug_printf("[PROBE] Wii IR drives native mouse movement; buttons retain profile mapping; MotionPlus bias learns in background\n");
probe_debug_printf("[PROBE] Hold BOOTSEL2s for pairing; Wii cue feedback uses bounded ERM patterns, not HD audio waveforms\n");
#elif SWITCH2_BRIDGE_FULL_INPUT
probe_debug_printf("[PROBE] Full gamepad controls on R/L; IMU mask=%u; Wii bias learns without startup settling\n",
(unsigned)SWITCH2_BRIDGE_IMU_TARGET_MASK);
probe_debug_printf("[PROBE] Full gamepad controls on %u pair(s); IMU side mask=%u; Wii bias learns without startup settling\n",
PROBE_CONTROLLER_COUNT / 2, (unsigned)SWITCH2_BRIDGE_IMU_TARGET_MASK);
probe_debug_printf("[PROBE] Hold BOOTSEL 2s for Bluetooth pairing (never clears pairings); cues use source capabilities\n");
#else
probe_debug_printf("[PROBE] Live Joy-Con buttons/stick/native mouse; hold BOOTSEL 2s for Bluetooth pairing (never clears pairings)\n");
#endif
#elif SWITCH2_PROBE_NEUTRAL_INPUT
probe_debug_printf("[PROBE] Neutral captured-calibration reports only; physical BOOTSEL input disabled; no Bluetooth, mouse/IMU samples or motor cue acknowledgements\n");
probe_debug_printf("[PROBE] Private software BOOTSEL on root/children enabled; profile/configuration management disabled\n");
#else
probe_debug_printf("[PROBE] Manual input test: hold BOOTSEL for SL+SR, release for neutral; no controller forwarding\n");
#endif
@ -858,10 +898,12 @@ int main(void) {
uint32_t last_heartbeat = 0;
while (true) {
#if SWITCH2_PROBE_HUB
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
native_hub_trace_phase(NATIVE_HUB_TRACE_PHASE_RADIO_POLL);
#endif
probe_controller_input_task();
#endif
#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
native_hub_trace_phase(NATIVE_HUB_TRACE_PHASE_USB_TASK);
#endif
@ -877,7 +919,7 @@ int main(void) {
native_hub_trace_phase(NATIVE_HUB_TRACE_PHASE_PROTOCOL);
#endif
const uint32_t now = to_ms_since_boot(get_absolute_time());
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
#if defined(SWITCH_PICO_SWITCH2_USB_BRIDGE) || SWITCH2_PROBE_NEUTRAL_INPUT
probe_bootsel_task(now);
#endif
#ifdef SWITCH2_PROBE_USB_INIT
@ -888,7 +930,7 @@ int main(void) {
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
controller_input_task(&controllers[instance], now);
#else
#elif !SWITCH2_PROBE_NEUTRAL_INPUT
button_test_task(&controllers[instance], now);
#endif
#if !defined(SWITCH2_PROBE_JOIN_CHORD_GATE) || SWITCH2_PROBE_HUB

View file

@ -23,6 +23,18 @@
#error "Native hub and composite USB backends are mutually exclusive"
#endif
#ifndef SWITCH2_PROBE_NEUTRAL_INPUT
#define SWITCH2_PROBE_NEUTRAL_INPUT 0
#endif
#if SWITCH2_PROBE_NEUTRAL_INPUT != 0 && SWITCH2_PROBE_NEUTRAL_INPUT != 1
#error "SWITCH2_PROBE_NEUTRAL_INPUT must be 0 or 1"
#endif
#if SWITCH2_PROBE_NEUTRAL_INPUT && (!SWITCH2_PROBE_HUB || defined(SWITCH_PICO_SWITCH2_USB_BRIDGE))
#error "Neutral input is only supported by the standalone native hub"
#endif
#ifndef SWITCH2_PROBE_JOYCON_LEFT
#define SWITCH2_PROBE_JOYCON_LEFT 0
#endif
@ -33,13 +45,20 @@
#if SWITCH2_PROBE_COMPOSITE || SWITCH2_PROBE_HUB
#if SWITCH2_PROBE_JOYCON_LEFT
#error "Dual-controller primary must be Joy-Con 2 (R)"
#error "Multi-controller primary must be Joy-Con 2 (R)"
#endif
#ifndef PROBE_CONTROLLER_COUNT
#define PROBE_CONTROLLER_COUNT 2
#endif
#if PROBE_CONTROLLER_COUNT != 2
#error "Dual-controller output requires two controller instances"
#if SWITCH2_PROBE_HUB
#if PROBE_CONTROLLER_COUNT != 2 && PROBE_CONTROLLER_COUNT != 4
#error "Native hub requires two or four controller instances"
#endif
#if PROBE_CONTROLLER_COUNT == 4 && !SWITCH2_PROBE_NEUTRAL_INPUT && !SWITCH2_BRIDGE_FULL_INPUT
#error "Two-pair hub requires full-gamepad input or explicit neutral transport"
#endif
#elif PROBE_CONTROLLER_COUNT != 2
#error "Composite output requires two controller instances"
#endif
#else
#ifndef PROBE_CONTROLLER_COUNT
@ -66,11 +85,11 @@
#define PROBE_IMU_DATA_OFFSET 16u
#endif
// Instance zero is the standalone model or the dual-controller right function.
// In composite and native hub modes, instance one is the independent left side.
// Instance zero is the standalone model or the first pair's right function.
// Composite/hub instances alternate right/left, with later pairs following.
static inline bool probe_model_is_left(uint8_t instance) {
#if SWITCH2_PROBE_COMPOSITE || SWITCH2_PROBE_HUB
return instance == 1;
return (instance & 1u) != 0;
#else
(void)instance;
return SWITCH2_PROBE_JOYCON_LEFT != 0;

View file

@ -14,11 +14,15 @@
#if !SWITCH2_PROBE_HUB || SWITCH2_BRIDGE_WII_INPUT
#error "A full gamepad source requires the native R/L USB hub"
#endif
static_assert(PROBE_CONTROLLER_COUNT == 2);
static_assert(PROBE_CONTROLLER_COUNT == 2 || PROBE_CONTROLLER_COUNT == 4);
static_assert(BLUEPAD32_NATIVE_PAIR_COUNT == PROBE_CONTROLLER_COUNT / 2);
extern "C" int probe_debug_printf(const char* format, ...);
#ifndef SWITCH2_BRIDGE_IMU_TARGET_MASK
#define SWITCH2_BRIDGE_IMU_TARGET_MASK 3
#endif
#ifndef SWITCH2_BRIDGE_SECOND_SOURCE_AUTO
#define SWITCH2_BRIDGE_SECOND_SOURCE_AUTO 1
#endif
static_assert(SWITCH2_BRIDGE_IMU_TARGET_MASK >= 1 && SWITCH2_BRIDGE_IMU_TARGET_MASK <= 3);
namespace {
@ -29,6 +33,10 @@ constexpr uint32_t kOutputDeadlineUs = 100000;
constexpr uint8_t kSourceAddress[] = {SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES};
static_assert(sizeof(kSourceAddress) == 6);
#endif
#if PROBE_CONTROLLER_COUNT == 4 && !SWITCH2_BRIDGE_SECOND_SOURCE_AUTO
constexpr uint8_t kSecondSourceAddress[] = {SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS_BYTES};
static_assert(sizeof(kSecondSourceAddress) == 6);
#endif
struct Child {
bool enabled = false;
@ -51,15 +59,18 @@ struct Child {
uint32_t committed_ticks = 0;
};
Child g_children[PROBE_CONTROLLER_COUNT];
Bluepad32NativeGamepadSnapshot g_source;
ControllerProfileTransformResult g_mapped;
ProbeNativeMotion g_motion;
bool g_active;
bool g_evaluated;
uint32_t g_evaluated_ms;
uint32_t g_profile_generation;
struct Pair {
Bluepad32NativeGamepadSnapshot source{};
ControllerProfileTransformResult mapped{};
ProbeNativeMotion motion;
bool active = false;
bool evaluated = false;
uint32_t evaluated_ms = 0;
uint32_t profile_generation = 0;
int sensor_status = -1;
};
Pair g_pairs[BLUEPAD32_NATIVE_PAIR_COUNT];
uint32_t g_report_token;
int g_sensor_status = -1;
bool g_clock_started;
uint32_t g_clock_us;
uint32_t g_clock_ticks;
@ -111,11 +122,12 @@ int16_t negate_axis(int16_t value) {
return value == INT16_MIN ? INT16_MAX : static_cast<int16_t>(-value);
}
void native_motion_axes(const int32_t source[3], float scale, float output[3]) {
void native_motion_axes(ControllerProfileNativeJoyconLayout layout, const int32_t source[3],
float scale, float output[3]) {
// Undo rotate_solo_joycon's horizontal SDL normalization, then apply the
// existing upright native mount [X,-Z,Y]. Rotate accel and gyro together.
output[1] = -static_cast<float>(source[2]) * scale;
switch (g_mapped.native_joycon_layout) {
switch (layout) {
case ControllerProfileNativeJoyconLayout::kLeftSolo:
output[0] = static_cast<float>(source[1]) * scale;
output[2] = -static_cast<float>(source[0]) * scale;
@ -133,11 +145,12 @@ void native_motion_axes(const int32_t source[3], float scale, float output[3]) {
void pack_controls(uint8_t instance) {
Child& child = g_children[instance];
const Pair& pair = g_pairs[instance / 2];
child.input = {};
child.input.serial = g_source.state_generation;
if (!g_active || !child.calibrated) return;
child.input.serial = pair.source.state_generation;
if (!pair.active || !child.calibrated) return;
const bool left = probe_model_is_left(instance);
const auto layout = g_mapped.native_joycon_layout;
const auto layout = pair.mapped.native_joycon_layout;
const bool solo = layout != ControllerProfileNativeJoyconLayout::kPaired;
// Leave both USB identities in place. The existing inactive-input protocol
// path emits neutral reports for the unselected child.
@ -145,7 +158,7 @@ void pack_controls(uint8_t instance) {
child.input.active = true;
child.input.native_status = 0x30; // Host feature status is gated per model in main.
child.input.mouse_surface = 0xff; // No optical sensor, clicks, or invented movement.
const ControllerState& state = g_mapped.state;
const ControllerState& state = pair.mapped.state;
if (left) {
child.input.buttons[0] = static_cast<uint8_t>(
((solo ? state.button_east : state.dpad_down) ? 0x01 : 0) |
@ -153,7 +166,7 @@ void pack_controls(uint8_t instance) {
((solo ? state.button_south : state.dpad_left) ? 0x04 : 0) |
((solo ? state.button_west : state.dpad_up) ? 0x08 : 0) |
(state.button_left_shoulder ? 0x10 : 0) |
(state.left_trigger != 0 && state.left_trigger >= g_mapped.left_trigger_digital_threshold ? 0x20 : 0) |
(state.left_trigger != 0 && state.left_trigger >= pair.mapped.left_trigger_digital_threshold ? 0x20 : 0) |
(state.button_select ? 0x40 : 0) | (state.button_left_stick ? 0x80 : 0));
child.input.buttons[1] = static_cast<uint8_t>(
(state.button_capture ? 0x01 : 0) |
@ -166,7 +179,7 @@ void pack_controls(uint8_t instance) {
((solo ? state.button_north : state.button_west) ? 0x04 : 0) |
((solo ? state.button_east : state.button_north) ? 0x08 : 0) |
(state.button_right_shoulder ? 0x10 : 0) |
(state.right_trigger != 0 && state.right_trigger >= g_mapped.right_trigger_digital_threshold ? 0x20 : 0) |
(state.right_trigger != 0 && state.right_trigger >= pair.mapped.right_trigger_digital_threshold ? 0x20 : 0) |
(state.button_start ? 0x40 : 0) |
((solo ? state.button_left_stick : state.button_right_stick) ? 0x80 : 0));
child.input.buttons[1] = static_cast<uint8_t>(
@ -189,66 +202,84 @@ void pack_controls(uint8_t instance) {
child.input.stick[2] = static_cast<uint8_t>(y >> 4);
}
void lose_source(uint32_t now_ms) {
if (g_active) {
Bluepad32SlotSnapshot inactive{};
(void)controller_profile_runtime_transform(g_source.slot, inactive, now_ms, AdapterUsbMode::kSwitch);
g_motion.reset();
for (uint8_t i = 0; i < PROBE_CONTROLLER_COUNT; ++i) {
discard_output(g_children[i]);
bluepad32_input_backend_native_sample_cancel(i);
}
g_sensor_status = -1;
void reset_pair_output(uint8_t pair_index) {
Pair& pair = g_pairs[pair_index];
pair.motion.reset();
pair.sensor_status = -1;
for (uint8_t instance = pair_index * 2; instance < pair_index * 2 + 2; ++instance) {
discard_output(g_children[instance]);
bluepad32_input_backend_native_sample_cancel(instance);
}
g_active = false;
for (Child& child : g_children) child.input = {};
}
void refresh(uint32_t now_ms) {
void lose_source(uint8_t pair_index, uint32_t now_ms) {
Pair& pair = g_pairs[pair_index];
if (pair.active) {
// A physical slot may already belong to the other pair by the time
// this pair observes its loss. Never clear that source's slot-local
// macro/Shift state; its new connection epoch retired our old state.
bool slot_reassigned = false;
for (uint8_t other = 0; other < BLUEPAD32_NATIVE_PAIR_COUNT; ++other) {
if (other == pair_index) continue;
Bluepad32NativeGamepadSnapshot current;
bluepad32_input_backend_native_snapshot(other, &current);
if (current.controller.active && current.slot == pair.source.slot) {
slot_reassigned = true;
break;
}
}
if (!slot_reassigned) {
Bluepad32SlotSnapshot inactive{};
(void)controller_profile_runtime_transform(pair.source.slot, inactive, now_ms, AdapterUsbMode::kSwitch);
}
reset_pair_output(pair_index);
}
pair.active = false;
pair.evaluated = false;
for (uint8_t instance = pair_index * 2; instance < pair_index * 2 + 2; ++instance)
g_children[instance].input = {};
}
void refresh(uint8_t pair_index, uint32_t now_ms) {
Pair& pair = g_pairs[pair_index];
Bluepad32NativeGamepadSnapshot source;
bluepad32_input_backend_native_snapshot(&source);
bluepad32_input_backend_native_snapshot(pair_index, &source);
// Snapshot first: source receipt timestamps must not be ahead of this clock.
const uint32_t now_us = time_us_32();
advance_clock(now_us);
if (!source.controller.active || source.slot >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT ||
now_us - source.received_us >= kInputDeadlineUs) {
lose_source(now_ms);
g_source = source;
g_evaluated = false;
lose_source(pair_index, now_ms);
pair.source = source;
return;
}
const bool changed_connection = !g_active || source.slot != g_source.slot ||
source.controller.connection_generation != g_source.controller.connection_generation;
const bool changed_connection = !pair.active || source.slot != pair.source.slot ||
source.controller.connection_generation != pair.source.controller.connection_generation;
const uint32_t profile_generation = profile_service_database_generation();
// Both polls and both peeks in a paired output round share one profile and
// motion evaluation. A real publication in the same millisecond still wins.
if (!changed_connection && g_evaluated && g_evaluated_ms == now_ms &&
profile_generation == g_profile_generation &&
source.state_generation == g_source.state_generation && source.received_us == g_source.received_us &&
source.accel_sequence == g_source.accel_sequence && source.gyro_sequence == g_source.gyro_sequence &&
source.accel_received_us == g_source.accel_received_us && source.gyro_received_us == g_source.gyro_received_us &&
source.accel_valid == g_source.accel_valid && source.gyro_valid == g_source.gyro_valid &&
source.track_stationary_bias == g_source.track_stationary_bias) return;
// This pair's polls and peeks share one profile and motion evaluation. A
// real publication in the same millisecond still wins, independently of
// the other pair's source updates and endpoint backpressure.
if (!changed_connection && pair.evaluated && pair.evaluated_ms == now_ms &&
profile_generation == pair.profile_generation &&
source.state_generation == pair.source.state_generation && source.received_us == pair.source.received_us &&
source.accel_sequence == pair.source.accel_sequence && source.gyro_sequence == pair.source.gyro_sequence &&
source.accel_received_us == pair.source.accel_received_us && source.gyro_received_us == pair.source.gyro_received_us &&
source.accel_valid == pair.source.accel_valid && source.gyro_valid == pair.source.gyro_valid &&
source.track_stationary_bias == pair.source.track_stationary_bias) return;
if (changed_connection) {
lose_source(now_ms);
g_motion.reset();
for (Child& child : g_children) discard_output(child);
probe_debug_printf("[PROBE] Native gamepad source active in slot %u\n", source.slot);
lose_source(pair_index, now_ms);
probe_debug_printf("[PROBE] Native gamepad pair %u source active in slot %u\n", pair_index, source.slot);
}
g_source = source;
g_active = true;
g_evaluated = true;
g_evaluated_ms = now_ms;
pair.source = source;
pair.active = true;
pair.evaluated = true;
pair.evaluated_ms = now_ms;
// Store the generation observed before transforming: a concurrent storage
// publication must invalidate this result rather than bless an older profile.
g_profile_generation = profile_generation;
const auto previous_layout = g_mapped.native_joycon_layout;
g_mapped = controller_profile_runtime_transform(source.slot, source.controller, now_ms, AdapterUsbMode::kSwitch);
if (g_mapped.native_joycon_layout != previous_layout) {
g_motion.reset();
for (Child& child : g_children) discard_output(child);
g_sensor_status = -1;
}
pair.profile_generation = profile_generation;
const auto previous_layout = pair.mapped.native_joycon_layout;
pair.mapped = controller_profile_runtime_transform(source.slot, source.controller, now_ms, AdapterUsbMode::kSwitch);
if (pair.mapped.native_joycon_layout != previous_layout) reset_pair_output(pair_index);
ControllerProfileRuntimeProfileChangeEvent feedback{};
if (controller_profile_runtime_take_initial_profile_indication(source.slot, &feedback) ||
controller_profile_runtime_take_profile_change(source.slot, &feedback)) {
@ -262,30 +293,37 @@ void refresh(uint32_t now_ms) {
sample.gyro_sequence = source.gyro_sequence;
sample.accel_us = source.accel_received_us;
sample.gyro_us = source.gyro_received_us;
native_motion_axes(source.accel_q13, 1.0f / 8192.0f, sample.accel_g);
native_motion_axes(source.gyro_q10, 1.0f / 1024.0f, sample.gyro_dps);
g_motion.update(now_us, source.controller.connection_generation, sample,
native_motion_axes(pair.mapped.native_joycon_layout, source.accel_q13, 1.0f / 8192.0f, sample.accel_g);
native_motion_axes(pair.mapped.native_joycon_layout, source.gyro_q10, 1.0f / 1024.0f, sample.gyro_dps);
pair.motion.update(now_us, source.controller.connection_generation, sample,
source.track_stationary_bias ? ProbeNativeMotionBias::kTrackStationary :
ProbeNativeMotionBias::kAlreadyCalibrated);
const int status = !sensors_fresh(g_source, now_us) ? 0 : g_motion.ready() ? 2 : 1;
if (status != g_sensor_status) {
g_sensor_status = status;
probe_debug_printf("[PROBE] Native gamepad IMU %s\n", status == 2 ? "ready" :
const int status = !sensors_fresh(source, now_us) ? 0 : pair.motion.ready() ? 2 : 1;
if (status != pair.sensor_status) {
pair.sensor_status = status;
probe_debug_printf("[PROBE] Native gamepad pair %u IMU %s\n", pair_index, status == 2 ? "ready" :
status == 1 ? "waiting for a usable acceleration sample" : "waiting for supported fresh sensors");
}
for (uint8_t i = 0; i < PROBE_CONTROLLER_COUNT; ++i) {
for (uint8_t instance = pair_index * 2; instance < pair_index * 2 + 2; ++instance) {
// Latest-only: a blocked endpoint never queues obsolete controls/IMU.
g_children[i].pending_token = 0;
pack_controls(i);
g_children[instance].pending_token = 0;
pack_controls(instance);
}
}
} // namespace
void probe_native_gamepad_input_init() {
#if SWITCH2_BRIDGE_SOURCE_AUTO
bluepad32_input_backend_select_native_source(nullptr);
bluepad32_input_backend_select_native_source(0, nullptr);
#else
bluepad32_input_backend_select_native_source(kSourceAddress);
bluepad32_input_backend_select_native_source(0, kSourceAddress);
#endif
#if PROBE_CONTROLLER_COUNT == 4
#if SWITCH2_BRIDGE_SECOND_SOURCE_AUTO
bluepad32_input_backend_select_native_source(1, nullptr);
#else
bluepad32_input_backend_select_native_source(1, kSecondSourceAddress);
#endif
#endif
}
@ -319,20 +357,21 @@ void probe_native_gamepad_input_set_native_stream(uint8_t instance, bool enabled
void probe_native_gamepad_input_poll(uint8_t instance, uint32_t now_ms, probe_controller_input* out) {
if (!out) return;
if (instance >= PROBE_CONTROLLER_COUNT) { *out = {}; return; }
refresh(now_ms);
refresh(instance / 2, now_ms);
*out = g_children[instance].input;
}
uint32_t probe_native_gamepad_input_peek_native_report(uint8_t instance, uint32_t now_ms, uint8_t report[63]) {
if (instance >= PROBE_CONTROLLER_COUNT || !report) return 0;
refresh(now_ms);
refresh(instance / 2, now_ms);
const Pair& pair = g_pairs[instance / 2];
Child& child = g_children[instance];
if (!child.enabled || !child.input.active) return 0;
const uint32_t now_us = time_us_32();
const bool motion_ready = (SWITCH2_BRIDGE_IMU_TARGET_MASK & (1u << instance)) != 0 &&
g_motion.ready() && sensors_fresh(g_source, now_us) &&
(!child.have_committed_motion || child.committed_accel_sequence != g_source.accel_sequence ||
child.committed_gyro_sequence != g_source.gyro_sequence);
const bool motion_ready = (SWITCH2_BRIDGE_IMU_TARGET_MASK & (1u << (instance & 1u))) != 0 &&
pair.motion.ready() && sensors_fresh(pair.source, now_us) &&
(!child.have_committed_motion || child.committed_accel_sequence != pair.source.accel_sequence ||
child.committed_gyro_sequence != pair.source.gyro_sequence);
if (child.pending_token && (now_us - child.pending_us >= kOutputDeadlineUs ||
child.pending_motion != motion_ready)) child.pending_token = 0;
if (!child.pending_token) {
@ -340,7 +379,7 @@ uint32_t probe_native_gamepad_input_peek_native_report(uint8_t instance, uint32_
memset(child.pending_report, 0, sizeof(child.pending_report));
child.pending_report[0] = child.counter;
// Source battery level and the virtual controller's USB power are separate.
const unsigned battery_level = (static_cast<unsigned>(g_source.battery) * 9u + 127u) / 255u;
const unsigned battery_level = (static_cast<unsigned>(pair.source.battery) * 9u + 127u) / 255u;
child.pending_report[1] = static_cast<uint8_t>((battery_level << 2) | 0x01u);
memcpy(child.pending_report + 2, child.input.buttons, sizeof(child.input.buttons));
child.pending_report[4] = 7;
@ -351,11 +390,11 @@ uint32_t probe_native_gamepad_input_peek_native_report(uint8_t instance, uint32_
const uint32_t elapsed = child.have_committed_motion ? child.pending_ticks - child.committed_ticks : 1;
const uint16_t wire_elapsed = static_cast<uint16_t>(elapsed <= 0xfff ? elapsed : 1);
child.pending_motion = motion_ready && probe_native_imu_pack(
g_motion.quaternion(), g_motion.acceleration(), static_cast<uint16_t>(child.pending_ticks & 0xfff),
pair.motion.quaternion(), pair.motion.acceleration(), static_cast<uint16_t>(child.pending_ticks & 0xfff),
wire_elapsed, 0, child.pending_report + probe_model_imu_data_offset(instance));
if (child.pending_motion) child.pending_report[probe_model_imu_length_offset(instance)] = 30;
child.pending_accel_sequence = g_source.accel_sequence;
child.pending_gyro_sequence = g_source.gyro_sequence;
child.pending_accel_sequence = pair.source.accel_sequence;
child.pending_gyro_sequence = pair.source.gyro_sequence;
child.pending_us = now_us;
child.pending_token = ++g_report_token;
}
@ -366,20 +405,21 @@ uint32_t probe_native_gamepad_input_peek_native_report(uint8_t instance, uint32_
bool probe_native_gamepad_input_commit_native_report(uint8_t instance, uint32_t token) {
if (instance >= PROBE_CONTROLLER_COUNT || !token) return false;
Child& child = g_children[instance];
const Pair& pair = g_pairs[instance / 2];
// Profile edits/activation need no physical publication to retire a token.
if (!child.enabled || !child.input.active || !g_active || child.pending_token != token ||
profile_service_database_generation() != g_profile_generation) return false;
if (!child.enabled || !child.input.active || !pair.active || child.pending_token != token ||
profile_service_database_generation() != pair.profile_generation) return false;
// Check the live source even when the caller did not poll after a disconnect.
Bluepad32NativeGamepadSnapshot source;
bluepad32_input_backend_native_snapshot(&source);
bluepad32_input_backend_native_snapshot(instance / 2, &source);
const uint32_t now_us = time_us_32();
if (!source.controller.active || source.slot != g_source.slot ||
source.controller.connection_generation != g_source.controller.connection_generation ||
source.state_generation != g_source.state_generation ||
source.accel_sequence != g_source.accel_sequence || source.gyro_sequence != g_source.gyro_sequence ||
source.accel_received_us != g_source.accel_received_us || source.gyro_received_us != g_source.gyro_received_us ||
source.accel_valid != g_source.accel_valid || source.gyro_valid != g_source.gyro_valid ||
source.track_stationary_bias != g_source.track_stationary_bias ||
if (!source.controller.active || source.slot != pair.source.slot ||
source.controller.connection_generation != pair.source.controller.connection_generation ||
source.state_generation != pair.source.state_generation ||
source.accel_sequence != pair.source.accel_sequence || source.gyro_sequence != pair.source.gyro_sequence ||
source.accel_received_us != pair.source.accel_received_us || source.gyro_received_us != pair.source.gyro_received_us ||
source.accel_valid != pair.source.accel_valid || source.gyro_valid != pair.source.gyro_valid ||
source.track_stationary_bias != pair.source.track_stationary_bias ||
now_us - source.received_us >= kInputDeadlineUs || now_us - child.pending_us >= kOutputDeadlineUs ||
(child.pending_motion && !sensors_fresh(source, now_us))) return false;
child.pending_token = 0;

View file

@ -3,7 +3,8 @@
#include "controller_input.h"
#if SWITCH2_BRIDGE_FULL_INPUT
// Core 0 only. One coherent profile/motion evaluation feeds both native children.
// Core 0 only. Each source pair shares one coherent profile/motion evaluation.
// Child instances remain A_R, A_L, B_R, B_L; transport state is child-local.
void probe_native_gamepad_input_init();
void probe_native_gamepad_input_set_stick_calibration(uint8_t instance, const uint8_t calibration[9]);
void probe_native_gamepad_input_set_native_stream(uint8_t instance, bool enabled);

View file

@ -6,6 +6,20 @@ set(PICO_MBEDTLS_CONFIG_FILE "${SWITCH2_USB_PROBE_DIR}/mbedtls_config.h")
option(SWITCH2_PROBE_COMPOSITE
"Experiment: independent right and left Joy-Con 2 functions on one USB port" OFF)
option(SWITCH2_PROBE_HUB "Native R/L devices on the built-in SIO USB hub" OFF)
set(SWITCH2_PROBE_PAIR_COUNT "1" CACHE STRING "Native hub pair count: 1 or 2")
set_property(CACHE SWITCH2_PROBE_PAIR_COUNT PROPERTY STRINGS 1 2)
option(SWITCH2_PROBE_NEUTRAL_INPUT "Standalone hub transport experiment with neutral reports and no Bluetooth" OFF)
if(NOT "${SWITCH2_PROBE_PAIR_COUNT}" MATCHES "^[12]$")
message(FATAL_ERROR "SWITCH2_PROBE_PAIR_COUNT must be 1 or 2")
endif()
if(SWITCH2_PROBE_NEUTRAL_INPUT AND (NOT SWITCH2_PROBE_HUB OR SWITCH_PICO_SWITCH2_USB_BRIDGE))
message(FATAL_ERROR "Neutral transport requires the standalone native HUB, without the Bluetooth bridge")
endif()
if(SWITCH2_PROBE_PAIR_COUNT GREATER 1 AND
(NOT SWITCH2_PROBE_HUB OR
(NOT SWITCH2_PROBE_NEUTRAL_INPUT AND NOT SWITCH2_BRIDGE_FULL_INPUT)))
message(FATAL_ERROR "Two pairs require a native HUB with GAMEPAD/DUALSENSE input or explicit neutral transport")
endif()
if(SWITCH2_PROBE_HUB AND SWITCH2_PROBE_COMPOSITE)
message(FATAL_ERROR "Select native hub or composite, not both")
endif()
@ -24,15 +38,20 @@ else()
message(FATAL_ERROR "SWITCH2_PROBE_SIDE must be LEFT or RIGHT")
endif()
if(SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB)
if(NOT SWITCH_PICO_SWITCH2_USB_BRIDGE OR SWITCH2_BRIDGE_WII_INPUT
OR (NOT SWITCH2_BRIDGE_INPUT STREQUAL "JOYCON2" AND NOT SWITCH2_BRIDGE_FULL_INPUT))
message(FATAL_ERROR "Native R/L output requires JOYCON2 input or a full-controller HUB source")
if(NOT SWITCH2_PROBE_NEUTRAL_INPUT AND
(NOT SWITCH_PICO_SWITCH2_USB_BRIDGE OR SWITCH2_BRIDGE_WII_INPUT
OR (NOT SWITCH2_BRIDGE_INPUT STREQUAL "JOYCON2" AND NOT SWITCH2_BRIDGE_FULL_INPUT)))
message(FATAL_ERROR "Native R/L output requires JOYCON2 input, a full-controller HUB source, or standalone neutral transport")
endif()
set(probe_composite 0)
if(SWITCH2_PROBE_COMPOSITE)
set(probe_composite 1)
endif()
set(probe_controller_count 2)
if(SWITCH2_PROBE_HUB)
math(EXPR probe_controller_count "2 * ${SWITCH2_PROBE_PAIR_COUNT}")
else()
set(probe_controller_count 2)
endif()
else()
set(probe_composite 0)
set(probe_controller_count 1)
@ -45,20 +64,35 @@ add_compile_definitions(
SWITCH2_PROBE_JOYCON_LEFT=${probe_joycon_left}
SWITCH2_PROBE_COMPOSITE=${probe_composite}
SWITCH2_PROBE_HUB=$<BOOL:${SWITCH2_PROBE_HUB}>
SWITCH2_PROBE_NEUTRAL_INPUT=$<BOOL:${SWITCH2_PROBE_NEUTRAL_INPUT}>
PROBE_CONTROLLER_COUNT=${probe_controller_count})
set(SWITCH2_BRIDGE_SOURCE_ADDRESS "" CACHE STRING
"Primary physical Bluetooth source address (xx:xx:xx:xx:xx:xx)")
set(SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS "" CACHE STRING
"Secondary left physical Bluetooth source address (xx:xx:xx:xx:xx:xx)")
"Second physical Joy-Con source, or Pair B full-gamepad source (empty selects auto)")
set(SWITCH2_BRIDGE_SOURCE_AUTO OFF)
set(SWITCH2_BRIDGE_SECOND_SOURCE_AUTO OFF)
if(SWITCH_PICO_SWITCH2_USB_BRIDGE OR SWITCH2_PROBE_COMPOSITE)
set(probe_source_fields SWITCH2_BRIDGE_SOURCE_ADDRESS)
if(SWITCH2_BRIDGE_FULL_INPUT AND SWITCH2_BRIDGE_SOURCE_ADDRESS STREQUAL "")
set(SWITCH2_BRIDGE_SOURCE_AUTO ON)
set(probe_source_fields "")
elseif((SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB) AND NOT SWITCH2_BRIDGE_FULL_INPUT)
list(APPEND probe_source_fields SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS)
set(probe_source_fields "")
if(SWITCH2_BRIDGE_FULL_INPUT)
if(SWITCH2_BRIDGE_SOURCE_ADDRESS STREQUAL "")
set(SWITCH2_BRIDGE_SOURCE_AUTO ON)
else()
list(APPEND probe_source_fields SWITCH2_BRIDGE_SOURCE_ADDRESS)
endif()
if(probe_controller_count GREATER 2)
if(SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS STREQUAL "")
set(SWITCH2_BRIDGE_SECOND_SOURCE_AUTO ON)
else()
list(APPEND probe_source_fields SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS)
endif()
endif()
else()
list(APPEND probe_source_fields SWITCH2_BRIDGE_SOURCE_ADDRESS)
if(SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB)
list(APPEND probe_source_fields SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS)
endif()
endif()
set(probe_source_addresses "")
foreach(field IN LISTS probe_source_fields)
@ -71,7 +105,7 @@ if(SWITCH_PICO_SWITCH2_USB_BRIDGE OR SWITCH2_PROBE_COMPOSITE)
message(FATAL_ERROR "Provide ${field} as a physical six-byte Bluetooth address")
endif()
if(source_address IN_LIST probe_source_addresses)
message(FATAL_ERROR "Composite physical source addresses must be distinct")
message(FATAL_ERROR "Physical source addresses must be distinct")
endif()
list(APPEND probe_source_addresses "${source_address}")
string(REPLACE ":" ",0x" ${field}_BYTES "${source_address}")
@ -79,6 +113,7 @@ if(SWITCH_PICO_SWITCH2_USB_BRIDGE OR SWITCH2_PROBE_COMPOSITE)
endforeach()
endif()
add_compile_definitions(SWITCH2_BRIDGE_SOURCE_AUTO=$<BOOL:${SWITCH2_BRIDGE_SOURCE_AUTO}>)
add_compile_definitions(SWITCH2_BRIDGE_SECOND_SOURCE_AUTO=$<BOOL:${SWITCH2_BRIDGE_SECOND_SOURCE_AUTO}>)
function(switch2_usb_probe_configure target)
set(probe_sources
@ -101,8 +136,8 @@ function(switch2_usb_probe_configure target)
option(SWITCH2_PROBE_TRACE_NATIVE_INPUT
"Trace one completed native USB input report per second without changing its contents" OFF)
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
if(NOT SWITCH_PICO_SWITCH2_USB_BRIDGE)
message(FATAL_ERROR "Native input tracing requires the Bluetooth USB bridge")
if(NOT SWITCH_PICO_SWITCH2_USB_BRIDGE AND NOT SWITCH2_PROBE_NEUTRAL_INPUT)
message(FATAL_ERROR "Native input tracing requires the Bluetooth bridge or neutral hub experiment")
endif()
target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_TRACE_NATIVE_INPUT=1)
endif()
@ -161,7 +196,7 @@ function(switch2_usb_probe_configure target)
target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_ZERO_NATIVE_IMU_PAYLOAD=1)
endif()
foreach(prefix IN ITEMS SWITCH2_PROBE SWITCH2_PROBE_SECOND)
foreach(prefix IN ITEMS SWITCH2_PROBE SWITCH2_PROBE_SECOND SWITCH2_PROBE_THIRD SWITCH2_PROBE_FOURTH)
set(${prefix}_IDENTITY_FILE "" CACHE FILEPATH "64-byte matching Joy-Con 2 factory-format identity block")
set(${prefix}_VERSION_FILE "" CACHE FILEPATH "Captured 12-byte matching Joy-Con 2 firmware-version reply")
set(${prefix}_CONTROLLER_ADDRESS "" CACHE STRING "Advertised controller address (captured or distinct virtual identity)")
@ -183,18 +218,25 @@ function(switch2_usb_probe_configure target)
target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_USB_INIT=1)
endif()
if(SWITCH2_PROBE_NEUTRAL_INPUT AND NOT SWITCH2_PROBE_USB_INIT)
message(FATAL_ERROR "Neutral transport requires SWITCH2_PROBE_USB_INIT and verified identity/calibration captures")
endif()
set(probe_capture_prefixes SWITCH2_PROBE)
if(SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB)
list(APPEND probe_capture_prefixes SWITCH2_PROBE_SECOND)
endif()
if(probe_controller_count EQUAL 4)
list(APPEND probe_capture_prefixes SWITCH2_PROBE_THIRD SWITCH2_PROBE_FOURTH)
endif()
set(identity_rows "")
set(status_rows "")
set(firmware_rows "")
set(factory_rows "")
set(user_calibration_rows "")
set(controller_addresses "")
set(controller_identities "")
foreach(prefix IN LISTS probe_capture_prefixes)
if(probe_joycon_left OR prefix STREQUAL "SWITCH2_PROBE_SECOND")
if(probe_joycon_left OR prefix STREQUAL "SWITCH2_PROBE_SECOND" OR prefix STREQUAL "SWITCH2_PROBE_FOURTH")
set(probe_model "Joy-Con 2 (L)")
set(probe_vid_pid "7e056720")
set(probe_firmware_type "00")
@ -206,7 +248,7 @@ function(switch2_usb_probe_configure target)
if(SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB)
foreach(field IDENTITY_FILE VERSION_FILE FACTORY_FILE USER_CALIBRATION_FILE CONTROLLER_ADDRESS)
if(NOT ${prefix}_${field})
message(FATAL_ERROR "Composite ${probe_model} requires ${prefix}_${field}")
message(FATAL_ERROR "Native ${probe_model} requires ${prefix}_${field}")
endif()
endforeach()
endif()
@ -217,6 +259,10 @@ function(switch2_usb_probe_configure target)
message(FATAL_ERROR "${prefix}_IDENTITY_FILE must contain exactly 64 bytes")
endif()
string(TOLOWER "${identity_hex}" identity_hex)
if(identity_hex IN_LIST controller_identities)
message(FATAL_ERROR "Native child factory identities must be distinct")
endif()
list(APPEND controller_identities "${identity_hex}")
string(SUBSTRING "${identity_hex}" 36 8 identity_vid_pid)
if(NOT identity_vid_pid STREQUAL probe_vid_pid)
message(FATAL_ERROR "${prefix}_IDENTITY_FILE must match selected model ${probe_model}")
@ -247,7 +293,7 @@ function(switch2_usb_probe_configure target)
message(FATAL_ERROR "Provide ${prefix}_CONTROLLER_ADDRESS as a six-byte advertised Bluetooth address")
endif()
if(address_hex IN_LIST controller_addresses)
message(FATAL_ERROR "Composite advertised controller addresses must be distinct")
message(FATAL_ERROR "Advertised controller addresses must be distinct")
endif()
list(APPEND controller_addresses "${address_hex}")
set(address_reversed "")
@ -354,7 +400,9 @@ function(switch2_usb_probe_configure target)
endif()
pico_enable_stdio_usb(${target} 0)
pico_enable_stdio_uart(${target} 1)
if(SWITCH2_PROBE_HUB)
if(SWITCH2_PROBE_NEUTRAL_INPUT)
pico_set_program_name(${target} "Native Joy-Con 2 neutral transport experiment")
elseif(SWITCH2_PROBE_HUB)
pico_set_program_name(${target} "Native Joy-Con 2 R and L stock USB hub bridge")
elseif(SWITCH2_PROBE_COMPOSITE)
pico_set_program_name(${target} "Switch 2 right and left Joy-Con composite bridge")
@ -367,17 +415,29 @@ function(switch2_usb_probe_configure target)
else()
pico_set_program_name(${target} "Switch 2 USB initialization capture")
endif()
if(SWITCH2_PROBE_HUB AND SWITCH2_BRIDGE_FULL_INPUT)
if(SWITCH2_PROBE_NEUTRAL_INPUT)
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
pico_set_program_version(${target} "0.89-native-digital-stick-trace")
pico_set_program_version(${target} "0.97-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair-trace")
else()
pico_set_program_version(${target} "0.89-native-digital-stick")
pico_set_program_version(${target} "0.97-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair")
endif()
elseif(SWITCH2_PROBE_HUB AND SWITCH2_BRIDGE_FULL_INPUT)
if(probe_controller_count GREATER 2)
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
pico_set_program_version(${target} "0.97-live-two-pair-trace")
else()
pico_set_program_version(${target} "0.97-live-two-pair")
endif()
elseif(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
pico_set_program_version(${target} "0.97-native-digital-stick-trace")
else()
pico_set_program_version(${target} "0.97-native-digital-stick")
endif()
elseif(SWITCH2_PROBE_HUB)
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
pico_set_program_version(${target} "0.89-native-hub-profiles-trace")
pico_set_program_version(${target} "0.97-native-hub-profiles-trace")
else()
pico_set_program_version(${target} "0.89-native-hub-profiles")
pico_set_program_version(${target} "0.97-native-hub-profiles")
endif()
elseif(SWITCH2_PROBE_JOIN_CHORD_GATE)
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)

View file

@ -17,16 +17,18 @@ namespace {
constexpr size_t kSlotCount = 2;
constexpr size_t kMaximumPayloadSize = 512;
constexpr size_t kStorageSize = kSlotCount * FLASH_SECTOR_SIZE;
constexpr size_t kReservedStorageSize = 2 * kStorageSize;
constexpr size_t kReservedBankCount = PROBE_CONTROLLER_COUNT > 2 ? PROBE_CONTROLLER_COUNT : 2;
constexpr size_t kReservedStorageSize = kReservedBankCount * kStorageSize;
constexpr size_t kConfigurationStorageSize =
CONFIGURATION_STORAGE_COPY_COUNT * FLASH_SECTOR_SIZE;
constexpr size_t kConfigurationStorageOffset =
PICO_FLASH_BANK_STORAGE_OFFSET - kConfigurationStorageSize;
constexpr size_t kProfileStorageOffset =
kConfigurationStorageOffset - PROFILE_STORAGE_TOTAL_SIZE;
// Keep the original right bank adjacent to profiles; reserve the left bank below.
// Preserve the original R/L banks; each additional child takes the next lower bank.
constexpr uint32_t kRightStorageOffset = kProfileStorageOffset - kStorageSize;
constexpr uint32_t kLeftStorageOffset = kRightStorageOffset - kStorageSize;
constexpr uint32_t kReservedStorageOffset = kProfileStorageOffset - kReservedStorageSize;
constexpr uint32_t kFlashSafeTimeoutMs = 5000;
constexpr uint32_t kFormatVersion = 1;
@ -74,7 +76,7 @@ static_assert(PICO_FLASH_BANK_STORAGE_OFFSET >=
"pairing storage offset underflows flash");
static_assert(kLeftStorageOffset + kStorageSize == kRightStorageOffset);
static_assert(kRightStorageOffset + kStorageSize == kProfileStorageOffset);
static_assert(kLeftStorageOffset + kReservedStorageSize == kProfileStorageOffset);
static_assert(kReservedStorageOffset + kReservedStorageSize == kProfileStorageOffset);
static_assert(kProfileStorageOffset + PROFILE_STORAGE_TOTAL_SIZE ==
kConfigurationStorageOffset);
static_assert(kConfigurationStorageOffset + kConfigurationStorageSize ==
@ -128,11 +130,11 @@ bool is_erased(const uint8_t *bytes, size_t size) {
bool storage_region_available(uint32_t storage_offset) {
const uintptr_t binary_end = reinterpret_cast<uintptr_t>(&__flash_binary_end);
return binary_end >= XIP_BASE &&
binary_end - XIP_BASE <= kLeftStorageOffset &&
binary_end - XIP_BASE <= kReservedStorageOffset &&
storage_offset % FLASH_SECTOR_SIZE == 0 &&
storage_offset <= PICO_FLASH_SIZE_BYTES &&
kStorageSize <= PICO_FLASH_SIZE_BYTES - storage_offset &&
storage_offset >= kLeftStorageOffset &&
storage_offset >= kReservedStorageOffset &&
storage_offset + kStorageSize <= kProfileStorageOffset;
}
@ -385,5 +387,9 @@ bool probe_storage_save(uint8_t instance, const uint8_t *data, size_t size) {
uint32_t probe_storage_offset(uint8_t instance) {
if (instance >= PROBE_CONTROLLER_COUNT) return UINT32_MAX;
#if SWITCH2_PROBE_COMPOSITE || SWITCH2_PROBE_HUB
return static_cast<uint32_t>(kRightStorageOffset - instance * kStorageSize);
#else
return probe_model_is_left(instance) ? kLeftStorageOffset : kRightStorageOffset;
#endif
}

View file

@ -19,9 +19,11 @@ bool probe_storage_load(uint8_t instance, uint8_t *output, size_t size);
bool probe_storage_save(uint8_t instance, const uint8_t *data, size_t size);
// Flash-relative offset of the instance's two-sector pairing bank, or UINT32_MAX
// for an invalid instance. The right bank remains immediately below profile
// storage; the left bank occupies the preceding two sectors. Both are reserved
// in every build, and load/save inspect and mutate only the selected bank.
// for an invalid instance. Pair A's right bank remains immediately below profile
// storage and its left bank immediately below that; standalone selects the same
// bank for its side. Both original banks are always reserved. Four-child hubs
// reserve two more banks below pair A, ordered pair B right then left. Firmware
// must fit below the entire reserved range; load/save touch only the selected bank.
uint32_t probe_storage_offset(uint8_t instance);
#ifdef __cplusplus

View file

@ -85,7 +85,8 @@ static inline bool probe_transport_control_xfer(uint8_t rhport,
const tusb_control_request_t* request,
void* buffer, uint16_t length) {
#if SWITCH2_PROBE_HUB
return native_hub_control_xfer(rhport, request, buffer, length);
return native_hub_control_xfer(rhport, request, buffer, length,
(request->bmRequestType & 0x80u) != 0);
#else
return tud_control_xfer(rhport, request, buffer, length);
#endif

View file

@ -27,6 +27,8 @@
#ifdef __cplusplus
extern "C" {
#endif
// Returns the formatted length when queued, or -1 if the whole message cannot
// fit. A diagnostic consumer may retry later; native logging never masks IRQs.
int probe_debug_printf(const char* format, ...);
#ifdef __cplusplus
}