fix(native-usb): isolate two-pair transport and hand off read status in IRQ
This commit is contained in:
parent
c26ea25f93
commit
9f8678af96
51 changed files with 8159 additions and 815 deletions
|
|
@ -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
476
README.md
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
104
tests/native_hub_log_test.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
160
tests/native_hub_router_test.c
Normal file
160
tests/native_hub_router_test.c
Normal 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;
|
||||
}
|
||||
6
tests/native_hub_stubs/hardware/uart.h
Normal file
6
tests/native_hub_stubs/hardware/uart.h
Normal 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);
|
||||
1
tests/native_hub_stubs/pico.h
Normal file
1
tests/native_hub_stubs/pico.h
Normal file
|
|
@ -0,0 +1 @@
|
|||
#include "hardware_stub.h"
|
||||
2230
tests/native_hub_trace_test.c
Normal file
2230
tests/native_hub_trace_test.c
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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)
|
||||
|
|
@ -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);
|
||||
|
|
@ -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))
|
||||
296
tests/switch2_usb_probe_storage_test.cpp
Normal file
296
tests/switch2_usb_probe_storage_test.cpp
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
53
tests/test_native_hub_log_native.py
Normal file
53
tests/test_native_hub_log_native.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
95
tests/test_native_hub_trace_native.py
Normal file
95
tests/test_native_hub_trace_native.py
Normal 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"
|
||||
)
|
||||
245
tests/test_native_joycon_hub_live.py
Normal file
245
tests/test_native_joycon_hub_live.py
Normal 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()
|
||||
315
tests/test_native_joycon_hub_recovery.py
Normal file
315
tests/test_native_joycon_hub_recovery.py
Normal 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"]
|
||||
357
tests/test_native_joycon_hub_trace.py
Normal file
357
tests/test_native_joycon_hub_trace.py
Normal 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"]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
75
tests/test_switch2_usb_probe_storage_native.py
Normal file
75
tests/test_switch2_usb_probe_storage_native.py
Normal 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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, ¤t);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue