From 9f8678af96b18fc0f6ef34ab2884a9260eccf4bb Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 17 Sep 2026 20:24:32 -0600 Subject: [PATCH] fix(native-usb): isolate two-pair transport and hand off read status in IRQ --- CMakeLists.txt | 4 +- README.md | 476 ++++ .../input/bluepad32_input_backend.cpp | 291 ++- src/firmware/input/bluepad32_input_backend.h | 23 +- src/firmware/platform/pico/system_clock.h | 12 +- src/firmware/usb/native_hub/native_hub.c | 667 +++-- src/firmware/usb/native_hub/native_hub.h | 16 +- .../usb/native_hub/native_hub_trace.h | 19 +- .../usb/usb_configuration_management.cpp | 5 +- tests/native_gamepad_backend_test.cpp | 395 ++- tests/native_hub_log_test.c | 104 + tests/native_hub_management_test.cpp | 514 +++- tests/native_hub_router_test.c | 160 ++ tests/native_hub_stubs/hardware/uart.h | 6 + tests/native_hub_stubs/pico.h | 1 + tests/native_hub_trace_test.c | 2230 +++++++++++++++++ tests/native_hub_transport_fixture.c | 94 +- tests/switch2_mouse_bridge_test.cpp | 1 + tests/switch2_native_gamepad_bridge_test.cpp | 410 ++- tests/switch2_usb_probe_protocol_test.c | 255 +- .../hardware/flash.h | 11 + .../pico/btstack_flash_bank.h | 6 + .../pico/flash.h | 7 + .../pico/platform.h | 8 + tests/switch2_usb_probe_storage_test.cpp | 296 +++ tests/test_native_gamepad_backend_native.py | 39 +- tests/test_native_hub_log_native.py | 53 + tests/test_native_hub_management_native.py | 42 +- tests/test_native_hub_trace_native.py | 95 + tests/test_native_joycon_hub_live.py | 245 ++ tests/test_native_joycon_hub_recovery.py | 315 +++ tests/test_native_joycon_hub_trace.py | 357 +++ ...st_switch2_native_gamepad_bridge_native.py | 3 + .../test_switch2_usb_probe_protocol_native.py | 42 +- .../test_switch2_usb_probe_storage_native.py | 75 + tools/native_joycon_hub_check.py | 1149 +++++++-- tools/pico_usb_address_probe/router.c | 32 +- tools/pico_usb_address_probe/router.h | 4 + tools/switch2_usb_probe/CMakeLists.txt | 15 + tools/switch2_usb_probe/bootsel.cpp | 6 +- tools/switch2_usb_probe/bootsel.h | 4 +- tools/switch2_usb_probe/descriptors.h | 4 + tools/switch2_usb_probe/main.c | 86 +- tools/switch2_usb_probe/model.h | 31 +- .../native_gamepad_input.cpp | 226 +- .../switch2_usb_probe/native_gamepad_input.h | 3 +- tools/switch2_usb_probe/probe_build.cmake | 108 +- tools/switch2_usb_probe/storage.cpp | 16 +- tools/switch2_usb_probe/storage.h | 8 +- tools/switch2_usb_probe/transport.h | 3 +- tools/switch2_usb_probe/tusb_config.h | 2 + 51 files changed, 8159 insertions(+), 815 deletions(-) create mode 100644 tests/native_hub_log_test.c create mode 100644 tests/native_hub_router_test.c create mode 100644 tests/native_hub_stubs/hardware/uart.h create mode 100644 tests/native_hub_stubs/pico.h create mode 100644 tests/native_hub_trace_test.c create mode 100644 tests/switch2_usb_probe_storage_native_stubs/hardware/flash.h create mode 100644 tests/switch2_usb_probe_storage_native_stubs/pico/btstack_flash_bank.h create mode 100644 tests/switch2_usb_probe_storage_native_stubs/pico/flash.h create mode 100644 tests/switch2_usb_probe_storage_native_stubs/pico/platform.h create mode 100644 tests/switch2_usb_probe_storage_test.cpp create mode 100644 tests/test_native_hub_log_native.py create mode 100644 tests/test_native_hub_trace_native.py create mode 100644 tests/test_native_joycon_hub_live.py create mode 100644 tests/test_native_joycon_hub_recovery.py create mode 100644 tests/test_native_joycon_hub_trace.py create mode 100644 tests/test_switch2_usb_probe_storage_native.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 9273c80..d17fe79 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/README.md b/README.md index d7f6f23..5de8b59 100644 --- a/README.md +++ b/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, diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 45469c5..7dc81fa 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -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(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(~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(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(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(1u << binding.slot); + if (selected[pair] != 0xff) retired_slots |= static_cast(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(); diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index 079c076..9767e4a 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -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. diff --git a/src/firmware/platform/pico/system_clock.h b/src/firmware/platform/pico/system_clock.h index 749c936..8982984 100644 --- a/src/firmware/platform/pico/system_clock.h +++ b/src/firmware/platform/pico/system_clock.h @@ -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 diff --git a/src/firmware/usb/native_hub/native_hub.c b/src/firmware/usb/native_hub/native_hub.c index 56f99a0..00dadfb 100644 --- a/src/firmware/usb/native_hub/native_hub.c +++ b/src/firmware/usb/native_hub/native_hub.c @@ -1,4 +1,4 @@ -// Native RP2350 SIE transport for an embedded hub and two Joy-Con devices. +// Native RP2350 SIE transport for an embedded hub and two or four Joy-Cons. // Core1 selects address, endpoint controls and receive buffers. Core0 publishes // transmit buffers and owns protocols/IRQ completions; IN endpoints NAK until ready. #include "native_hub.h" @@ -23,9 +23,12 @@ #ifndef NATIVE_HUB_SAMPLE_PHASE #define NATIVE_HUB_SAMPLE_PHASE 4u #endif -#define DEVICES 3u +#define CHILDREN PROBE_CONTROLLER_COUNT +#define DEVICES (CHILDREN + 1u) #define CHANNELS 6u #define PACKET 64u +#define PRIVATE_CHANNELS (CHANNELS - 2u) +#define PRIVATE_DATA_BASE 0x180u #define EVENTS 64u #define NONE 255u #define CONNECT 1u @@ -38,6 +41,14 @@ #define C_SUSPEND 4u #define C_RESET 16u +_Static_assert(CHILDREN == 2u || CHILDREN == 4u, "Native hub supports one or two pairs"); +_Static_assert(DEVICES == PROBE_ROUTER_SLOTS, "Hub and router slot counts must match"); +_Static_assert(PRIVATE_DATA_BASE % PACKET == 0u, "Private buffers must be packet aligned"); +_Static_assert(offsetof(usb_device_dpram_t, ep0_buf_a) + PACKET <= PRIVATE_DATA_BASE, + "Private banks must not overlap EP0"); +_Static_assert(PRIVATE_DATA_BASE + DEVICES * PRIVATE_CHANNELS * PACKET <= USB_DPRAM_SIZE, + "Native hub endpoint banks must fit USB DPRAM"); + typedef enum { IDLE, DATA_IN, DATA_OUT, STATUS_IN, STATUS_OUT, STALLED } stage_t; typedef enum { NO_ACTION, ADDRESS, CONFIGURE, PORT_SET, PORT_CLEAR, HID_SET_REPORT, HID_IDLE, HID_PROTOCOL, ENDPOINT_HALT, ENDPOINT_CLEAR } action_t; @@ -47,6 +58,7 @@ typedef struct { bool busy, flush, zlp; uint8_t next_pid; bool halted; + bool status_out_on_complete; } endpoint_t; typedef struct { tusb_control_request_t request; @@ -55,7 +67,7 @@ typedef struct { uint16_t length, position, packet_length; stage_t stage; action_t action; - bool zlp, vendor; + bool zlp, vendor, read_status_preapproved; uint32_t generation; } control_t; typedef struct { @@ -73,18 +85,22 @@ typedef struct { typedef struct { uint16_t status, change; uint32_t deadline; } port_t; typedef struct { uint8_t device, channel, kind; + bool status_out_armed; uint16_t length; uint32_t generation; uint32_t reset_generation; +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + uint32_t trace_cycle; + uint32_t status_out_arm_cycle; +#endif uint8_t data[64]; } event_t; static device_t devices[DEVICES]; -static port_t ports[2]; +static port_t ports[CHILDREN]; static uint8_t addresses[DEVICES]; static uint8_t default_device; static volatile uint8_t active_device; -static volatile bool bank_restore_pending; static spin_lock_t* bank_lock; static event_t events[EVENTS]; static volatile uint32_t event_head, event_tail; @@ -106,6 +122,12 @@ static char root_serial[48]; #define OUT_TRACE_RETAIN 64u #define OUT_TRACE_STALL_US 200000u #define OUT_TRACE_LINE_US 50000u +#define OUT_TRACE_SNAPSHOTS 2u + +enum { OUT_TRACE_IDLE, OUT_TRACE_PENDING, OUT_TRACE_SUPERSEDED, OUT_TRACE_HOST }; +enum { OUT_TRACE_FIRST_IN_ARMED = 1u, OUT_TRACE_FIRST_IN_COMPLETED = 2u }; +enum { OUT_TRACE_STATUS_OUT_ARMED = 1u, OUT_TRACE_STATUS_OUT_COMPLETED = 2u }; +enum { OUT_TRACE_TOKEN_IN = 1u, OUT_TRACE_TOKEN_OUT = 2u }; enum { OUT_TRACE_LOCK = 1u, @@ -113,21 +135,30 @@ enum { OUT_TRACE_SETUP = 4u, OUT_TRACE_GUARD = 8u, OUT_TRACE_INVALID = 16u, + OUT_TRACE_AFTER_ARM = 32u, // Device-IN observation after notification, not acceptance. }; typedef struct { uint32_t cursor, cutoff, clock_before, clock_after, sof; uint32_t address_before, address_after, in0, out0, buffers, sie, sm, ints; uint32_t blocked_buffers, blocked_sie, missed_lock, irq_enter, irq_exit, core0_phase; - uint32_t ep0_word; + uint32_t ep0_word, address_cycle; + uint32_t publication_sequence, rx_error; uint8_t address, owner, owner_before, owner_after, selected, reason, pid, before_valid; } out_trace_record_t; typedef struct { - uint32_t time_us, quiet_us, cursor, count, input[2], cycle, sof; + uint32_t time_us, quiet_us, cursor, count, input[CHILDREN], cycle, sof; uint32_t address, owner, out0, buffers, sie, sm, ints, intr, inte; uint32_t tx_error, rx_error, irq_enter, irq_exit, core0_phase; - uint32_t log_max_us, log_max_us_bytes, log_max_bytes, log_nested; uint32_t control_slot, control_generation, control_stage, control_position, control_length; uint32_t control_word, in0, ep0_word; + uint32_t reason, setup_cycle, first_in_arm_cycle, first_in_complete_cycle; + uint32_t first_in_sequence; + uint16_t first_in_arm_length, first_in_length; + uint8_t first_in_flags, first_in_pid; + uint32_t device_generation, control_shadow_in, control_shadow_out; + uint32_t status_out_arm_cycle, status_out_complete_cycle; + uint16_t status_out_length; + uint8_t status_out_flags; tusb_control_request_t control_request; } out_trace_header_t; @@ -141,27 +172,48 @@ typedef struct { // has already been published, and at most that one iteration can still write. // Core0 therefore reads only the 64 slots BEFORE its acquired cursor, never // the possible in-flight 65th slot. Ordinary record reads/writes cannot race. -// No rearm occurs until the dump reader has finished; cursors are never reset. +// Core0 copies those records into an immutable snapshot before rearming the +// producer. UART draining never reads the live ring or holds it frozen. static out_trace_record_t out_trace_records[OUT_TRACE_SLOTS]; static uint32_t out_trace_cursor, out_trace_frozen; static uint32_t out_trace_irq_enter, out_trace_irq_exit; static uint32_t out_trace_last_missed_lock; // Core1, updated after every rejected selection. +static uint32_t out_trace_last_switches; // Core1, updated after every successful selection. +static uint8_t out_trace_first_tokens[DEVICES]; // Core1: first IN/OUT after SETUP. +// Core0 releases a notification after each child's successful first EP0 IN arm. +// Core1 alone owns seen values. Equality detects updates across uint32_t wrap; +// sequence zero is valid when OUT_TRACE_AFTER_ARM marks a retained observation. +static uint32_t out_trace_publication_sequence[CHILDREN]; +static uint32_t out_trace_seen_publication_sequence[CHILDREN]; +static uint32_t out_trace_address_cycle; // Core1: most recent selector address write. static uint32_t out_trace_core0_phase; -// Core0 alone owns logger metrics and dump/IN-progress state. IRQ code never -// logs, and Core1 does not read these fields, so they need no cross-core atomics. -static uint32_t out_trace_log_max_us, out_trace_log_max_us_bytes; -static uint32_t out_trace_log_max_bytes, out_trace_log_nested; -static out_trace_header_t out_trace_header; -static uint32_t out_trace_input[2], out_trace_last_input_us, out_trace_last_line_us; -static uint32_t out_trace_dump_line, out_trace_dump_slot; -static bool out_trace_seen_input, out_trace_dumping; +// Core0 owns snapshots, UART draining and control/IN-progress watches. Neither +// IRQ nor Core1 logs, allocates, or waits for diagnostic output. +typedef struct { + out_trace_header_t header; + out_trace_record_t records[OUT_TRACE_RETAIN]; +} out_trace_snapshot_t; +static out_trace_snapshot_t out_trace_snapshots[OUT_TRACE_SNAPSHOTS]; +static uint32_t out_trace_snapshot_head, out_trace_snapshot_count, out_trace_snapshot_drops; +static uint32_t out_trace_input[CHILDREN], out_trace_last_input_us, out_trace_last_line_us; +static uint32_t out_trace_dump_line; +static bool out_trace_seen_input; typedef struct { uint32_t generation, since_us; stage_t stage; uint16_t position; + bool captured; + uint32_t setup_cycle, first_in_arm_cycle, first_in_complete_cycle; + uint32_t first_in_sequence; + uint16_t first_in_arm_length, first_in_length; + uint8_t first_in_flags, first_in_pid; + uint32_t status_out_arm_cycle, status_out_complete_cycle; + uint16_t status_out_length; + uint8_t status_out_flags; } control_trace_watch_t; static control_trace_watch_t out_trace_controls[DEVICES]; +static const out_trace_header_t* out_trace_snapshot(uint32_t now, uint8_t control_slot, uint32_t reason); #endif static const uint8_t hub_device[] = { @@ -172,7 +224,11 @@ static const uint8_t hub_configuration[] = { // batteries. Do not advertise unimplemented high-speed TT/remote wake. 9,2,25,0,1,1,0,0xc0,250, 9,4,0,0,1,9,0,0,0, 7,5,0x8f,3,1,0,12 }; -static const uint8_t hub_descriptor[] = {9,0x29,2,0x11,0,5,100,6,255}; +// One bitmap byte covers the hub and all ports. Children are non-removable; +// the reserved hub bit is clear, and the legacy power-control mask stays 0xff. +static const uint8_t hub_descriptor[] = { + 9,0x29,CHILDREN,0x11,0,5,100,(1u << DEVICES)-2u,255 +}; static inline volatile uint32_t* buffer_regs(void) { return (volatile uint32_t*)&usb_dpram->ep_buf_ctrl[0]; @@ -181,7 +237,7 @@ static inline volatile uint32_t* endpoint_regs(void) { return (volatile uint32_t*)&usb_dpram->ep_ctrl[0]; } static inline uint32_t data_offset(uint8_t device, uint8_t channel) { - return 0x180u + ((uint32_t)device * 4u + channel - 2u) * PACKET; + return PRIVATE_DATA_BASE + ((uint32_t)device * PRIVATE_CHANNELS + channel - 2u) * PACKET; } static inline unsigned physical_channel(uint8_t slot, uint8_t channel) { return slot == 0 && channel == 2 ? 30u : channel; @@ -226,8 +282,7 @@ static __force_inline void buffer_settle(void) { static __force_inline void set_buffer(uint8_t device, uint8_t channel, uint32_t value) { devices[device].buffers[channel] = value; __dmb(); - if ((active_device == device && (!bank_restore_pending || channel != 0)) || - (device == 0 && channel == 2)) { + if (active_device == device || (device == 0 && channel == 2)) { unsigned physical = physical_channel(device,channel); buffer_regs()[physical] = value & ~USB_BUF_CTRL_AVAIL; if (value & USB_BUF_CTRL_AVAIL) buffer_settle(); @@ -238,9 +293,14 @@ static __force_inline void set_buffer(uint8_t device, uint8_t channel, uint32_t // SRAM receiver only. No bank changes while a hardware completion is pending. bool __not_in_flash_func(native_hub_select_device)(uint8_t address, uint8_t owner, uint32_t cutoff) { if (owner >= DEVICES || bank_lock == NULL) return false; - ++token_hits[owner]; - if (active_device == owner && usb_hw->dev_addr_ctrl == address) return true; - if (!spin_try_lock_unsafe(bank_lock)) { ++missed_switches; ++missed_lock; return false; } + if (active_device == owner && usb_hw->dev_addr_ctrl == address) { + ++token_hits[owner]; + return true; + } + if (!spin_try_lock_unsafe(bank_lock)) { + ++missed_switches; ++missed_lock; ++token_hits[owner]; + return false; + } __dmb(); if ((usb_hw->sie_status & USB_SIE_STATUS_SETUP_REC_BITS) || usb_hw->buf_status || (int32_t)(sio_hw->mtime - cutoff) >= 0) { @@ -248,29 +308,58 @@ bool __not_in_flash_func(native_hub_select_device)(uint8_t address, uint8_t owne blocked_buffers = usb_hw->buf_status; blocked_sie = usb_hw->sie_status; spin_unlock_unsafe(bank_lock); + ++token_hits[owner]; return false; } if (active_device != owner) { - const device_t* restrict incoming = &devices[owner]; volatile uint32_t* buffers = (volatile uint32_t*)&usb_dpram->ep_buf_ctrl[0]; + const device_t* restrict incoming = &devices[owner]; volatile uint32_t* controls = (volatile uint32_t*)&usb_dpram->ep_ctrl[0]; - // Select metadata before accepting a token. Only EP0 IN needs a later - // shared-buffer copy; other endpoints already have private DPRAM data. + // PID/length and endpoint pointers must describe the incoming owner + // before its address becomes visible. AVAIL stays clear until the bank + // is settled; EP0 IN also needs its shared payload copied below. for (unsigned i = 0; i < CHANNELS; ++i) { uint32_t value = owner == 0 && i >= 2 ? 0 : incoming->buffers[i]; buffers[i] = value & ~USB_BUF_CTRL_AVAIL; } usb_dpram->ep_ctrl[14].in = owner == 0 ? hub_endpoint_control : 0; for (unsigned i = 0; i < 4; ++i) controls[i] = incoming->endpoint_controls[i]; + usb_hw->ep_stall_arm = ((incoming->buffers[0] & USB_BUF_CTRL_STALL) ? 1u : 0u) | + ((incoming->buffers[1] & USB_BUF_CTRL_STALL) ? 2u : 0u); + __dmb(); + usb_hw->dev_addr_ctrl = address; + active_device = owner; +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + out_trace_address_cycle = sio_hw->mtime; +#endif buffer_settle(); for (unsigned i = 1; i < CHANNELS; ++i) buffers[i] = owner == 0 && i >= 2 ? 0 : incoming->buffers[i]; + // Complete EP0 publication here, not in a later foreground pass: + // alternating control polls must not depend on which owner Core0 sees. + const uint32_t ep0 = incoming->buffers[0]; + if ((ep0 & (USB_BUF_CTRL_FULL | USB_BUF_CTRL_AVAIL)) == + (USB_BUF_CTRL_FULL | USB_BUF_CTRL_AVAIL)) { + volatile uint32_t* to = (volatile uint32_t*)usb_dpram->ep0_buf_a; + const uint32_t* from = incoming->ep0_image; + unsigned words = ((ep0 & USB_BUF_CTRL_LEN_MASK) + 3u) / 4u; + // Four aligned words per iteration keep the common 16/64-byte + // replies bounded without a flash-backed memcpy or jump table. + for (; words >= 4; words -= 4) { + to[0] = from[0]; to[1] = from[1]; + to[2] = from[2]; to[3] = from[3]; + to += 4; from += 4; + } + while (words) { *to++ = *from++; --words; } + } + // Metadata was settled above; publish AVAIL only after the payload. __dmb(); - usb_hw->dev_addr_ctrl = address; - bank_restore_pending = true; - active_device = owner; + buffers[0] = ep0; } else { usb_hw->dev_addr_ctrl = address; +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + out_trace_address_cycle = sio_hw->mtime; +#endif } __dmb(); ++switches; @@ -281,6 +370,9 @@ bool __not_in_flash_func(native_hub_select_device)(uint8_t address, uint8_t owne if ((uint32_t)lateness > maximum_lateness) maximum_lateness = (uint32_t)lateness; } spin_unlock_unsafe(bank_lock); + // Count every valid attempt, but keep this diagnostic store off the + // address-critical path on a successful handover. + ++token_hits[owner]; return true; } @@ -303,6 +395,7 @@ static __force_inline void out_trace_finish_record(out_trace_record_t* record) { record->out0 = usb_dpram->ep_buf_ctrl[0].out; record->buffers = usb_hw->buf_status; record->sie = usb_hw->sie_status; + record->rx_error = usb_hw->ep_rx_error; record->sm = usb_hw->sm_state; record->ints = usb_hw->ints; record->missed_lock = missed_lock; @@ -310,38 +403,60 @@ static __force_inline void out_trace_finish_record(out_trace_record_t* record) { record->irq_enter = __atomic_load_n(&out_trace_irq_enter,__ATOMIC_ACQUIRE); record->irq_exit = __atomic_load_n(&out_trace_irq_exit,__ATOMIC_ACQUIRE); record->ep0_word = *(const volatile uint32_t*)usb_dpram->ep0_buf_a; + // A handover has a fresh commit; same-owner control observations can name + // an earlier selector write. Failed selections did not commit an address. + record->address_cycle = record->selected ? out_trace_address_cycle : 0; const uint32_t cursor = record->cursor; const uint32_t next = (cursor & 127u) == OUT_TRACE_SLOTS-1u ? cursor+64u : cursor+1u; __atomic_store_n(&out_trace_cursor,next,__ATOMIC_SEQ_CST); } -bool __no_inline_not_in_flash_func(native_hub_select_device_traced)( +void __no_inline_not_in_flash_func(native_hub_note_selected_token)( uint8_t address, uint8_t owner, uint32_t cutoff, uint8_t pid) { - if (__atomic_load_n(&out_trace_frozen,__ATOMIC_SEQ_CST)) - return native_hub_select_device(address,owner,cutoff); - const uint32_t clock_before = sio_hw->mtime; - const uint32_t address_before = usb_hw->dev_addr_ctrl; - const uint8_t owner_before = active_device; - const bool selected = native_hub_select_device(address,owner,cutoff); - const uint32_t clock_after = sio_hw->mtime; - // The router records all failures through the post-rejection hook below. - if (!selected) return false; + // Update observation state even while frozen, so the next same-owner poll + // cannot be mislabelled as a handover which happened during the snapshot. + bool retain = switches != out_trace_last_switches; + out_trace_last_switches = switches; + if (owner >= DEVICES) return; + if (owner) { + if (pid == 0x2du) { + out_trace_first_tokens[owner] = OUT_TRACE_TOKEN_IN | OUT_TRACE_TOKEN_OUT; + retain = true; + } else if (pid == 0x69u && (out_trace_first_tokens[owner] & OUT_TRACE_TOKEN_IN)) { + out_trace_first_tokens[owner] &= (uint8_t)~OUT_TRACE_TOKEN_IN; + retain = true; + } else if (pid == 0xe1u && (out_trace_first_tokens[owner] & OUT_TRACE_TOKEN_OUT)) { + out_trace_first_tokens[owner] &= (uint8_t)~OUT_TRACE_TOKEN_OUT; + retain = true; + } + } + uint32_t publication_sequence = 0; + bool after_arm = false; + if (owner && pid == 0x69u) { + publication_sequence = __atomic_load_n(&out_trace_publication_sequence[owner-1u],__ATOMIC_ACQUIRE); + after_arm = publication_sequence != out_trace_seen_publication_sequence[owner-1u]; + out_trace_seen_publication_sequence[owner-1u] = publication_sequence; + retain |= after_arm; + } + if (!retain) return; + // All tracing follows selection. Endpoint bits are not decoded here; + // neither a handover nor a first-token record proves SIE/host acceptance. out_trace_record_t* record = out_trace_begin_record(); - if (!record) return true; + if (!record) return; record->cutoff = cutoff; - record->clock_before = clock_before; - record->clock_after = clock_after; + record->clock_before = 0; + record->clock_after = sio_hw->mtime; record->address = address; record->owner = owner; - record->address_before = address_before; - record->owner_before = owner_before; + record->address_before = 0; + record->owner_before = NONE; record->selected = true; - record->reason = 0; + record->reason = after_arm ? OUT_TRACE_AFTER_ARM : 0; + record->publication_sequence = after_arm ? publication_sequence : 0; record->pid = pid; - record->before_valid = true; + record->before_valid = false; record->blocked_buffers = record->blocked_sie = 0; out_trace_finish_record(record); - return true; } void __no_inline_not_in_flash_func(native_hub_note_failed_select)( @@ -363,6 +478,7 @@ void __no_inline_not_in_flash_func(native_hub_note_failed_select)( record->selected = false; record->pid = pid; record->before_valid = false; // No added work before normal IN/SETUP selection. + record->publication_sequence = 0; record->blocked_buffers = record->blocked_sie = 0; if (lock_failed) record->reason = OUT_TRACE_LOCK; else if (owner >= DEVICES || bank_lock == NULL) record->reason = OUT_TRACE_INVALID; @@ -383,44 +499,7 @@ uint32_t __no_inline_not_in_flash_func(native_hub_trace_phase)(uint32_t phase) { return previous; } -void __no_inline_not_in_flash_func(native_hub_note_log_mask)( - uint32_t elapsed_us, uint32_t bytes, bool already_masked) { - if (elapsed_us > out_trace_log_max_us) { - out_trace_log_max_us = elapsed_us; - out_trace_log_max_us_bytes = bytes; - } - if (bytes > out_trace_log_max_bytes) out_trace_log_max_bytes = bytes; - if (already_masked) ++out_trace_log_nested; -} #endif -// Section placement alone permits inlining into the flash-backed task. Keep -// bank-lock ownership independent of XIP instruction-cache refill latency. -static void __no_inline_not_in_flash_func(restore_selected_bank)(void) { - if (!bank_restore_pending) return; - uint32_t flags = spin_lock_blocking(bank_lock); - if (!bank_restore_pending || (usb_hw->sie_status & USB_SIE_STATUS_SETUP_REC_BITS) || - (usb_hw->buf_status & 0x3fu)) { - spin_unlock(bank_lock,flags); - return; - } - uint8_t owner = active_device; - const device_t* incoming = &devices[owner]; - volatile uint32_t* buffers = buffer_regs(); - if ((incoming->buffers[0] & USB_BUF_CTRL_FULL) && - (incoming->buffers[0] & USB_BUF_CTRL_LEN_MASK)) { - volatile uint32_t* to = (volatile uint32_t*)usb_dpram->ep0_buf_a; - const uint32_t* from = incoming->ep0_image; - unsigned words = ((incoming->buffers[0] & USB_BUF_CTRL_LEN_MASK) + 3u) / 4u; - for (unsigned i = 0; i < words; ++i) to[i] = from[i]; - } - buffers[0] = incoming->buffers[0] & ~USB_BUF_CTRL_AVAIL; - usb_hw->ep_stall_arm = ((incoming->buffers[0] & USB_BUF_CTRL_STALL) ? 1u : 0u) | - ((incoming->buffers[1] & USB_BUF_CTRL_STALL) ? 2u : 0u); - buffer_settle(); - buffers[0] = incoming->buffers[0]; - bank_restore_pending = false; - spin_unlock(bank_lock,flags); -} static void publish_addresses(void) { probe_router_publish(addresses, default_device); } @@ -431,15 +510,22 @@ static uint8_t hardware_owner(void) { return NONE; } static __force_inline bool push_event(uint8_t device, uint8_t channel, uint8_t kind, uint16_t length, - const uint8_t* data) { + const uint8_t* data, bool status_out_armed, uint32_t status_arm_cycle) { uint32_t next = (event_head + 1u) % EVENTS; if (next == event_tail || length > 64) { failed = true; return false; } event_t* event = &events[event_head]; event->device = device; event->channel = channel; event->kind = kind; event->length = length; + event->status_out_armed = status_out_armed; event->generation = device < DEVICES ? (channel < 2 ? devices[device].generation : devices[device].endpoint_generation[channel]) : 0; event->reset_generation = device < DEVICES ? devices[device].reset_generation : 0; +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + event->trace_cycle = sio_hw->mtime; + event->status_out_arm_cycle = status_arm_cycle; +#else + (void)status_arm_cycle; +#endif if (kind == 2 && (channel & 1u) && channel != 1) copy_from_usb(event->data,data,length); else { // SRAM sources may be unaligned. Volatile byte reads keep this copy @@ -459,12 +545,19 @@ static void __not_in_flash_func(usb_interrupt)(void) { for (uint8_t i = 0; i < DEVICES; ++i) { ++devices[i].generation; ++devices[i].reset_generation; + devices[i].ep[0].status_out_on_complete = false; for (unsigned ch = 2; ch < CHANNELS; ++ch) ++devices[i].endpoint_generation[ch]; + // Selection can now publish EP0 without foreground dispatch. + // Revoke all shadow readiness here so no old bank can reappear + // between the reset IRQ and reset_bus. Volatile stores stay in SRAM. + volatile uint32_t* shadow = devices[i].buffers; + for (unsigned ch = 0; ch < CHANNELS; ++ch) shadow[ch] = 0; } for (unsigned i = 0; i < CHANNELS; ++i) buffer_regs()[i] = 0; + buffer_regs()[30] = 0; // Root interrupt endpoint has its own physical bank. hw_clear_bits(&usb_hw->buf_status,usb_hw->buf_status); hw_clear_bits(&usb_hw->sie_status,USB_SIE_STATUS_BUS_RESET_BITS | USB_SIE_STATUS_SETUP_REC_BITS); - push_event(0,0,3,0,NULL); + push_event(0,0,3,0,NULL,false,0); spin_unlock(bank_lock, flags); return; } @@ -493,6 +586,27 @@ static void __not_in_flash_func(usb_interrupt)(void) { copy_from_usb((uint8_t*)ep0_snapshot,data,length); data = (const uint8_t*)ep0_snapshot; } + bool status_out_armed = false; + uint32_t status_arm_cycle = 0; + if (channel == 0) { + endpoint_t* in = &devices[completed_owner].ep[0]; + // Only pre-approved reads may cross this boundary without a DATA + // callback. A replacement SETUP owns EP0 and must not be overwritten. + status_out_armed = in->status_out_on_complete && + length == in->packet_length && !failed && + !(usb_hw->sie_status & USB_SIE_STATUS_SETUP_REC_BITS) && + !(pending & 2u); + in->status_out_on_complete = false; + if (status_out_armed) { + devices[completed_owner].ep[1].next_pid = 1; + devices[completed_owner].ep[1].packet_length = 0; +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + status_arm_cycle = sio_hw->mtime; +#endif + set_buffer(completed_owner,1,USB_BUF_CTRL_AVAIL | + USB_BUF_CTRL_LAST | USB_BUF_CTRL_SEL | USB_BUF_CTRL_DATA1_PID); + } + } devices[completed_owner].ep[channel].next_pid ^= 1u; devices[completed_owner].buffers[channel] = 0; buffer_regs()[physical] = 0; @@ -501,7 +615,8 @@ static void __not_in_flash_func(usb_interrupt)(void) { // Unarmed device-specific buffers and the TX shadow cannot be reused // until Core0 consumes this event. Copy them without blocking routing. spin_unlock(bank_lock,flags); - push_event(completed_owner,(uint8_t)channel,2,length,data); + push_event(completed_owner,(uint8_t)channel,2,length,data, + status_out_armed,status_arm_cycle); if (!pending && !(status & (USB_INTS_SETUP_REQ_BITS | USB_INTS_DEV_SUSPEND_BITS | USB_INTS_DEV_RESUME_FROM_HOST_BITS))) return; flags = spin_lock_blocking(bank_lock); @@ -525,11 +640,12 @@ static void __not_in_flash_func(usb_interrupt)(void) { } if (actual_owner < DEVICES && actual_owner == owner) { ++devices[owner].generation; + devices[owner].ep[0].status_out_on_complete = false; devices[owner].buffers[0] = devices[owner].buffers[1] = USB_BUF_CTRL_DATA1_PID | USB_BUF_CTRL_SEL; buffer_regs()[0] = buffer_regs()[1] = USB_BUF_CTRL_DATA1_PID | USB_BUF_CTRL_SEL; devices[owner].ep[0].next_pid = devices[owner].ep[1].next_pid = 1; - push_event(owner,0,1,sizeof(setup),setup); + push_event(owner,0,1,sizeof(setup),setup,false,0); } else { // No logical owner: never reinterpret it as another controller. hw_set_bits(&usb_hw->ep_stall_arm,3u); @@ -576,6 +692,7 @@ static void stall(uint8_t slot) { } devices[slot].control.stage = STALLED; devices[slot].control.action = NO_ACTION; + devices[slot].ep[0].status_out_on_complete = false; if (active_device == slot) hw_set_bits(&usb_hw->ep_stall_arm,3u); set_buffer(slot,0,USB_BUF_CTRL_STALL); set_buffer(slot,1,USB_BUF_CTRL_STALL); spin_unlock(bank_lock, flags); @@ -585,11 +702,18 @@ static void __no_inline_not_in_flash_func(arm_packet)(uint8_t slot, uint8_t chan uint32_t generation = channel < 2 ? devices[slot].control.generation : devices[slot].endpoint_generation[channel]; if (!(channel & 1u) && length) { - // Private packet storage is unarmed until the metadata below is published. + // Images are rewritten only while the per-device buffer is unarmed. + // The publication lock releases this copy to the Core1 bank selector. memcpy(ep->data,data,length); if (channel == 0) memcpy(devices[slot].ep0_image,data,length); else copy_to_usb(packet_buffer(slot,channel),data,length); } +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + // Publication attempt, not on-wire readiness: an inactive bank receives + // a shadow which the next selection copies. Do not extend IRQ masking. + const bool status_out = channel == 1 && devices[slot].control.stage == STATUS_OUT; + const uint32_t arm_cycle = (channel == 0 || status_out) ? sio_hw->mtime : 0; +#endif uint32_t flags = spin_lock_blocking(bank_lock); if (generation != (channel < 2 ? devices[slot].generation : devices[slot].endpoint_generation[channel])) { @@ -597,10 +721,17 @@ static void __no_inline_not_in_flash_func(arm_packet)(uint8_t slot, uint8_t chan return; } ep->packet_length = length; + if (channel == 0) { + const control_t* c = &devices[slot].control; + ep->status_out_on_complete = c->stage == DATA_IN && + (c->request.bmRequestType & 0x80u) && + (!c->vendor || c->read_status_preapproved) && + c->position + length == c->length && !c->zlp; + } uint32_t value = USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_LAST | USB_BUF_CTRL_SEL | (ep->next_pid ? USB_BUF_CTRL_DATA1_PID : 0); if (!(channel & 1u)) { - if (channel == 0 && active_device == slot && !bank_restore_pending) + if (channel == 0 && active_device == slot) copy_to_usb(packet_buffer(slot,channel),data,length); value |= USB_BUF_CTRL_FULL | length; } else if (channel == 1) { @@ -612,6 +743,28 @@ static void __no_inline_not_in_flash_func(arm_packet)(uint8_t slot, uint8_t chan } set_buffer(slot,channel,value); spin_unlock(bank_lock, flags); +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + control_trace_watch_t* watch = &out_trace_controls[slot]; + if (channel == 0) { + if (!(watch->first_in_flags & OUT_TRACE_FIRST_IN_ARMED)) { + watch->first_in_arm_cycle = arm_cycle; + watch->first_in_pid = (value & USB_BUF_CTRL_DATA1_PID) != 0; + watch->first_in_arm_length = length; + watch->first_in_flags |= OUT_TRACE_FIRST_IN_ARMED; + if (slot) { + // Notify only after the generation-validated publication above. + // Do not extend the bank lock or IRQ masking for diagnostics. + const uint32_t sequence = __atomic_load_n( + &out_trace_publication_sequence[slot-1u],__ATOMIC_RELAXED) + 1u; + watch->first_in_sequence = sequence; + __atomic_store_n(&out_trace_publication_sequence[slot-1u],sequence,__ATOMIC_RELEASE); + } + } + } else if (status_out && !(watch->status_out_flags & OUT_TRACE_STATUS_OUT_ARMED)) { + watch->status_out_arm_cycle = arm_cycle; + watch->status_out_flags |= OUT_TRACE_STATUS_OUT_ARMED; + } +#endif } static void control_next(uint8_t slot) { control_t* c = &devices[slot].control; @@ -645,11 +798,13 @@ static void status_in(uint8_t slot, action_t action) { arm_packet(slot,0,NULL,0); } bool native_hub_control_xfer(uint8_t slot, const tusb_control_request_t* request, - void* buffer, uint16_t length) { + void* buffer, uint16_t length, bool read_status_preapproved) { if (slot >= DEVICES || request == NULL || (length && buffer == NULL)) return false; + if (read_status_preapproved && !(request->bmRequestType & 0x80u)) return false; control_t* c = &devices[slot].control; if (c->generation != devices[slot].generation || memcmp(request,&c->request,sizeof(*request)) != 0) return false; + c->read_status_preapproved = read_status_preapproved; if (request->bmRequestType & 0x80) reply(slot,buffer,length); else if (!request->wLength) status_in(slot,NO_ACTION); else { @@ -690,6 +845,11 @@ static void reset_device(uint8_t slot) { for (unsigned i = 0; i < 4; ++i) endpoint_regs()[i] = 0; } spin_unlock(bank_lock, flags); +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + // Foreground-only evidence belongs to the cleared control generation. + // Do not extend the reset critical section or alter queued snapshots. + memset(&out_trace_controls[slot],0,sizeof(out_trace_controls[slot])); +#endif if (slot) native_joycon_usb_reset(slot-1); } static void forget_port(unsigned port) { @@ -704,7 +864,7 @@ static void reset_bus(void) { uint32_t flags = spin_lock_blocking(bank_lock); active_device = 0; usb_hw->dev_addr_ctrl = 0; memset(ports,0,sizeof(ports)); - addresses[0] = 0; addresses[1] = addresses[2] = NONE; default_device = 0; + memset(addresses,NONE,sizeof(addresses)); addresses[0] = 0; default_device = 0; bus_suspended = false; spin_unlock(bank_lock, flags); for (uint8_t slot = 0; slot < DEVICES; ++slot) reset_device(slot); @@ -738,7 +898,7 @@ static void configure_device(uint8_t slot, uint8_t configuration) { native_joycon_usb_reset(slot-1); if (configuration) { arm_packet(slot,3,NULL,0); arm_packet(slot,5,NULL,0); } } else if (!configuration) { - for (unsigned p = 0; p < 2; ++p) { ports[p].status = ports[p].change = 0; forget_port(p); } + for (unsigned p = 0; p < CHILDREN; ++p) { ports[p].status = ports[p].change = 0; forget_port(p); } } } static const uint16_t* hub_string(uint8_t index) { @@ -814,7 +974,7 @@ static bool class_request(uint8_t slot) { if (!slot) { if (r->bmRequestType == 0xa0 && r->bRequest == 6 && r->wValue == 0x2900 && !r->wIndex) { reply(slot,hub_descriptor,sizeof(hub_descriptor)); return true; } if (r->bmRequestType == 0xa0 && r->bRequest == 0 && !r->wValue && !r->wIndex && r->wLength == 4) { uint32_t zero=0; reply(slot,&zero,4); return true; } - if (r->wIndex < 1 || r->wIndex > 2) return false; + if (r->wIndex < 1 || r->wIndex > CHILDREN) return false; port_t* p = &ports[r->wIndex-1]; if (r->bmRequestType == 0xa3 && !r->bRequest && !r->wValue && r->wLength == 4) { uint8_t status[4]={(uint8_t)p->status,(uint8_t)(p->status>>8),(uint8_t)p->change,(uint8_t)(p->change>>8)}; @@ -827,6 +987,11 @@ static bool class_request(uint8_t slot) { (r->wValue < 16 || r->wValue > 20)) return false; if (set && r->wValue == 4 && (!(p->status & POWER) || (default_device != NONE && default_device != r->wIndex))) return false; + if (set && r->wValue == 4) { + // Only one child may be resetting toward the shared address zero. + for (unsigned port = 0; port < CHILDREN; ++port) + if (port+1 != r->wIndex && (ports[port].status & RESET)) return false; + } status_in(slot,set ? PORT_SET : PORT_CLEAR); return true; } if (r->wIndex != 0) return false; @@ -854,6 +1019,17 @@ static bool class_request(uint8_t slot) { static void setup_request(const event_t* event) { uint8_t slot = event->device; device_t* d = &devices[slot]; if (event->generation != d->generation) return; +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + control_trace_watch_t* watch = &out_trace_controls[slot]; + if (slot && d->control.generation && d->control.stage != IDLE && + d->control.stage != STALLED && + !(watch->captured && watch->generation == d->control.generation && + watch->stage == d->control.stage && watch->position == d->control.position)) + out_trace_snapshot(time_us_32(), slot, OUT_TRACE_SUPERSEDED); + memset(watch, 0, sizeof(*watch)); + watch->since_us = time_us_32(); + watch->setup_cycle = event->trace_cycle; +#endif memset(&d->control,0,sizeof(d->control)); control_t* c = &d->control; memcpy(&c->request,event->data,8); c->generation = event->generation; @@ -862,6 +1038,27 @@ static void setup_request(const event_t* event) { c->generation, c->request.bmRequestType, c->request.bRequest, c->request.wValue, c->request.wIndex, c->request.wLength); ++setup_count[slot]; +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + // Root-only diagnostic read. Capture the actual child state before host + // cleanup can supersede it; never forward this into controller management. + if (!slot && c->request.bmRequestType == 0xc0 && + c->request.bRequest == NATIVE_HUB_TRACE_REQUEST && + c->request.wValue == NATIVE_HUB_TRACE_VALUE && + c->request.wIndex >= 1 && c->request.wIndex <= CHILDREN && + c->request.wLength == NATIVE_HUB_TRACE_REPLY_SIZE) { + const uint8_t target = (uint8_t)c->request.wIndex; + const out_trace_header_t* captured = out_trace_snapshot(time_us_32(),target,OUT_TRACE_HOST); + uint8_t response[NATIVE_HUB_TRACE_REPLY_SIZE] = {'N','H','T','R',1,captured ? 0 : 1,target,0}; + const uint32_t captured_time = captured ? captured->time_us : 0; + const uint32_t captured_generation = captured ? captured->control_generation : 0; + for (unsigned byte = 0; byte < 4; ++byte) { + response[8+byte] = (uint8_t)(captured_time >> (8u*byte)); + response[12+byte] = (uint8_t)(captured_generation >> (8u*byte)); + } + reply(slot,response,sizeof(response)); + return; + } +#endif uint8_t type = c->request.bmRequestType & 0x60; bool supported; if (type == 0) supported = standard_request(slot); @@ -952,6 +1149,26 @@ static void __no_inline_not_in_flash_func(transfer_complete)(const event_t* even // Preserve a real status ACK queued before the next SETUP. Unlike // SETUP, reset invalidates even these queued completions (above). if (event->generation != c->generation) return; +#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + control_trace_watch_t* watch = &out_trace_controls[slot]; + if (event->status_out_armed) { + watch->status_out_arm_cycle = event->status_out_arm_cycle; + watch->status_out_flags |= OUT_TRACE_STATUS_OUT_ARMED; + } + if (channel == 0 && !(watch->first_in_flags & OUT_TRACE_FIRST_IN_COMPLETED)) { + watch->first_in_complete_cycle = event->trace_cycle; + watch->first_in_length = event->length; + watch->first_in_flags |= OUT_TRACE_FIRST_IN_COMPLETED; + } + // For a control read, channel 1 is the status direction even if its + // completion is observed before foreground DATA_IN bookkeeping. + if (channel == 1 && (c->request.bmRequestType & 0x80u) && + !(watch->status_out_flags & OUT_TRACE_STATUS_OUT_COMPLETED)) { + watch->status_out_complete_cycle = event->trace_cycle; + watch->status_out_length = event->length; + watch->status_out_flags |= OUT_TRACE_STATUS_OUT_COMPLETED; + } +#endif if ((c->stage == STATUS_IN && channel == 0) || (c->stage == STATUS_OUT && channel == 1)) { if (event->length) stall(slot); else if (c->stage == STATUS_OUT && (c->request.bmRequestType & 0x80u)) { @@ -973,13 +1190,24 @@ static void __no_inline_not_in_flash_func(transfer_complete)(const event_t* even restore_interrupts(flags); } } else if (c->stage == DATA_IN && channel == 0) { - if (event->generation != d->generation) return; - if (event->length != c->packet_length) { stall(slot); return; } - c->position += event->length; - if (c->position < c->length || c->zlp) control_next(slot); + // Claim DATA completion before reset can revoke it, as for ACK. + // A newer SETUP may follow already queued final-IN/status events; + // unlike reset, it must not discard their foreground callback order. + uint32_t flags = save_and_disable_interrupts(); + bool valid = event->reset_generation == d->reset_generation && + (event->generation == d->generation || event->status_out_armed); + bool length_valid = event->length == c->packet_length; + if (valid && length_valid) c->position += event->length; + bool more = c->position < c->length || c->zlp; + if (valid && length_valid && !more) c->stage = STATUS_OUT; + restore_interrupts(flags); + if (!valid) return; + if (!length_valid) { stall(slot); return; } + if (more) control_next(slot); else { if (c->vendor && !tud_vendor_control_xfer_cb(slot,CONTROL_STAGE_DATA,&c->request)) { stall(slot); return; } - c->stage = STATUS_OUT; arm_packet(slot,1,NULL,0); + // IRQ may already have consumed this status OUT. Never rearm it. + if (!event->status_out_armed) arm_packet(slot,1,NULL,0); } } else if (c->stage == DATA_OUT && channel == 1) { if (event->generation != d->generation) return; @@ -1012,8 +1240,8 @@ static void __no_inline_not_in_flash_func(transfer_complete)(const event_t* even } } -bool native_hub_mounted(uint8_t instance) { return instance < 2 && devices[instance+1].configuration != 0; } -bool native_hub_suspended(uint8_t instance) { return instance >= 2 || bus_suspended || (ports[instance].status & SUSPEND); } +bool native_hub_mounted(uint8_t instance) { return instance < CHILDREN && devices[instance+1].configuration != 0; } +bool native_hub_suspended(uint8_t instance) { return instance >= CHILDREN || bus_suspended || (ports[instance].status & SUSPEND); } bool native_hub_hid_ready(uint8_t instance) { return native_hub_mounted(instance) && !native_hub_suspended(instance) && !devices[instance+1].ep[2].busy && !devices[instance+1].ep[2].halted; @@ -1038,7 +1266,7 @@ uint32_t native_hub_vendor_write(uint8_t instance, const void* data, uint32_t le return length; } uint32_t native_hub_vendor_write_flush(uint8_t instance) { - if (instance >= 2) return 0; + if (instance >= CHILDREN) return 0; endpoint_t* ep = &devices[instance+1].ep[4]; if (!ep->busy || ep->flush) return 0; ep->flush = true; transmit_next(instance+1,4); return ep->length; @@ -1062,7 +1290,8 @@ bool native_hub_init(void) { pico_get_unique_board_id_string(root_serial+12,sizeof(root_serial)-12); reset_block(RESETS_RESET_USBCTRL_BITS); unreset_block_wait(RESETS_RESET_USBCTRL_BITS); memset(usb_dpram,0,USB_DPRAM_SIZE); - active_device = 0; addresses[0] = 0; addresses[1] = addresses[2] = NONE; default_device = 0; + memset(addresses,NONE,sizeof(addresses)); + active_device = 0; addresses[0] = 0; default_device = 0; usb_hw->muxing = USB_USB_MUXING_TO_PHY_BITS | USB_USB_MUXING_SOFTCON_BITS | USB_USB_MUXING_USBPHY_AS_GPIO_BITS; sio_hw->gpio_hi_oe_clr = SIO_GPIO_HI_IN_USB_DP_BITS | SIO_GPIO_HI_IN_USB_DM_BITS; hw_set_bits(&usb_hw->phy_direct,USB_USBPHY_DIRECT_DP_PULLUP_EN_BITS); @@ -1089,21 +1318,39 @@ bool native_hub_init(void) { irq_set_enabled(USBCTRL_IRQ,true); hw_set_bits(&usb_hw->sie_ctrl,USB_SIE_CTRL_PULLUP_EN_BITS); watchdog_enable(8000,false); started = true; startup_time = time_us_32(); +#if CHILDREN == 2 probe_debug_printf("[NATIVE_HUB] stock USB, SIO phase=%u, 240MHz; hub2068 R2066 L2067; isolated EP0/1/2 banks\n",NATIVE_HUB_SAMPLE_PHASE); +#else + probe_debug_printf("[NATIVE_HUB] stock USB, SIO phase=%u, 240MHz; hub2068 children=%u order=AR/AL/BR/BL; isolated EP0/1/2 banks\n",NATIVE_HUB_SAMPLE_PHASE,CHILDREN); +#endif return true; } #if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT) -static void out_trace_freeze(uint32_t now, uint8_t control_slot) { +static const out_trace_header_t* out_trace_snapshot(uint32_t now, uint8_t control_slot, uint32_t reason) { + const bool replace_pending = out_trace_snapshot_count == OUT_TRACE_SNAPSHOTS; + uint32_t tail = (out_trace_snapshot_head + out_trace_snapshot_count) % OUT_TRACE_SNAPSHOTS; + if (replace_pending) { + ++out_trace_snapshot_drops; + tail = (out_trace_snapshot_head+1u)%OUT_TRACE_SNAPSHOTS; + if (reason != OUT_TRACE_HOST || out_trace_snapshots[tail].header.reason == OUT_TRACE_HOST) + return NULL; + // Only the waiting automatic snapshot can be evicted. The head may + // already have printed a header; neither it nor another host latch is + // ever rewritten. The loss counter accounts for the displaced snapshot. + } + out_trace_snapshot_t* snapshot = &out_trace_snapshots[tail]; __atomic_store_n(&out_trace_frozen,1u,__ATOMIC_SEQ_CST); - out_trace_header_t* h = &out_trace_header; + out_trace_header_t* h = &snapshot->header; + memset(h,0,sizeof(*h)); h->cursor = __atomic_load_n(&out_trace_cursor,__ATOMIC_SEQ_CST); h->count = h->cursor < OUT_TRACE_RETAIN ? h->cursor : OUT_TRACE_RETAIN; h->time_us = now; + h->reason = reason; h->quiet_us = now - (control_slot < DEVICES ? out_trace_controls[control_slot].since_us : out_trace_last_input_us); - h->input[0] = input_count[1]; - h->input[1] = input_count[2]; + for (unsigned instance = 0; instance < CHILDREN; ++instance) + h->input[instance] = input_count[instance+1]; h->cycle = sio_hw->mtime; h->sof = usb_hw->sof_rd; h->address = usb_hw->dev_addr_ctrl; @@ -1120,49 +1367,69 @@ static void out_trace_freeze(uint32_t now, uint8_t control_slot) { h->irq_enter = __atomic_load_n(&out_trace_irq_enter,__ATOMIC_ACQUIRE); h->irq_exit = __atomic_load_n(&out_trace_irq_exit,__ATOMIC_ACQUIRE); h->core0_phase = __atomic_load_n(&out_trace_core0_phase,__ATOMIC_ACQUIRE); - h->log_max_us = out_trace_log_max_us; - h->log_max_us_bytes = out_trace_log_max_us_bytes; - h->log_max_bytes = out_trace_log_max_bytes; - h->log_nested = out_trace_log_nested; h->in0 = usb_dpram->ep_buf_ctrl[0].in; h->ep0_word = *(const volatile uint32_t*)usb_dpram->ep0_buf_a; h->control_slot = control_slot; - h->control_generation = h->control_stage = h->control_position = h->control_length = 0; - h->control_word = 0; - memset(&h->control_request,0,sizeof(h->control_request)); if (control_slot < DEVICES) { const control_t* c = &devices[control_slot].control; + const control_trace_watch_t* watch = &out_trace_controls[control_slot]; h->control_generation = c->generation; h->control_stage = c->stage; h->control_position = c->position; h->control_length = c->length; h->control_request = c->request; + h->setup_cycle = watch->setup_cycle; + h->first_in_arm_cycle = watch->first_in_arm_cycle; + h->first_in_complete_cycle = watch->first_in_complete_cycle; + h->first_in_sequence = watch->first_in_sequence; + h->first_in_arm_length = watch->first_in_arm_length; + h->first_in_length = watch->first_in_length; + h->first_in_flags = watch->first_in_flags; + h->first_in_pid = watch->first_in_pid; + // IRQ can update generation/readiness between these individual reads. + // They are observations, not an atomic multiword ownership snapshot. + h->device_generation = *(const volatile uint32_t*)&devices[control_slot].generation; + const volatile uint32_t* shadow = devices[control_slot].buffers; + h->control_shadow_in = shadow[0]; + h->control_shadow_out = shadow[1]; + h->status_out_arm_cycle = watch->status_out_arm_cycle; + h->status_out_complete_cycle = watch->status_out_complete_cycle; + h->status_out_length = watch->status_out_length; + h->status_out_flags = watch->status_out_flags; if (c->position < c->length) { unsigned length = c->length-c->position; if (length > sizeof(h->control_word)) length = sizeof(h->control_word); memcpy(&h->control_word,c->data+c->position,length); } } - // Snapshot EVERYTHING printed in the header before enqueueing its first - // byte. Later lines must not accidentally describe the act of dumping. - out_trace_dump_slot = (h->cursor & 127u)+OUT_TRACE_SLOTS-h->count; - if (out_trace_dump_slot >= OUT_TRACE_SLOTS) out_trace_dump_slot -= OUT_TRACE_SLOTS; - out_trace_dump_line = 0; - out_trace_last_line_us = now-OUT_TRACE_LINE_US; - out_trace_dumping = true; + // Exclude the acquired cursor's possible in-flight slot. IRQs remain + // enabled while Core0 copies two contiguous spans; only Core1 recording + // pauses. The immutable copy, not the live ring, feeds the slow UART. + unsigned start = (h->cursor & 127u)+OUT_TRACE_SLOTS-h->count; + if (start >= OUT_TRACE_SLOTS) start -= OUT_TRACE_SLOTS; + unsigned first = OUT_TRACE_SLOTS-start; + if (first > h->count) first = h->count; + memcpy(snapshot->records,out_trace_records+start,first*sizeof(snapshot->records[0])); + memcpy(snapshot->records+first,out_trace_records,(h->count-first)*sizeof(snapshot->records[0])); + __atomic_store_n(&out_trace_frozen,0u,__ATOMIC_SEQ_CST); + if (!out_trace_snapshot_count) { + out_trace_dump_line = 0; + out_trace_last_line_us = now-OUT_TRACE_LINE_US; + } + if (!replace_pending) ++out_trace_snapshot_count; + return h; } static bool out_trace_task(uint32_t now) { - const bool progress = input_count[1] != out_trace_input[0] || - input_count[2] != out_trace_input[1]; + bool progress = false; + for (unsigned instance = 0; instance < CHILDREN; ++instance) { + progress |= input_count[instance+1] != out_trace_input[instance]; + out_trace_input[instance] = input_count[instance+1]; + } if (progress) { - out_trace_input[0] = input_count[1]; - out_trace_input[1] = input_count[2]; out_trace_last_input_us = now; out_trace_seen_input = true; } - bool control_progress = false; - uint8_t stalled_control = NONE; for (uint8_t slot = 0; slot < DEVICES; ++slot) { const control_t* c = &devices[slot].control; control_trace_watch_t* watch = &out_trace_controls[slot]; @@ -1175,45 +1442,55 @@ static bool out_trace_task(uint32_t now) { watch->generation = c->generation; watch->stage = c->stage; watch->position = c->position; + watch->captured = false; + } + if (pending && !watch->captured && + (uint32_t)(now-watch->since_us) >= OUT_TRACE_STALL_US) { + out_trace_snapshot(now,slot,OUT_TRACE_PENDING); + // Count queue-full once for this unchanged state, not every tick. + watch->captured = true; } - control_progress |= changed; - if (pending && (uint32_t)(now-watch->since_us) >= OUT_TRACE_STALL_US && - stalled_control == NONE) stalled_control = slot; } - if (__atomic_load_n(&out_trace_frozen,__ATOMIC_SEQ_CST)) { - if (!out_trace_dumping) { - // A fresh completion after the dump permits diagnostic rearm. - // Never reset the producer cursor or touch controller/protocol state. - if (progress || control_progress) __atomic_store_n(&out_trace_frozen,0u,__ATOMIC_SEQ_CST); - return false; - } - } else if (stalled_control != NONE || (out_trace_seen_input && - (uint32_t)(now-out_trace_last_input_us) >= OUT_TRACE_STALL_US)) { - out_trace_freeze(now,stalled_control); - } else return false; + if (out_trace_seen_input && (uint32_t)(now-out_trace_last_input_us) >= OUT_TRACE_STALL_US) { + out_trace_snapshot(now,NONE,OUT_TRACE_IDLE); + // Root polling is not new controller input. One quiet episode must not + // repeatedly snapshot/fill the queue between tests. + out_trace_seen_input = false; + } + if (!out_trace_snapshot_count) return false; - // At most one <512-byte logger line per 50ms, below UART line capacity; - // no ring copying, waiting for Core1, masking, or formatting on Core1/IRQ. + // One bounded line per 50ms. A full logger retries this same line later; + // neither producer progress nor queued snapshot contents depend on UART. if ((uint32_t)(now-out_trace_last_line_us) < OUT_TRACE_LINE_US) return true; out_trace_last_line_us = now; - const out_trace_header_t* h = &out_trace_header; + const out_trace_snapshot_t* snapshot = &out_trace_snapshots[out_trace_snapshot_head]; + const out_trace_header_t* h = &snapshot->header; + const unsigned header_lines = 5u + (CHILDREN == 2u ? 0u : CHILDREN); + int queued; if (out_trace_dump_line == 0) { - probe_debug_printf("[HUB_FLIGHT_FREEZE] us=%"PRIu32" quiet=%"PRIu32 + queued = probe_debug_printf("[HUB_FLIGHT_FREEZE] us=%"PRIu32" quiet=%"PRIu32 +#if CHILDREN == 2 " next=%08"PRIx32" n=%"PRIu32" in=%"PRIu32"/%"PRIu32 +#else + " next=%08"PRIx32" n=%"PRIu32" children=%u" +#endif " cycle=%08"PRIx32" sof=%08"PRIx32" addr=%08"PRIx32" owner=%"PRIu32 " out0=%08"PRIx32" bs=%08"PRIx32" sie=%08"PRIx32" sm=%08"PRIx32 - " ints=%08"PRIx32" intr=%08"PRIx32" inte=%08"PRIx32"\n", - h->time_us,h->quiet_us,h->cursor,h->count,h->input[0],h->input[1], + " ints=%08"PRIx32" intr=%08"PRIx32" inte=%08"PRIx32" reason=%"PRIu32"\n", + h->time_us,h->quiet_us,h->cursor,h->count, +#if CHILDREN == 2 + h->input[0],h->input[1], +#else + CHILDREN, +#endif h->cycle,h->sof,h->address,h->owner,h->out0,h->buffers,h->sie,h->sm, - h->ints,h->intr,h->inte); + h->ints,h->intr,h->inte,h->reason); } else if (out_trace_dump_line == 1) { - probe_debug_printf("[HUB_FLIGHT_CONTEXT] next=%08"PRIx32" irq=%08"PRIx32"/%08"PRIx32 - " phase=%08"PRIx32" txerr=%08"PRIx32" rxerr=%08"PRIx32" log_us=%"PRIu32 - " bytes_at_max=%"PRIu32" max_bytes=%"PRIu32" nested=%"PRIu32"\n", - h->cursor,h->irq_enter,h->irq_exit,h->core0_phase,h->tx_error,h->rx_error, - h->log_max_us,h->log_max_us_bytes,h->log_max_bytes,h->log_nested); + queued = probe_debug_printf("[HUB_FLIGHT_CONTEXT] next=%08"PRIx32" irq=%08"PRIx32"/%08"PRIx32 + " phase=%08"PRIx32" txerr=%08"PRIx32" rxerr=%08"PRIx32"\n", + h->cursor,h->irq_enter,h->irq_exit,h->core0_phase,h->tx_error,h->rx_error); } else if (out_trace_dump_line == 2) { - probe_debug_printf("[HUB_FLIGHT_CONTROL] slot=%"PRIu32" gen=%"PRIu32 + queued = probe_debug_printf("[HUB_FLIGHT_CONTROL] slot=%"PRIu32" gen=%"PRIu32 " stage=%"PRIu32" pos=%"PRIu32"/%"PRIu32 " setup=%02x/%02x v=%04x i=%04x n=%u" " expected=%08"PRIx32" ep0=%08"PRIx32" in0=%08"PRIx32"\n", @@ -1222,32 +1499,59 @@ static bool out_trace_task(uint32_t now) { h->control_request.bmRequestType,h->control_request.bRequest, h->control_request.wValue,h->control_request.wIndex,h->control_request.wLength, h->control_word,h->ep0_word,h->in0); - } else if (out_trace_dump_line < h->count+3u) { - const out_trace_record_t* r = &out_trace_records[out_trace_dump_slot]; + } else if (out_trace_dump_line == 3) { + queued = probe_debug_printf("[HUB_FLIGHT_CONTROL_CLOCK] slot=%"PRIu32" gen=%"PRIu32 + " setup=%08"PRIx32" arm=%08"PRIx32" complete=%08"PRIx32 + " flags=%02x pid=%u arm_len=%u len=%u pub=%08"PRIx32"\n", + h->control_slot,h->control_generation,h->setup_cycle,h->first_in_arm_cycle, + h->first_in_complete_cycle,h->first_in_flags,h->first_in_pid, + h->first_in_arm_length,h->first_in_length,h->first_in_sequence); + } else if (out_trace_dump_line == 4) { + queued = probe_debug_printf("[HUB_FLIGHT_STATUS_OUT] slot=%"PRIu32" gen=%"PRIu32 + " dgen=%"PRIu32" shadow_in=%08"PRIx32" shadow_out=%08"PRIx32 + " arm=%08"PRIx32" complete=%08"PRIx32" flags=%02x len=%u\n", + h->control_slot,h->control_generation,h->device_generation, + h->control_shadow_in,h->control_shadow_out,h->status_out_arm_cycle, + h->status_out_complete_cycle,h->status_out_flags,h->status_out_length); + } else if (out_trace_dump_line < header_lines) { + const unsigned instance = out_trace_dump_line-5u; + queued = probe_debug_printf("[HUB_FLIGHT_INPUT] slot=%u in=%"PRIu32"\n",instance+1u,h->input[instance]); + } else if (out_trace_dump_line < h->count+header_lines) { + const out_trace_record_t* r = &snapshot->records[out_trace_dump_line-header_lines]; // Hex except owner/ok/lock; req=address/owner, addr/owner=before/after. // pre=0 means before-clock/address/owner are unavailable; all HW/IRQ fields are POST. // reason bits: 01 lock, 02 saved BUF_STATUS, 04 saved SETUP_REC, - // 08 original guard, 10 invalid selector arguments; 00 means selected. + // 08 original guard, 10 invalid arguments; selected records use 00 or + // 20 for first device IN after a publication notification. Match pub + // with a valid CONTROL_CLOCK arm/slot before associating a generation: + // a notification can outlive its control. Endpoint number is unknown. // Guard snapshots can race hardware; absence of 02/04 alone does not // prove cutoff was the cause. Keep raw cutoff and clocks for analysis. - probe_debug_printf("[HUB_FLIGHT] n=%08"PRIx32" pid=%02x pre=%u cutoff=%08"PRIx32 - " clock=%08"PRIx32"/%08"PRIx32" sof=%08"PRIx32 + queued = probe_debug_printf("[HUB_FLIGHT] n=%08"PRIx32" pid=%02x pre=%u cutoff=%08"PRIx32 + " clock=%08"PRIx32"/%08"PRIx32" commit=%08"PRIx32" sof=%08"PRIx32 " req=%02x/%u addr=%08"PRIx32"/%08"PRIx32" owner=%u/%u ok=%u why=%02x" " in0=%08"PRIx32" out0=%08"PRIx32" bs=%08"PRIx32" sie=%08"PRIx32" sm=%08"PRIx32 " ints=%08"PRIx32" block=%08"PRIx32"/%08"PRIx32 " lock=%"PRIu32" irq=%08"PRIx32"/%08"PRIx32" phase=%08"PRIx32 - " ep0=%08"PRIx32"\n", - r->cursor,r->pid,r->before_valid,r->cutoff,r->clock_before,r->clock_after,r->sof, + " ep0=%08"PRIx32" pub=%08"PRIx32" rxerr=%08"PRIx32"\n", + r->cursor,r->pid,r->before_valid,r->cutoff,r->clock_before,r->clock_after, + r->address_cycle,r->sof, r->address,r->owner,r->address_before,r->address_after, r->owner_before,r->owner_after,r->selected,r->reason, r->in0,r->out0,r->buffers,r->sie,r->sm,r->ints,r->blocked_buffers, - r->blocked_sie,r->missed_lock,r->irq_enter,r->irq_exit,r->core0_phase,r->ep0_word); - if (++out_trace_dump_slot == OUT_TRACE_SLOTS) out_trace_dump_slot = 0; + r->blocked_sie,r->missed_lock,r->irq_enter,r->irq_exit,r->core0_phase,r->ep0_word, + r->publication_sequence,r->rx_error); } else { - probe_debug_printf("[HUB_FLIGHT_END] next=%08"PRIx32" n=%"PRIu32"\n",h->cursor,h->count); - out_trace_dumping = false; + // Live drops stay visible even without another snapshot trigger. + queued = probe_debug_printf("[HUB_FLIGHT_END] next=%08"PRIx32" n=%"PRIu32" lost=%"PRIu32"\n", + h->cursor,h->count,out_trace_snapshot_drops); + if (queued < 0) return true; + out_trace_snapshot_head = (out_trace_snapshot_head+1u)%OUT_TRACE_SNAPSHOTS; + --out_trace_snapshot_count; + out_trace_dump_line = 0; + return true; } - ++out_trace_dump_line; + if (queued >= 0) ++out_trace_dump_line; return true; } #endif @@ -1266,13 +1570,12 @@ void native_hub_task(void) { else if (event.device < DEVICES && event.kind == 1) setup_request(&event); else if (event.device < DEVICES && event.kind == 2) transfer_complete(&event); } - restore_selected_bank(); uint32_t now = time_us_32(); if (usb_hw->ep_nak_stall_status & (1u << 30)) { ++root_naks; hw_clear_bits(&usb_hw->ep_nak_stall_status,1u << 30); } - for (unsigned p = 0; p < 2; ++p) { + for (unsigned p = 0; p < CHILDREN; ++p) { if ((ports[p].status & RESET) && (int32_t)(now-ports[p].deadline) >= 0) { if (default_device != NONE && default_device != p+1) { failed = true; return; } ports[p].status = (ports[p].status & ~RESET) | ENABLE; @@ -1280,7 +1583,9 @@ void native_hub_task(void) { } } if (devices[0].configuration && !devices[0].ep[2].busy && !devices[0].ep[2].halted) { - uint8_t changed = (ports[0].change ? 2u : 0u) | (ports[1].change ? 4u : 0u); + uint8_t changed = 0; + for (unsigned p = 0; p < CHILDREN; ++p) + if (ports[p].change) changed |= 1u << (p+1u); if (changed) { endpoint_t* ep = &devices[0].ep[2]; ep->data[0] = changed; ep->length = 1; ep->sent = 0; ep->busy = ep->flush = true; ep->zlp = false; @@ -1293,6 +1598,7 @@ void native_hub_task(void) { #endif if ((uint32_t)(now-last_log) >= 1000000u) { last_log = now; +#if CHILDREN == 2 probe_debug_printf("[NATIVE_HUB] addr=%u/%u/%u cfg=%u/%u/%u setup=%"PRIu32"/%"PRIu32"/%"PRIu32 " in=%"PRIu32"/%"PRIu32" out=%"PRIu32"/%"PRIu32" switch=%"PRIu32" busy=%"PRIu32" slow=%"PRIu32" late=%"PRIu32"/%"PRIu32"\n", addresses[0],addresses[1],addresses[2],devices[0].configuration,devices[1].configuration,devices[2].configuration, @@ -1306,6 +1612,25 @@ void native_hub_task(void) { " lock=%"PRIu32" blocked=%08"PRIx32"/%08"PRIx32" rootnak=%"PRIu32"\n", token_hits[0],token_hits[1],token_hits[2],missed_lock, blocked_buffers,blocked_sie,root_naks); +#else + probe_debug_printf("[NATIVE_HUB] children=%u switch=%"PRIu32" busy=%"PRIu32 + " slow=%"PRIu32" late=%"PRIu32"/%"PRIu32"\n", + CHILDREN,switches,missed_switches,slow_switches,minimum_lateness,maximum_lateness); + probe_debug_printf("[HUB_SIE] owner=%u sie=%08"PRIx32" nak=%08"PRIx32 + " txerr=%08"PRIx32" rxerr=%08"PRIx32" ep1=%08"PRIx32"/%08"PRIx32"\n", + active_device,usb_hw->sie_status,usb_hw->ep_nak_stall_status, + usb_hw->ep_tx_error,usb_hw->ep_rx_error,endpoint_regs()[0],buffer_regs()[2]); + probe_debug_printf("[HUB_ROUTE] lock=%"PRIu32" blocked=%08"PRIx32"/%08"PRIx32" rootnak=%"PRIu32"\n", + missed_lock,blocked_buffers,blocked_sie,root_naks); + for (unsigned slot = 0; slot < DEVICES; ++slot) { + probe_debug_printf("[HUB_SLOT] slot=%u addr=%u cfg=%u setup=%"PRIu32 + " in=%"PRIu32" out=%"PRIu32" hits=%"PRIu32 + " hidbusy=%u status=%04x change=%04x\n", + slot,addresses[slot],devices[slot].configuration,setup_count[slot], + input_count[slot],output_count[slot],token_hits[slot],devices[slot].ep[2].busy, + slot ? ports[slot-1].status : 0,slot ? ports[slot-1].change : 0); + } +#endif #if SWITCH2_PROBE_TRACE_NATIVE_INPUT probe_router_stats observer; probe_router_snapshot(&observer); @@ -1321,8 +1646,10 @@ void native_hub_task(void) { " mtime=%08"PRIx32" watchdog=%08"PRIx32" nak_poll=%08"PRIx32"\n", usb_hw->intr,usb_hw->inte,usb_hw->sof_rd,sio_hw->mtime, usb_hw->dev_sm_watchdog,usb_hw->nak_poll); +#if CHILDREN == 2 probe_debug_printf("[HUB_PORTS] status=%04x/%04x change=%04x/%04x\n", ports[0].status,ports[1].status,ports[0].change,ports[1].change); +#endif for (uint8_t slot = 0; slot < DEVICES; ++slot) { const device_t* d = &devices[slot]; probe_debug_printf("[HUB_EP0] slot=%u stage=%u gen=%"PRIu32"/%"PRIu32 diff --git a/src/firmware/usb/native_hub/native_hub.h b/src/firmware/usb/native_hub/native_hub.h index 05b8fbc..266a927 100644 --- a/src/firmware/usb/native_hub/native_hub.h +++ b/src/firmware/usb/native_hub/native_hub.h @@ -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); diff --git a/src/firmware/usb/native_hub/native_hub_trace.h b/src/firmware/usb/native_hub/native_hub_trace.h index c09a6be..9f2d60e 100644 --- a/src/firmware/usb/native_hub/native_hub_trace.h +++ b/src/firmware/usb/native_hub/native_hub_trace.h @@ -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, diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index bc228e1..fe741c3 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -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 diff --git a/tests/native_gamepad_backend_test.cpp b/tests/native_gamepad_backend_test.cpp index 535a53f..8369dfe 100644 --- a/tests/native_gamepad_backend_test.cpp +++ b/tests/native_gamepad_backend_test.cpp @@ -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(ControllerProfileLogicalButton::kSouth)] = + static_cast(ControllerProfileLogicalButton::kNorth); + profile_b.button_map[static_cast(ControllerProfileLogicalButton::kEast)] = + static_cast(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; } diff --git a/tests/native_hub_log_test.c b/tests/native_hub_log_test.c new file mode 100644 index 0000000..9e0154b --- /dev/null +++ b/tests/native_hub_log_test.c @@ -0,0 +1,104 @@ +#include +#include +#include +#include +#include +#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; +} diff --git a/tests/native_hub_management_test.cpp b/tests/native_hub_management_test.cpp index 0cb384a..f046513 100644 --- a/tests/native_hub_management_test.cpp +++ b/tests/native_hub_management_test.cpp @@ -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 fl uint32_t programs = 0; uint32_t erases = 0; uint32_t bootsel_calls = 0; -std::array child_identity[2]; +std::array 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 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(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(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& expected) { + const auto setup = request(Operation::kProfileRead, true, kMaximumResponseSize); + require(native_test_setup(0, &setup, true), "interleaved profile read setup failed"); + std::vector 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(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(Operation::kProfileRead) && + bytes[6] == static_cast(Status::kOk) && u16(bytes, 8) == expected.size() && + u32(bytes, 16) == configuration_crc32(expected.data(), expected.size()) && + std::vector(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 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"; } diff --git a/tests/native_hub_router_test.c b/tests/native_hub_router_test.c new file mode 100644 index 0000000..a9b50e3 --- /dev/null +++ b/tests/native_hub_router_test.c @@ -0,0 +1,160 @@ +#include "hardware_stub.h" +#include +#include + +// 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; +} diff --git a/tests/native_hub_stubs/hardware/uart.h b/tests/native_hub_stubs/hardware/uart.h new file mode 100644 index 0000000..2f40265 --- /dev/null +++ b/tests/native_hub_stubs/hardware/uart.h @@ -0,0 +1,6 @@ +#pragma once +#include +#include +#define uart0 ((void*)0) +bool uart_is_writable(void* uart); +void uart_putc_raw(void* uart, char value); diff --git a/tests/native_hub_stubs/pico.h b/tests/native_hub_stubs/pico.h new file mode 100644 index 0000000..f83a6b3 --- /dev/null +++ b/tests/native_hub_stubs/pico.h @@ -0,0 +1 @@ +#include "hardware_stub.h" diff --git a/tests/native_hub_trace_test.c b/tests/native_hub_trace_test.c new file mode 100644 index 0000000..0b04941 --- /dev/null +++ b/tests/native_hub_trace_test.c @@ -0,0 +1,2230 @@ +#include +#include +#include +#include +#include + +#include "hardware_stub.h" + +static sio_hw_t* trace_test_sio(void); +static bool trace_test_lock_busy; +static bool trace_test_trylock(spin_lock_t* lock) { + return !trace_test_lock_busy && spin_try_lock_unsafe(lock); +} +#undef sio_hw +#define sio_hw trace_test_sio() +#define spin_try_lock_unsafe trace_test_trylock + +#define NATIVE_TEST_EXTERNAL_LOG 1 +#include "native_hub_transport_fixture.c" + +// Each scenario runs in a fresh process: the real recorder's BSS is its reset. +// Hardware registers model host/IRQ observations, not packets on a USB wire. +#define TEST_STALL_US 200000u +#define TEST_LINE_US 50000u +#define TEST_RETAIN 64u +#define TEST_PID_OUT 0xe1u +#define TEST_PID_IN 0x69u +#define TEST_PID_SETUP 0x2du +#define TEST_LOG_LINES 1024u +#define TEST_LINE_SIZE 512u + +static char log_lines[TEST_LOG_LINES][TEST_LINE_SIZE]; +static unsigned log_count; +static bool logger_full, retry_each_line; +static unsigned retry_count, rejected_lines; +static char rejected_line[TEST_LINE_SIZE]; +static uint8_t vendor_reply[96]; +static bool supersede_in_callback; +static uint8_t handover_reply[DEVICES][83]; +static unsigned handover_acks[DEVICES]; +static bool supersede_status_in_callback; +static bool reject_status_in_callback; +static unsigned handover_data_callbacks[DEVICES]; +static uint8_t approved_reply[DEVICES][2u*PACKET]; +typedef struct { + uint8_t slot, stage; + tusb_control_request_t request; +} approved_callback_t; +static approved_callback_t approved_callbacks[64]; +static unsigned approved_callback_count; +static bool reset_on_root_complete; + +typedef struct { + usb_device_dpram_t dpram; + uint32_t stall; +} hardware_bank_t; +static bool clock_steps, commit_probe, commit_handover; +static uint8_t commit_address, commit_owner; +static uint32_t commit_cycle, commit_buffers[CHANNELS], commit_controls[4]; +static uint32_t commit_root_control, commit_stall; +static hardware_bank_t commit_bank, commit_latched_bank; + +static hardware_bank_t hardware_bank(void) { + hardware_bank_t bank; + memcpy(&bank.dpram,usb_dpram,sizeof(bank.dpram)); + bank.stall = usb_hw->ep_stall_arm; + return bank; +} + +static void expect_bank_unchanged(const hardware_bank_t* bank) { + assert(memcmp(&bank->dpram,usb_dpram,sizeof(bank->dpram)) == 0); + assert(usb_hw->ep_stall_arm == bank->stall); +} + +static sio_hw_t* trace_test_sio(void) { + if (clock_steps) ++native_test_sio.mtime; + if (commit_probe && usb_hw->dev_addr_ctrl == commit_address) { + commit_probe = false; + commit_cycle = native_test_sio.mtime; + // Latch the bank at the first clock access after the address store. + // Later packet delivery uses this metadata, not a repaired return-time + // bank. This models the publication contract, NOT physical SIE timing, + // packet acceptance, or how hardware ACKs a mismatched DATA PID. + commit_latched_bank = hardware_bank(); + assert(active_device == commit_owner); + if (commit_handover) { + const uint32_t* buffers = (const uint32_t*)&commit_latched_bank.dpram.ep_buf_ctrl[0]; + const uint32_t* controls = (const uint32_t*)&commit_latched_bank.dpram.ep_ctrl[0]; + for (unsigned channel = 0; channel < CHANNELS; ++channel) { + assert(!(buffers[channel] & USB_BUF_CTRL_AVAIL)); + assert(buffers[channel] == commit_buffers[channel] && + "address commit exposed missing or outgoing packet metadata"); + } + for (unsigned i = 0; i < 4; ++i) + assert(controls[i] == commit_controls[i]); + assert(commit_latched_bank.dpram.ep_ctrl[14].in == commit_root_control); + assert(commit_latched_bank.stall == commit_stall); + // Root EP15 has independent storage; its buffer is not the child + // EP1 bank. Visibility follows the incoming root endpoint control. + assert(commit_latched_bank.dpram.ep_buf_ctrl[15].in == + commit_bank.dpram.ep_buf_ctrl[15].in); + } else expect_bank_unchanged(&commit_bank); + } + return &native_test_sio; +} + +static uint16_t handover_length(uint8_t slot) { + return slot == 0 ? 16 : slot % 2u ? PACKET : sizeof(handover_reply[slot]); +} + +int probe_debug_printf(const char* format, ...) { + char line[TEST_LINE_SIZE]; + va_list arguments; + va_start(arguments,format); + int length = vsnprintf(line,sizeof(line),format,arguments); + va_end(arguments); + assert(length >= 0 && (size_t)length < sizeof(line)); + if (reset_on_root_complete && strstr(line,"[HUB_CTRL] complete ") == line) { + reset_on_root_complete = false; + native_test_bus_reset(false); + } + if (strncmp(line,"[HUB_FLIGHT",11) != 0) return length; + if (logger_full) return -1; + if (retry_each_line) { + if (!retry_count) memcpy(rejected_line,line,(size_t)length+1u); + else assert(strcmp(rejected_line,line) == 0 && "a rejected dump line was skipped or changed"); + if (++retry_count <= 2u) { ++rejected_lines; return -1; } + retry_count = 0; + } + assert(log_count < TEST_LOG_LINES); + memcpy(log_lines[log_count++],line,(size_t)length+1u); + return length; +} + +void reset_usb_boot(uint32_t gpio_mask, uint32_t disable_mask) { + (void)gpio_mask; (void)disable_mask; + assert(false && "transport failed closed during trace regression"); + abort(); +} + +bool tud_vendor_control_xfer_cb(uint8_t slot, uint8_t stage, const tusb_control_request_t* request) { + if (request->bmRequestType == 0xc0 && request->bRequest == 0x5d) { + if (stage == CONTROL_STAGE_SETUP) { + if (!request->wValue || request->wValue > sizeof(approved_reply[slot]) || + !request->wLength) return false; + return native_hub_control_xfer(slot,request,approved_reply[slot],request->wValue,true); + } + assert(stage == CONTROL_STAGE_DATA || stage == CONTROL_STAGE_ACK); + assert(approved_callback_count < sizeof(approved_callbacks)/sizeof(approved_callbacks[0])); + approved_callbacks[approved_callback_count++] = (approved_callback_t){ + .slot = slot, .stage = stage, .request = *request, + }; + return true; // SETUP validated the reply; DATA never rejects status. + } + if (request->bmRequestType == 0xc0 && request->bRequest == 0x5b) { + if (stage == CONTROL_STAGE_ACK) ++handover_acks[slot]; + if (stage == CONTROL_STAGE_DATA) { + ++handover_data_callbacks[slot]; + if (reject_status_in_callback) return false; + } + if (stage == CONTROL_STAGE_DATA && supersede_status_in_callback) { + supersede_status_in_callback = false; + const tusb_control_request_t replacement = { + .bmRequestType = 0x80, .bRequest = TUSB_REQ_GET_DESCRIPTOR, + .wValue = TUSB_DESC_DEVICE << 8, .wLength = 18, + }; + assert(native_test_setup(slot,&replacement,false)); + } + return stage != CONTROL_STAGE_SETUP || native_hub_control_xfer( + slot,request,handover_reply[slot],handover_length(slot),false); + } + if (request->bmRequestType != 0xc0 || request->bRequest != 0x5a) return false; + if (stage != CONTROL_STAGE_SETUP) return true; + bool accepted = native_hub_control_xfer(slot,request,vendor_reply,sizeof(vendor_reply),false); + if (supersede_in_callback) { + supersede_in_callback = false; + const tusb_control_request_t replacement = { + .bmRequestType = 0x80, .bRequest = TUSB_REQ_GET_DESCRIPTOR, + .wValue = TUSB_DESC_DEVICE << 8, .wLength = 18, + }; + native_test_time_us += 7u; + assert(native_test_setup(slot,&replacement,false)); + } + return accepted; +} + +static bool tagged(const char* line, const char* tag) { + return strncmp(line,tag,strlen(tag)) == 0; +} + +static unsigned count_tag(const char* tag) { + unsigned count = 0; + for (unsigned i = 0; i < log_count; ++i) count += tagged(log_lines[i],tag); + return count; +} + +static const char* snapshot_line(unsigned snapshot, const char* tag) { + unsigned current = 0; + for (unsigned i = 0; i < log_count; ++i) { + if (tagged(log_lines[i],"[HUB_FLIGHT_FREEZE]")) ++current; + if (current == snapshot+1u && tagged(log_lines[i],tag)) return log_lines[i]; + } + assert(false && "required snapshot diagnostic was not emitted"); + return NULL; +} + +static uint32_t field(const char* line, const char* name, unsigned base) { + const char* value = strstr(line,name); + assert(value != NULL && "required diagnostic field is absent"); + value += strlen(name); + char* end; + unsigned long result = strtoul(value,&end,(int)base); + assert(end != value && result <= UINT32_MAX); + return (uint32_t)result; +} + +static const char* record_line(unsigned snapshot, unsigned index) { + unsigned current = 0, record = 0; + for (unsigned i = 0; i < log_count; ++i) { + const char* line = log_lines[i]; + if (tagged(line,"[HUB_FLIGHT_FREEZE]")) ++current; + if (current == snapshot+1u && tagged(line,"[HUB_FLIGHT]") && record++ == index) + return line; + } + assert(false && "required retained token was not emitted"); + return NULL; +} + +static const char* expect_observation(unsigned snapshot, unsigned index, uint8_t address, + uint8_t owner, uint8_t pid, uint32_t cycle, + bool selected) { + const char* line = record_line(snapshot,index); + assert(field(line," cutoff=",16) == 0x70000000u+cycle); + assert(field(line," pid=",16) == pid); + assert(field(line," pre=",10) == 0 && field(line," ok=",10) == selected); + if (!selected) assert(field(line," commit=",16) == 0); + char expected[80]; + snprintf(expected,sizeof(expected)," req=%02x/%u ",(unsigned)address,(unsigned)owner); + assert(strstr(line,expected)); + assert(strstr(line," addr=00000000/") && strstr(line," owner=255/")); + snprintf(expected,sizeof(expected)," clock=00000000/%08"PRIx32" ",cycle); + assert(strstr(line,expected)); + return line; +} + +static void expect_lost(unsigned snapshots, uint32_t lost) { + for (unsigned i = 0; i < snapshots; ++i) + assert(field(snapshot_line(i,"[HUB_FLIGHT_END]")," lost=",10) == lost); +} + +static void poll(unsigned ticks) { + for (unsigned i = 0; i < ticks; ++i) native_test_advance(TEST_LINE_US); +} + +static void expect_dump_order(unsigned snapshots) { + unsigned line = 0; + const char* headers[] = { + "[HUB_FLIGHT_FREEZE]", "[HUB_FLIGHT_CONTEXT]", "[HUB_FLIGHT_CONTROL]", + "[HUB_FLIGHT_CONTROL_CLOCK]", "[HUB_FLIGHT_STATUS_OUT]", + }; + for (unsigned snapshot = 0; snapshot < snapshots; ++snapshot) { + assert(line < log_count); + unsigned records = field(log_lines[line]," n=",10); + for (unsigned h = 0; h < sizeof(headers)/sizeof(headers[0]); ++h) { + assert(line < log_count && tagged(log_lines[line],headers[h])); + ++line; + } + if (CHILDREN != 2u) { + for (unsigned slot = 1; slot <= CHILDREN; ++slot) { + assert(line < log_count && tagged(log_lines[line],"[HUB_FLIGHT_INPUT]")); + assert(field(log_lines[line++]," slot=",10) == slot); + } + } + for (unsigned record = 0; record < records; ++record) { + assert(line < log_count && tagged(log_lines[line],"[HUB_FLIGHT]")); + ++line; + } + assert(line < log_count && tagged(log_lines[line],"[HUB_FLIGHT_END]")); + assert(field(log_lines[line++]," n=",10) == records); + } +} + +static void dump_through(unsigned snapshots) { + unsigned attempts = 0; + while (count_tag("[HUB_FLIGHT_END]") < snapshots && attempts++ < 1000u) + native_test_advance(TEST_LINE_US); + assert(count_tag("[HUB_FLIGHT_END]") == snapshots && "snapshot dump failed to finish"); + expect_dump_order(snapshots); +} + +static void configure_child(uint8_t slot) { + const tusb_control_request_t request = { + .bRequest = TUSB_REQ_SET_CONFIGURATION, .wValue = 1, + }; + uint8_t data[PACKET]; + uint16_t length = UINT16_MAX; + assert(native_test_setup(slot,&request,true)); + assert(native_test_in(slot,data,&length,true) && length == 0); + assert(native_hub_mounted(slot-1u)); +} + +static void input_completion(uint8_t slot) { + const uint8_t payload[] = {0x12,0x34,0x56}; + uint8_t data[PACKET]; + uint16_t length = 0; + assert(native_hub_hid_report(slot-1u,0x30,payload,sizeof(payload))); + assert(native_test_private_in(slot,0x81,data,&length)); + assert(length == sizeof(payload)+1u && data[0] == 0x30); + assert(memcmp(data+1,payload,sizeof(payload)) == 0); + native_test_drain(); +} + +static void selections(uint32_t marker, unsigned count) { + for (unsigned i = 0; i < count; ++i) { + uint8_t slot = 1u + i % CHILDREN; + sio_hw->mtime = 1000u+i; + assert(native_hub_select_device(addresses[slot],slot,marker+i)); + native_hub_note_selected_token(addresses[slot],slot,marker+i,TEST_PID_OUT); + } +} + +static void expect_records(unsigned snapshot, uint32_t marker, unsigned count) { + unsigned current = 0, found = 0; + for (unsigned i = 0; i < log_count; ++i) { + const char* line = log_lines[i]; + if (tagged(line,"[HUB_FLIGHT_FREEZE]")) ++current; + if (current != snapshot+1u || !tagged(line,"[HUB_FLIGHT]")) continue; + assert(found < count); + assert(field(line," cutoff=",16) == marker+found); + assert(field(line," pid=",16) == TEST_PID_OUT); + assert(field(line," ok=",10) == 1); + ++found; + } + assert(found == count && "snapshot lost selections or read the overwritten live ring"); + assert(field(snapshot_line(snapshot,"[HUB_FLIGHT_FREEZE]")," n=",10) == count); + assert(field(snapshot_line(snapshot,"[HUB_FLIGHT_END]")," n=",10) == count); + assert(field(snapshot_line(snapshot,"[HUB_FLIGHT_FREEZE]")," reason=",10) == 0); +} + +static void root_read(void) { + const tusb_control_request_t request = { + .bmRequestType = 0x80, .bRequest = TUSB_REQ_GET_STATUS, .wLength = 2, + }; + uint8_t data[PACKET]; + uint16_t length = 0; + assert(native_test_setup(0,&request,true)); + assert(native_test_in(0,data,&length,true) && length == 2); + assert(data[0] == 1 && data[1] == 0); + assert(native_test_out(0,NULL,0,true)); +} + +typedef struct { + uint32_t time_us, generation; + uint8_t slot; +} marker_receipt_t; + +static tusb_control_request_t marker_request(uint16_t slot) { + const tusb_control_request_t request = { + .bmRequestType = 0xc0, .bRequest = 0x5e, .wValue = 0x5452, + .wIndex = slot, .wLength = 16, + }; + return request; +} + +static uint32_t reply_u32(const uint8_t* data) { + return (uint32_t)data[0] | (uint32_t)data[1] << 8 | + (uint32_t)data[2] << 16 | (uint32_t)data[3] << 24; +} + +static marker_receipt_t capture_marker(uint8_t slot, uint8_t status) { + const tusb_control_request_t request = marker_request(slot); + uint8_t data[PACKET]; + memset(data,0xa5,sizeof(data)); + uint16_t length = UINT16_MAX; + assert(native_test_setup(0,&request,true)); + assert(native_test_in(0,data,&length,true) && length == 16); + assert(memcmp(data,"NHTR",4) == 0 && data[4] == 1); + assert(data[5] == status && data[6] == slot && data[7] == 0); + const marker_receipt_t receipt = { + .time_us = reply_u32(data+8), .generation = reply_u32(data+12), .slot = data[6], + }; + if (status == 1) assert(receipt.time_us == 0 && receipt.generation == 0); + assert(native_test_out(0,NULL,0,true)); + return receipt; +} + +static void expect_marker(unsigned snapshot, const marker_receipt_t* receipt) { + const char* header = snapshot_line(snapshot,"[HUB_FLIGHT_FREEZE]"); + const char* control = snapshot_line(snapshot,"[HUB_FLIGHT_CONTROL]"); + assert(field(header," reason=",10) == 3); + assert(field(header," us=",10) == receipt->time_us); + assert(field(control," slot=",10) == receipt->slot); + assert(field(control," gen=",10) == receipt->generation); + const char* clock = snapshot_line(snapshot,"[HUB_FLIGHT_CONTROL_CLOCK]"); + assert(field(clock," slot=",10) == receipt->slot); + assert(field(clock," gen=",10) == receipt->generation); + const char* status = snapshot_line(snapshot,"[HUB_FLIGHT_STATUS_OUT]"); + assert(field(status," slot=",10) == receipt->slot); + assert(field(status," gen=",10) == receipt->generation); +} + +static void live_wrap(void) { + configure_child(CHILDREN); + selections(0x10000000u,TEST_RETAIN); + input_completion(CHILDREN); + native_test_advance(TEST_STALL_US); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 1); + assert(count_tag("[HUB_FLIGHT_END]") == 0); + + // The first idle dump is still underway. New successful selections must + // remain observable in a second snapshot, not disappear until UART drains. + input_completion(CHILDREN); + selections(0x20000000u,TEST_RETAIN); + native_test_advance(TEST_STALL_US); + selections(0x30000000u,3u*TEST_RETAIN); + dump_through(2); + expect_records(0,0x10000000u,TEST_RETAIN); + expect_records(1,0x20000000u,TEST_RETAIN); + poll(100); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 2); +} + +static void root_does_not_rearm(void) { + configure_child(CHILDREN); + selections(0x10000000u,4); + input_completion(CHILDREN); + native_test_advance(TEST_STALL_US); + for (unsigned i = 0; i < 40; ++i) { + root_read(); + native_test_advance(TEST_LINE_US); + } + dump_through(1); + for (unsigned i = 0; i < 40; ++i) { + root_read(); + native_test_advance(TEST_LINE_US); + } + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 1 && "root control traffic rearmed input-idle capture"); + input_completion(CHILDREN); + native_test_advance(TEST_STALL_US-1u); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 1); + native_test_advance(1); + dump_through(2); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 2); +} + +static void queue_pressure(void) { + configure_child(CHILDREN); + selections(0x10000000u,TEST_RETAIN); + input_completion(CHILDREN); + native_test_advance(TEST_STALL_US); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 1); + logger_full = true; + for (unsigned request = 2; request <= 3; ++request) { + input_completion(CHILDREN); + selections(request*0x10000000u,TEST_RETAIN); + native_test_advance(TEST_STALL_US); + } + const tusb_control_request_t pending = { + .bmRequestType = 0x80, .bRequest = TUSB_REQ_GET_DESCRIPTOR, + .wValue = TUSB_DESC_DEVICE << 8, .wLength = 18, + }; + assert(native_test_setup(CHILDREN,&pending,true)); + native_test_advance(TEST_STALL_US); // One more drop, from pending control. + selections(0x40000000u,3u*TEST_RETAIN); + poll(40); // Queue pressure consumes both idle and pending one-shots. + logger_full = false; + dump_through(2); + poll(100); // No later trigger is available to reveal the lost request. + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 2); + assert(count_tag("[HUB_FLIGHT_END]") == 2); + expect_records(0,0x10000000u,TEST_RETAIN); + expect_records(1,0x20000000u,TEST_RETAIN); + uint32_t lost = 0; + for (unsigned i = 0; i < log_count; ++i) + if (strstr(log_lines[i]," lost=")) { + uint32_t reported = field(log_lines[i]," lost=",10); + assert(reported <= 2u && "full queue repeatedly counted an unchanged trigger"); + if (reported > lost) lost = reported; + } + assert(lost == 2u && "dropped snapshot requests were never reported"); +} + +static void backpressure(bool full) { + configure_child(CHILDREN); + selections(0x10000000u,TEST_RETAIN); + input_completion(CHILDREN); + retry_each_line = full; + native_test_advance(TEST_STALL_US); + dump_through(1); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 1); + assert(count_tag("[HUB_FLIGHT_CONTEXT]") == 1); + assert(count_tag("[HUB_FLIGHT_CONTROL]") == 1); + assert(count_tag("[HUB_FLIGHT_CONTROL_CLOCK]") == 1); + assert(count_tag("[HUB_FLIGHT_STATUS_OUT]") == 1); + expect_records(0,0x10000000u,TEST_RETAIN); + if (full) assert(rejected_lines == 2u*log_count && retry_count == 0); + for (unsigned i = 0; i < log_count; ++i) fputs(log_lines[i],stdout); +} + +static tusb_control_request_t vendor_request(void) { + const tusb_control_request_t request = { + .bmRequestType = 0xc0, .bRequest = 0x5a, .wValue = 0x1122, + .wIndex = 0x3344, .wLength = sizeof(vendor_reply), + }; + return request; +} + +static tusb_control_request_t descriptor_request(void) { + const tusb_control_request_t request = { + .bmRequestType = 0x80, .bRequest = TUSB_REQ_GET_DESCRIPTOR, + .wValue = TUSB_DESC_DEVICE << 8, .wLength = 18, + }; + return request; +} + +static void expect_clock(unsigned snapshot, uint32_t setup, uint32_t arm, + uint32_t complete, uint32_t flags, uint16_t arm_length, + uint16_t complete_length) { + const char* clock = snapshot_line(snapshot,"[HUB_FLIGHT_CONTROL_CLOCK]"); + const char* control = snapshot_line(snapshot,"[HUB_FLIGHT_CONTROL]"); + assert(field(clock," slot=",10) == field(control," slot=",10)); + assert(field(clock," gen=",10) == field(control," gen=",10)); + assert(field(clock," setup=",16) == setup); + assert(field(clock," arm=",16) == arm); + assert(field(clock," complete=",16) == complete); + assert(field(clock," flags=",16) == flags); + assert(field(clock," pid=",10) == 1); // First EP0 publication is DATA1. + assert(field(clock," arm_len=",10) == arm_length); + assert(field(clock," len=",10) == complete_length); +} + +static const char* expect_status_out(unsigned snapshot, uint32_t arm, uint32_t complete, + uint8_t flags, uint16_t length) { + const char* status = snapshot_line(snapshot,"[HUB_FLIGHT_STATUS_OUT]"); + const char* control = snapshot_line(snapshot,"[HUB_FLIGHT_CONTROL]"); + assert(field(status," slot=",10) == field(control," slot=",10)); + assert(field(status," gen=",10) == field(control," gen=",10)); + assert(field(status," arm=",16) == arm); + assert(field(status," complete=",16) == complete); + assert(field(status," flags=",16) == flags); + assert(field(status," len=",10) == length); + return status; +} + +static void pending_one_shot(void) { + const tusb_control_request_t request = vendor_request(); + assert(native_test_setup(CHILDREN,&request,true)); + native_test_advance(TEST_STALL_US-1u); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 0); + native_test_advance(1); + dump_through(1); + poll(20); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 1); + + uint8_t data[PACKET]; + uint16_t length = 0; + assert(native_test_in(CHILDREN,data,&length,true) && length == PACKET); + assert(memcmp(data,vendor_reply,length) == 0); + native_test_advance(TEST_STALL_US); + dump_through(2); + poll(20); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 2); + assert(native_test_in(CHILDREN,data,&length,true) && length == sizeof(vendor_reply)-PACKET); + assert(memcmp(data,vendor_reply+PACKET,length) == 0); + native_test_advance(TEST_STALL_US); + dump_through(3); + poll(20); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 3); + + const char* first = snapshot_line(0,"[HUB_FLIGHT_CONTROL]"); + const char* second = snapshot_line(1,"[HUB_FLIGHT_CONTROL]"); + const char* third = snapshot_line(2,"[HUB_FLIGHT_CONTROL]"); + assert(strstr(first," pos=0/96 ") && strstr(second," pos=64/96 ") && strstr(third," pos=96/96 ")); + assert(field(first," gen=",10) == field(second," gen=",10)); + assert(field(second," gen=",10) == field(third," gen=",10)); + assert(field(first," stage=",10) == field(second," stage=",10)); + assert(field(second," stage=",10) != field(third," stage=",10)); + for (unsigned i = 0; i < 3; ++i) + assert(field(snapshot_line(i,"[HUB_FLIGHT_FREEZE]")," reason=",10) == 1); + // A zero cycle is valid evidence, not a missing-sample sentinel. Later + // 32-byte completion must not replace the first 64-byte publication. + expect_clock(0,0,0,0,1,PACKET,0); + expect_clock(1,0,0,0,3,PACKET,PACKET); + expect_clock(2,0,0,0,3,PACKET,PACKET); + expect_status_out(0,0,0,0,0); + expect_status_out(1,0,0,0,0); + const char* status = expect_status_out(2,0,0,1,0); + assert((field(status," shadow_out=",16) & + (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_DATA1_PID | USB_BUF_CTRL_LEN_MASK)) == + (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_DATA1_PID)); + + // Superseding a state already captured by the pending-control trigger must + // not queue the same generation/stage/position for a second time. + const tusb_control_request_t replacement = descriptor_request(); + assert(native_test_setup(CHILDREN,&replacement,true)); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 3); + assert(native_test_in(CHILDREN,data,&length,true) && length == 18); + assert(native_test_out(CHILDREN,NULL,0,true)); + poll(100); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 3); +} + + +static void expect_control_tokens(unsigned snapshot, const uint32_t* cycles, unsigned count) { + unsigned current = 0, found = 0; + for (unsigned i = 0; i < log_count; ++i) { + const char* line = log_lines[i]; + if (tagged(line,"[HUB_FLIGHT_FREEZE]")) ++current; + if (current != snapshot+1u || !tagged(line,"[HUB_FLIGHT]")) continue; + assert(found < count); + assert(field(line," cutoff=",16) == 0x70000000u+cycles[found]); + assert(field(line," pid=",16) == (found % 2u ? TEST_PID_IN : TEST_PID_SETUP)); + assert(field(line," pre=",10) == 0 && field(line," ok=",10) == 1); + char clock[48]; + snprintf(clock,sizeof(clock)," clock=00000000/%08"PRIx32" ",cycles[found]); + assert(strstr(line,clock)); + ++found; + } + assert(found == count && "accepted SETUP/first-IN token coverage is incomplete or duplicated"); +} + +static void control_token(uint8_t slot, uint8_t pid, uint32_t cycle) { + sio_hw->mtime = cycle; + assert(native_test_select(slot)); + native_hub_note_selected_token(addresses[slot],slot,0x70000000u+cycle,pid); +} + +static void superseded(void) { + const uint8_t slot = CHILDREN; + const tusb_control_request_t old_request = vendor_request(); + const tusb_control_request_t new_request = descriptor_request(); + control_token(slot,TEST_PID_SETUP,100); + sio_hw->mtime = 200; + assert(native_test_setup(slot,&old_request,false)); + sio_hw->mtime = 300; + native_test_drain(); + control_token(slot,TEST_PID_IN,400); + control_token(slot,TEST_PID_IN,450); // Only the first accepted IN is retained. + uint8_t data[PACKET]; + uint16_t length = 0; + sio_hw->mtime = 500; + assert(native_test_in(slot,data,&length,false) && length == PACKET); + assert(memcmp(data,vendor_reply,length) == 0); + control_token(slot,TEST_PID_SETUP,600); + sio_hw->mtime = 700; + assert(native_test_setup(slot,&new_request,false)); + // Both events are queued. Hardware completion time must survive delayed + // foreground processing and the newer device generation's protocol abort. + sio_hw->mtime = 800; + native_test_drain(); + control_token(slot,TEST_PID_IN,900); + control_token(slot,TEST_PID_IN,950); + sio_hw->mtime = 1000; + assert(native_test_in(slot,data,&length,false) && length == 18); + sio_hw->mtime = 1100; + native_test_drain(); + native_test_advance(TEST_STALL_US); + dump_through(2); + poll(100); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 2); + const char* old = snapshot_line(0,"[HUB_FLIGHT_CONTROL]"); + const char* next = snapshot_line(1,"[HUB_FLIGHT_CONTROL]"); + assert(field(old," slot=",10) == slot && field(next," slot=",10) == slot); + assert(field(next," gen=",10) == field(old," gen=",10)+1u); + assert(strstr(old," setup=c0/5a v=1122 i=3344 n=96 ")); + assert(strstr(old," pos=0/96 ")); + assert(strstr(next," setup=80/06 v=0100 i=0000 n=18 ")); + assert(strstr(next," pos=18/18 ")); + assert(field(snapshot_line(0,"[HUB_FLIGHT_FREEZE]")," reason=",10) == 2); + assert(field(snapshot_line(1,"[HUB_FLIGHT_FREEZE]")," reason=",10) == 1); + expect_clock(0,200,300,500,3,PACKET,PACKET); + expect_clock(1,700,800,1000,3,18,18); + expect_status_out(0,0,0,0,0); + expect_status_out(1,1000,0,1,0); // Standard reads arm at final-IN IRQ, not drain at 1100. + const uint32_t cycles[] = {100,400,600,900}; + expect_control_tokens(0,cycles,3); + expect_control_tokens(1,cycles,4); +} + +static void immediate_supersession(void) { + supersede_in_callback = true; + const tusb_control_request_t request = vendor_request(); + assert(native_test_setup(CHILDREN,&request,true)); + uint8_t data[PACKET]; + uint16_t length = 0; + assert(native_test_in(CHILDREN,data,&length,true) && length == 18); + assert(native_test_out(CHILDREN,NULL,0,true)); + dump_through(1); + const char* header = snapshot_line(0,"[HUB_FLIGHT_FREEZE]"); + assert(field(header," reason=",10) == 2); + assert(field(header," quiet=",10) == 7u && "immediate supersession reported uptime instead of transfer age"); +} + +static bool routed_token(uint8_t address, uint8_t owner, uint8_t pid, uint32_t cycle) { + sio_hw->mtime = cycle; + const uint32_t cutoff = 0x70000000u+cycle; + const bool selected = native_hub_select_device(address,owner,cutoff); + if (selected) native_hub_note_selected_token(address,owner,cutoff,pid); + else native_hub_note_failed_select(address,owner,cutoff,pid); + return selected; +} + +static const char* expect_publication(unsigned snapshot, unsigned index, uint8_t slot, + uint32_t cycle, uint32_t ticket) { + const char* line = expect_observation(snapshot,index,addresses[slot],slot,TEST_PID_IN,cycle,true); + const char* clock = snapshot_line(snapshot,"[HUB_FLIGHT_CONTROL_CLOCK]"); + const char* control = snapshot_line(snapshot,"[HUB_FLIGHT_CONTROL]"); + assert(field(line," why=",16) == 0x20 && field(line," pub=",16) == ticket); + assert(field(clock," flags=",16) & 1u); + assert(field(clock," slot=",10) == slot && field(control," slot=",10) == slot); + assert(field(clock," gen=",10) == field(control," gen=",10)); + assert(field(clock," pub=",16) == ticket); + return line; +} + +static void complete_descriptor(uint8_t slot) { + uint8_t data[PACKET]; + uint16_t length = 0; + assert(native_test_in(slot,data,&length,true) && length == 18); + assert(memcmp(data,hub_device,length) == 0); + assert(native_test_out(slot,NULL,0,true)); +} + +static void delayed_publication(void) { + const uint8_t slot = CHILDREN; + const tusb_control_request_t request = vendor_request(); + uint8_t data[PACKET]; + uint16_t length = 0; + control_token(slot,TEST_PID_SETUP,100); + sio_hw->mtime = 110; + assert(native_test_setup(slot,&request,false)); + control_token(slot,TEST_PID_IN,120); + assert(!native_test_in(slot,data,&length,false)); + control_token(slot,TEST_PID_IN,130); + // Foreground reply preparation lags the first same-owner, unarmed IN. + // This is the old recorder's blind spot: SETUP's first-IN bit is gone. + native_test_time_us += 3000u; + sio_hw->mtime = 200; + native_test_drain(); + // An address-only attempt exercises the lock guard without changing owner; + // the identical address/owner fast path deliberately bypasses that lock. + const uint8_t rejected_address = addresses[slot]+1u; + trace_test_lock_busy = true; + assert(!routed_token(rejected_address,slot,TEST_PID_IN,210)); + trace_test_lock_busy = false; + control_token(slot,TEST_PID_OUT,220); + usb_hw->ep_rx_error = 2u; // Model a sticky EP0 sequence-error observation. + control_token(slot,TEST_PID_IN,230); + control_token(slot,TEST_PID_IN,240); + assert(usb_hw->ep_rx_error == 2u); + logger_full = true; + const marker_receipt_t receipt = capture_marker(slot,0); + assert(usb_hw->ep_rx_error == 2u); + // Snapshot bytes must survive later hardware changes and real completion. + usb_hw->ep_rx_error |= 8u; + assert(native_test_in(slot,data,&length,true) && length == PACKET); + assert(memcmp(data,vendor_reply,length) == 0); + assert(native_test_in(slot,data,&length,true) && length == sizeof(vendor_reply)-PACKET); + assert(memcmp(data,vendor_reply+PACKET,length) == 0); + assert(native_test_out(slot,NULL,0,true)); + logger_full = false; + dump_through(1); + expect_marker(0,&receipt); + assert(field(snapshot_line(0,"[HUB_FLIGHT_FREEZE]")," n=",10) == 5); + expect_observation(0,0,addresses[slot],slot,TEST_PID_SETUP,100,true); + const char* early = expect_observation(0,1,addresses[slot],slot,TEST_PID_IN,120,true); + assert(!(field(early," in0=",16) & USB_BUF_CTRL_AVAIL)); + assert(field(early," why=",16) == 0 && field(early," rxerr=",16) == 0); + const char* rejected = expect_observation(0,2,rejected_address,slot,TEST_PID_IN,210,false); + assert(field(rejected," why=",16) == 1 && field(rejected," pub=",16) == 0); + const char* out = expect_observation(0,3,addresses[slot],slot,TEST_PID_OUT,220,true); + assert(field(out," why=",16) == 0 && field(out," pub=",16) == 0); + const char* after = expect_publication(0,4,slot,230,1); + assert(field(after," in0=",16) & USB_BUF_CTRL_AVAIL); + assert(field(after," rxerr=",16) == 2u); + assert(field(snapshot_line(0,"[HUB_FLIGHT_CONTEXT]")," rxerr=",16) == 2u); + assert(usb_hw->ep_rx_error == 10u); // Recorder never clears a hardware error. + expect_clock(0,110,200,0,1,PACKET,0); + assert(strstr(snapshot_line(0,"[HUB_FLIGHT_CONTROL]")," pos=0/96 ")); + // The posthook knows device address/PID, not endpoint number. Even the + // ticket-matched record does not prove an EP0 poll or SIE/host acceptance. + expect_lost(1,0); +} + +static void publication_isolation(void) { + const tusb_control_request_t request = descriptor_request(); + const uint8_t slots[] = {1,CHILDREN}; + for (unsigned i = 0; i < sizeof(slots); ++i) { + control_token(slots[i],TEST_PID_SETUP,100u+100u*i); + assert(native_test_setup(slots[i],&request,false)); + control_token(slots[i],TEST_PID_IN,120u+100u*i); + } + sio_hw->mtime = 300; + native_test_drain(); // Both children publish their independent first ticket. + __atomic_store_n(&out_trace_frozen,1u,__ATOMIC_SEQ_CST); + control_token(1,TEST_PID_IN,400); + __atomic_store_n(&out_trace_frozen,0u,__ATOMIC_SEQ_CST); + for (unsigned i = 0; i < TEST_RETAIN; ++i) + control_token(1,TEST_PID_IN,410u+i); + control_token(CHILDREN,TEST_PID_IN,500); + control_token(CHILDREN,TEST_PID_IN,510); + logger_full = true; + const marker_receipt_t first = capture_marker(1,0); + const marker_receipt_t second = capture_marker(CHILDREN,0); + complete_descriptor(1); + complete_descriptor(CHILDREN); + logger_full = false; + dump_through(2); + expect_marker(0,&first); + expect_marker(1,&second); + for (unsigned snapshot = 0; snapshot < 2; ++snapshot) { + assert(field(snapshot_line(snapshot,"[HUB_FLIGHT_FREEZE]")," n=",10) == 5); + for (unsigned i = 0; i < sizeof(slots); ++i) { + expect_observation(snapshot,2u*i,addresses[slots[i]],slots[i],TEST_PID_SETUP,100u+100u*i,true); + expect_observation(snapshot,2u*i+1u,addresses[slots[i]],slots[i],TEST_PID_IN,120u+100u*i,true); + } + expect_observation(snapshot,4,addresses[CHILDREN],CHILDREN,TEST_PID_IN,500,true); + } + assert(field(snapshot_line(0,"[HUB_FLIGHT_CONTROL_CLOCK]")," pub=",16) == 1); + expect_publication(1,4,CHILDREN,500,1); + // Consuming child 1's ticket while frozen neither consumes child 2's equal + // numeric ticket nor replays child 1's discarded observation after thaw. + // A later real publication for child 1 must still become observable. + control_token(1,TEST_PID_SETUP,700); + sio_hw->mtime = 710; + assert(native_test_setup(1,&request,true)); + control_token(1,TEST_PID_IN,720); + control_token(1,TEST_PID_IN,730); + const marker_receipt_t next = capture_marker(1,0); + complete_descriptor(1); + dump_through(3); + expect_marker(2,&next); + assert(field(snapshot_line(2,"[HUB_FLIGHT_FREEZE]")," n=",10) == 7); + expect_observation(2,5,addresses[1],1,TEST_PID_SETUP,700,true); + expect_publication(2,6,1,720,2); + expect_lost(3,0); +} + +static void publication_wrap_supersession(void) { + const uint8_t slot = CHILDREN; + const tusb_control_request_t request = descriptor_request(); + // Seed only the counter boundary; all publications still come from real + // control requests, not fabricated watch fields or posthook forwarding. + __atomic_store_n(&out_trace_publication_sequence[slot-1u],UINT32_MAX-1u,__ATOMIC_RELEASE); + control_token(slot,TEST_PID_SETUP,100); + sio_hw->mtime = 110; + assert(native_test_setup(slot,&request,true)); // First ticket: ffffffff. + control_token(slot,TEST_PID_SETUP,200); + sio_hw->mtime = 210; + assert(native_test_setup(slot,&request,false)); + // SETUP does not consume the old notification. Its replacement IRQ has + // revoked readiness, but Core0 has not yet superseded the old watch. + control_token(slot,TEST_PID_IN,220); + logger_full = true; + sio_hw->mtime = 300; + native_test_drain(); // Captures the old watch, then publishes ticket zero. + control_token(slot,TEST_PID_IN,310); + control_token(slot,TEST_PID_IN,320); + const marker_receipt_t receipt = capture_marker(slot,0); + complete_descriptor(slot); + logger_full = false; + dump_through(2); + const char* old_control = snapshot_line(0,"[HUB_FLIGHT_CONTROL]"); + const char* old_clock = snapshot_line(0,"[HUB_FLIGHT_CONTROL_CLOCK]"); + assert(field(snapshot_line(0,"[HUB_FLIGHT_FREEZE]")," reason=",10) == 2); + assert(field(snapshot_line(0,"[HUB_FLIGHT_FREEZE]")," n=",10) == 3); + assert(field(old_clock," flags=",16) == 1 && field(old_clock," pub=",16) == UINT32_MAX); + assert(field(old_clock," gen=",10) == field(old_control," gen=",10)); + assert(field(snapshot_line(0,"[HUB_FLIGHT_STATUS_OUT]")," dgen=",10) > field(old_clock," gen=",10)); + const char* stale = expect_observation(0,2,addresses[slot],slot,TEST_PID_IN,220,true); + assert(field(stale," why=",16) == 0x20 && field(stale," pub=",16) == UINT32_MAX); + assert(!(field(stale," in0=",16) & USB_BUF_CTRL_AVAIL)); + expect_marker(1,&receipt); + assert(receipt.generation == field(old_clock," gen=",10)+1u); + assert(field(snapshot_line(1,"[HUB_FLIGHT_FREEZE]")," n=",10) == 4); + expect_publication(1,3,slot,310,0); // why bit validates zero; zero is not absent. + assert(field(record_line(1,2)," pub=",16) != field(snapshot_line(1,"[HUB_FLIGHT_CONTROL_CLOCK]")," pub=",16)); + expect_clock(1,210,300,0,1,18,0); + expect_lost(2,0); +} + +static void poll_retention(void) { + const uint8_t slot = CHILDREN; + const tusb_control_request_t request = vendor_request(); + uint8_t data[PACKET]; + uint16_t length = 0; + control_token(slot,TEST_PID_SETUP,100); + assert(native_test_setup(slot,&request,true)); + control_token(slot,TEST_PID_IN,200); + assert(native_test_in(slot,data,&length,true) && length == PACKET); + assert(memcmp(data,vendor_reply,length) == 0); + control_token(slot,TEST_PID_IN,201); + assert(native_test_in(slot,data,&length,true) && length == sizeof(vendor_reply)-PACKET); + assert(memcmp(data,vendor_reply+PACKET,length) == 0); + control_token(slot,TEST_PID_OUT,300); + assert(native_test_out(slot,NULL,0,true)); + for (unsigned i = 0; i < 3u*TEST_RETAIN; ++i) + assert(routed_token(addresses[slot],slot,i % 2u ? TEST_PID_IN : TEST_PID_OUT,400u+i)); + + assert(routed_token(addresses[0],0,TEST_PID_IN,1000)); + for (unsigned i = 0; i < 3u*TEST_RETAIN; ++i) { + assert(routed_token(addresses[0],0,i % 2u ? TEST_PID_IN : TEST_PID_OUT,1100u+i)); + root_read(); + } + const marker_receipt_t first = capture_marker(slot,0); + dump_through(1); + expect_marker(0,&first); + assert(field(snapshot_line(0,"[HUB_FLIGHT_FREEZE]")," n=",10) == 4); + expect_observation(0,0,addresses[slot],slot,TEST_PID_SETUP,100,true); + expect_observation(0,1,addresses[slot],slot,TEST_PID_IN,200,true); + expect_observation(0,2,addresses[slot],slot,TEST_PID_OUT,300,true); + const char* root = expect_observation(0,3,addresses[0],0,TEST_PID_IN,1000,true); + assert(field(root," commit=",16) == 1000); + + // Each child's SETUP rearms both directions independently. OUT observation + // before IN is also valid recorder evidence, not a physical-acceptance claim. + const tusb_control_request_t descriptor = descriptor_request(); + control_token(1,TEST_PID_SETUP,2000); + assert(native_test_setup(1,&descriptor,true)); + control_token(slot,TEST_PID_SETUP,2100); + assert(native_test_setup(slot,&descriptor,true)); + control_token(1,TEST_PID_OUT,2200); + control_token(1,TEST_PID_OUT,2250); + control_token(1,TEST_PID_IN,2300); + assert(native_test_in(1,data,&length,true) && length == 18); + control_token(slot,TEST_PID_IN,2400); + assert(native_test_in(slot,data,&length,true) && length == 18); + control_token(slot,TEST_PID_OUT,2500); + assert(native_test_out(slot,NULL,0,true)); + assert(native_test_out(1,NULL,0,true)); + const marker_receipt_t second = capture_marker(slot,0); + dump_through(2); + expect_marker(1,&second); + assert(field(snapshot_line(1,"[HUB_FLIGHT_FREEZE]")," n=",10) == 10); + expect_observation(1,4,addresses[1],1,TEST_PID_SETUP,2000,true); + expect_observation(1,5,addresses[slot],slot,TEST_PID_SETUP,2100,true); + expect_observation(1,6,addresses[1],1,TEST_PID_OUT,2200,true); + expect_observation(1,7,addresses[1],1,TEST_PID_IN,2300,true); + expect_observation(1,8,addresses[slot],slot,TEST_PID_IN,2400,true); + expect_observation(1,9,addresses[slot],slot,TEST_PID_OUT,2500,true); + expect_lost(2,0); +} + +static void selection_history(void) { + // Synthetic selector inputs separate address-only from owner-only changes. + // The selector does not claim to validate the router's address mapping. + assert(routed_token(5,0,TEST_PID_IN,100)); + for (unsigned i = 0; i < 3u*TEST_RETAIN; ++i) + assert(routed_token(5,0,i % 2u ? TEST_PID_IN : TEST_PID_OUT,1000u+i)); + assert(routed_token(5,CHILDREN,TEST_PID_OUT,200)); + for (unsigned i = 0; i < 3u*TEST_RETAIN; ++i) + assert(routed_token(5,CHILDREN,i % 2u ? TEST_PID_IN : TEST_PID_OUT,2000u+i)); + assert(routed_token(addresses[CHILDREN],CHILDREN,TEST_PID_IN,300)); + const uint8_t pids[] = {TEST_PID_SETUP,TEST_PID_IN,TEST_PID_OUT}; + for (unsigned i = 0; i < sizeof(pids); ++i) + assert(!routed_token(0,DEVICES,pids[i],400u+i)); + usb_hw->buf_status = 2; + for (unsigned i = 0; i < sizeof(pids); ++i) + assert(!routed_token(addresses[0],0,pids[i],500u+i)); + usb_hw->buf_status = 0; + root_read(); + const marker_receipt_t receipt = capture_marker(CHILDREN,0); + dump_through(1); + expect_marker(0,&receipt); + assert(field(snapshot_line(0,"[HUB_FLIGHT_FREEZE]")," n=",10) == 9); + const char* line = expect_observation(0,0,5,0,TEST_PID_IN,100,true); + assert(strstr(line," addr=00000000/00000005 owner=255/0 ")); + assert(field(line," commit=",16) == 100); + line = expect_observation(0,1,5,CHILDREN,TEST_PID_OUT,200,true); + char transition[80]; + snprintf(transition,sizeof(transition)," addr=00000000/00000005 owner=255/%u ",(unsigned)CHILDREN); + assert(strstr(line,transition)); + assert(field(line," commit=",16) == 200); + line = expect_observation(0,2,addresses[CHILDREN],CHILDREN,TEST_PID_IN,300,true); + snprintf(transition,sizeof(transition)," addr=00000000/%08x owner=255/%u ", + (unsigned)addresses[CHILDREN],(unsigned)CHILDREN); + assert(strstr(line,transition)); + assert(field(line," commit=",16) == 300); + for (unsigned i = 0; i < sizeof(pids); ++i) { + line = expect_observation(0,3u+i,0,DEVICES,pids[i],400u+i,false); + assert(field(line," why=",16) == 0x10); + line = expect_observation(0,6u+i,addresses[0],0,pids[i],500u+i,false); + assert(field(line," why=",16) == 0x0a); + assert(strstr(line," block=00000002/00000000 ")); + } + expect_lost(1,0); +} + +static void frozen_selection_history(void) { + assert(routed_token(addresses[1],1,TEST_PID_SETUP,100)); + // Deterministically interleave Core1 tokens with the recorder's Core0 + // snapshot freeze. Assert the eventual log, not its internal cursor state. + __atomic_store_n(&out_trace_frozen,1u,__ATOMIC_SEQ_CST); + assert(routed_token(addresses[CHILDREN],CHILDREN,TEST_PID_SETUP,200)); + assert(routed_token(addresses[CHILDREN],CHILDREN,TEST_PID_IN,210)); + assert(routed_token(addresses[CHILDREN],CHILDREN,TEST_PID_OUT,220)); + __atomic_store_n(&out_trace_frozen,0u,__ATOMIC_SEQ_CST); + for (unsigned i = 0; i < TEST_RETAIN; ++i) + assert(routed_token(addresses[CHILDREN],CHILDREN, + i % 2u ? TEST_PID_IN : TEST_PID_OUT,300u+i)); + + __atomic_store_n(&out_trace_frozen,1u,__ATOMIC_SEQ_CST); + assert(routed_token(addresses[0],0,TEST_PID_IN,500)); + __atomic_store_n(&out_trace_frozen,0u,__ATOMIC_SEQ_CST); + for (unsigned i = 0; i < TEST_RETAIN; ++i) + assert(routed_token(addresses[0],0,i % 2u ? TEST_PID_IN : TEST_PID_OUT,600u+i)); + + // A frozen SETUP still rearms first-IN/OUT retention, but a first token + // already consumed during the earlier freeze must not reappear afterward. + __atomic_store_n(&out_trace_frozen,1u,__ATOMIC_SEQ_CST); + assert(routed_token(addresses[1],1,TEST_PID_SETUP,800)); + __atomic_store_n(&out_trace_frozen,0u,__ATOMIC_SEQ_CST); + assert(routed_token(addresses[1],1,TEST_PID_IN,900)); + assert(routed_token(addresses[1],1,TEST_PID_OUT,1000)); + for (unsigned i = 0; i < TEST_RETAIN; ++i) + assert(routed_token(addresses[1],1,i % 2u ? TEST_PID_IN : TEST_PID_OUT,1100u+i)); + const marker_receipt_t receipt = capture_marker(1,0); + dump_through(1); + expect_marker(0,&receipt); + assert(field(snapshot_line(0,"[HUB_FLIGHT_FREEZE]")," n=",10) == 3); + expect_observation(0,0,addresses[1],1,TEST_PID_SETUP,100,true); + expect_observation(0,1,addresses[1],1,TEST_PID_IN,900,true); + expect_observation(0,2,addresses[1],1,TEST_PID_OUT,1000,true); + expect_lost(1,0); +} + +static void marker_priority(bool partial) { + const uint8_t slot = CHILDREN; + configure_child(slot); + selections(0x10000000u,TEST_RETAIN); + input_completion(slot); + logger_full = !partial; + native_test_advance(TEST_STALL_US); + if (partial) { + poll(6u+CHILDREN); + assert(count_tag("[HUB_FLIGHT]") > 0 && count_tag("[HUB_FLIGHT_END]") == 0); + } else assert(log_count == 0); + logger_full = true; + input_completion(slot); + selections(0x20000000u,TEST_RETAIN); + native_test_advance(TEST_STALL_US); + + // Both automatic slots are occupied. The marker must replace the waiting + // automatic snapshot, never the head (even before its first UART line). + selections(0x30000000u,TEST_RETAIN-3u); + control_token(slot,TEST_PID_SETUP,100); + const tusb_control_request_t request = vendor_request(); + sio_hw->mtime = 150; + assert(native_test_setup(slot,&request,true)); + control_token(slot,TEST_PID_IN,200); + uint8_t data[PACKET]; + uint16_t length = 0; + sio_hw->mtime = 250; + assert(native_test_in(slot,data,&length,true) && length == PACKET); + assert(memcmp(data,vendor_reply,length) == 0); + control_token(slot,TEST_PID_OUT,300); + native_test_time_us += 7u; + const uint32_t captured_time = native_test_time_us; + const uint32_t captured_generation = devices[slot].control.generation; + const marker_receipt_t receipt = capture_marker(slot,0); + assert(receipt.time_us == captured_time && receipt.generation == captured_generation); + root_read(); + capture_marker(1,1); // A pending host snapshot cannot be replaced by another host. + + // The root read must not disturb the child's remaining payload or status. + assert(native_test_in(slot,data,&length,true) && length == sizeof(vendor_reply)-PACKET); + assert(memcmp(data,vendor_reply+PACKET,length) == 0); + assert(native_test_out(slot,NULL,0,true)); + const tusb_control_request_t replacement = descriptor_request(); + assert(native_test_setup(slot,&replacement,true)); + assert(devices[slot].control.generation != receipt.generation); + assert(native_test_in(slot,data,&length,true) && length == 18); + assert(memcmp(data,hub_device,length) == 0); + assert(native_test_out(slot,NULL,0,true)); + for (unsigned i = 0; i < 3; ++i) { + input_completion(slot); + selections(0x40000000u+i*0x10000000u,TEST_RETAIN); + native_test_advance(TEST_STALL_US); + } + poll(40); // Unchanged automatic triggers must not keep increasing lost. + logger_full = false; + dump_through(2); + poll(100); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 2); + expect_records(0,0x10000000u,TEST_RETAIN); + expect_marker(1,&receipt); + const char* control = snapshot_line(1,"[HUB_FLIGHT_CONTROL]"); + assert(strstr(control," setup=c0/5a v=1122 i=3344 n=96 ")); + assert(strstr(control," pos=64/96 ")); + expect_clock(1,150,150,250,3,PACKET,PACKET); + const char* status = expect_status_out(1,0,0,0,0); + assert(field(status," dgen=",10) == receipt.generation); + assert((field(status," shadow_in=",16) & + (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_FULL | USB_BUF_CTRL_LEN_MASK)) == + (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_FULL | (sizeof(vendor_reply)-PACKET))); + assert(!(field(status," shadow_out=",16) & USB_BUF_CTRL_AVAIL)); + assert(field(snapshot_line(1,"[HUB_FLIGHT_FREEZE]")," n=",10) == TEST_RETAIN); + for (unsigned i = 0; i < TEST_RETAIN-3u; ++i) { + const char* line = record_line(1,i); + assert(field(line," cutoff=",16) == 0x30000000u+i); + assert(field(line," pid=",16) == TEST_PID_OUT && field(line," ok=",10) == 1); + } + expect_observation(1,TEST_RETAIN-3u,addresses[slot],slot,TEST_PID_SETUP,100,true); + expect_observation(1,TEST_RETAIN-2u,addresses[slot],slot,TEST_PID_IN,200,true); + expect_observation(1,TEST_RETAIN-1u,addresses[slot],slot,TEST_PID_OUT,300,true); + expect_lost(2,5); // One replacement, one busy marker, three automatic drops. +} + +static void marker_zero_and_capacity(void) { + configure_child(CHILDREN); + native_test_time_us = 0; + logger_full = true; + const marker_receipt_t receipt = capture_marker(1,0); + // Both receipt fields may legitimately be zero: this child has not yet + // received a SETUP. Only status distinguishes capture from a busy reply. + assert(receipt.time_us == 0 && receipt.generation == 0); + const marker_receipt_t second = capture_marker(CHILDREN,0); + assert(second.time_us == 0 && second.generation != 0); + capture_marker(1,1); // Only a full queue with a waiting host refuses a marker. + assert(log_count == 0); + logger_full = false; + native_test_advance(TEST_LINE_US); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 1 && count_tag("[HUB_FLIGHT_END]") == 0); + logger_full = true; + input_completion(CHILDREN); + selections(0x10000000u,TEST_RETAIN); + native_test_advance(TEST_STALL_US); + root_read(); + for (unsigned i = 0; i < 2; ++i) { + input_completion(CHILDREN); + selections(0x20000000u+i*0x10000000u,TEST_RETAIN); + native_test_advance(TEST_STALL_US); + } + poll(40); + logger_full = false; + dump_through(2); + poll(100); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 2); + expect_marker(0,&receipt); + assert(field(snapshot_line(0,"[HUB_FLIGHT_FREEZE]")," n=",10) == 0); + expect_marker(1,&second); + assert(field(snapshot_line(1,"[HUB_FLIGHT_FREEZE]")," n=",10) == 0); + expect_lost(2,4); // One busy marker and three automatic drops. +} + +static void marker_active_priority(void) { + configure_child(CHILDREN); + selections(0x10000000u,TEST_RETAIN); + const marker_receipt_t first = capture_marker(1,0); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 1 && count_tag("[HUB_FLIGHT_END]") == 0); + logger_full = true; + input_completion(CHILDREN); + selections(0x20000000u,TEST_RETAIN); + native_test_advance(TEST_STALL_US); + // A host head is protected, but does not prevent replacing the automatic + // snapshot waiting behind it with another independently protected host. + const marker_receipt_t second = capture_marker(CHILDREN,0); + capture_marker(1,1); + root_read(); + for (unsigned i = 0; i < 2; ++i) { + input_completion(CHILDREN); + selections(0x30000000u+i*0x10000000u,TEST_RETAIN); + native_test_advance(TEST_STALL_US); + } + poll(40); + logger_full = false; + dump_through(2); + poll(100); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 2); + expect_marker(0,&first); + expect_marker(1,&second); + for (unsigned snapshot = 0; snapshot < 2; ++snapshot) { + assert(field(snapshot_line(snapshot,"[HUB_FLIGHT_FREEZE]")," n=",10) == TEST_RETAIN); + for (unsigned i = 0; i < TEST_RETAIN; ++i) { + const char* line = record_line(snapshot,i); + assert(field(line," cutoff=",16) == (snapshot+1u)*0x10000000u+i); + assert(field(line," pid=",16) == TEST_PID_OUT && field(line," ok=",10) == 1); + } + } + expect_lost(2,4); // One automatic replacement, one busy marker, two drops. +} + +static void marker_rejected_requests(void) { + const tusb_control_request_t valid = marker_request(CHILDREN); + tusb_control_request_t invalid[11]; + for (unsigned i = 0; i < sizeof(invalid)/sizeof(invalid[0]); ++i) invalid[i] = valid; + invalid[0].bmRequestType = 0x40; // OUT direction. + invalid[1].bmRequestType = 0xc1; // Interface recipient. + invalid[2].bmRequestType = 0xa0; // Class request. + invalid[3].bRequest = 0x7e; + invalid[4].wValue ^= 1u; + invalid[5].wIndex = 0; + invalid[6].wIndex = CHILDREN+1u; + invalid[7].wIndex = 0x100u+CHILDREN; + invalid[8].wLength = 0; + invalid[9].wLength = 15; + invalid[10].wLength = 17; + for (unsigned i = 0; i < sizeof(invalid)/sizeof(invalid[0]); ++i) { + assert(!native_test_setup(0,&invalid[i],true)); + root_read(); + poll(10); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 0); + } + for (uint8_t slot = 1; slot <= CHILDREN; ++slot) { + assert(!native_test_setup(slot,&valid,true)); + root_read(); + poll(10); + assert(count_tag("[HUB_FLIGHT_FREEZE]") == 0); + } + const marker_receipt_t receipt = capture_marker(CHILDREN,0); + dump_through(1); + expect_marker(0,&receipt); + expect_lost(1,0); +} + +static void expect_handover_packet(uint8_t slot, uint16_t offset, uint16_t expected_length, + bool data1) { + uint8_t data[PACKET]; + uint16_t length = UINT16_MAX; + assert(native_test_select(slot)); + const uint32_t packet = buffer_regs()[0]; + assert(((packet & USB_BUF_CTRL_DATA1_PID) != 0) == data1); + // Selection must expose the packet before the token, with no Core0 task. + assert(native_test_in(slot,data,&length,false)); + assert(length == expected_length); + assert(memcmp(data,handover_reply[slot]+offset,length) == 0); +} + +static tusb_control_request_t approved_request(uint16_t length, uint16_t host_length, + uint16_t tag) { + const tusb_control_request_t request = { + .bmRequestType = 0xc0, .bRequest = 0x5d, .wValue = length, + .wIndex = tag, .wLength = host_length, + }; + return request; +} + +static void expect_approved_packet(uint8_t slot, uint16_t offset, uint16_t expected_length, + bool data1) { + uint8_t data[PACKET]; + uint16_t length = UINT16_MAX; + assert(native_test_select(slot)); + assert(((buffer_regs()[0] & USB_BUF_CTRL_DATA1_PID) != 0) == data1); + assert(native_test_in(slot,data,&length,false) && length == expected_length); + assert(memcmp(data,approved_reply[slot]+offset,length) == 0); +} + +static void complete_ready_status(uint8_t slot) { + assert(native_test_select(slot)); + assert((buffer_regs()[1] & (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_DATA1_PID | + USB_BUF_CTRL_STALL | USB_BUF_CTRL_LEN_MASK)) == + (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_DATA1_PID)); + assert(native_test_out(slot,NULL,0,false)); + assert(!native_test_out(slot,NULL,0,false)); +} + +static void expect_approved_callback(unsigned index, uint8_t slot, uint8_t stage, + const tusb_control_request_t* request) { + assert(index < approved_callback_count); + const approved_callback_t* callback = &approved_callbacks[index]; + assert(callback->slot == slot && callback->stage == stage); + assert(memcmp(&callback->request,request,sizeof(*request)) == 0); +} + +static void expect_no_control_packets(void) { + uint8_t data[PACKET]; + uint16_t length = 0; + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + assert(!native_test_in(slot,data,&length,false)); + assert(!native_test_out(slot,NULL,0,false)); + } +} + +static void approved_status_handoff(void) { + unsigned callbacks = 0; + for (unsigned pass = 0; pass < 3; ++pass) { + tusb_control_request_t requests[DEVICES]; + bool final_first[DEVICES]; + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + // Mixed short reply, terminating ZLP, and multi-packet tail; + // then exact one/two-packet replies with no padding ZLP. + const uint16_t length = pass == 0 ? (slot == 0 ? 1 : slot % 2u ? PACKET : 83) : + pass == 1 ? PACKET : 2u*PACKET; + requests[slot] = approved_request(length,pass == 0 ? 128 : length, + (uint16_t)(100u*pass+slot)); + final_first[slot] = length < PACKET || (length == PACKET && requests[slot].wLength == PACKET); + assert(native_test_setup(slot,&requests[slot],true)); + assert(!native_test_out(slot,NULL,0,false)); + } + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + const uint16_t length = requests[slot].wValue < PACKET ? requests[slot].wValue : PACKET; + expect_approved_packet(slot,0,length,true); + if (final_first[slot]) complete_ready_status(slot); + else assert(!native_test_out(slot,NULL,0,false)); + } + assert(approved_callback_count == callbacks); // No callbacks in IRQ. + native_test_drain(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + if (!final_first[slot]) continue; + expect_approved_callback(callbacks++,slot,CONTROL_STAGE_DATA,&requests[slot]); + expect_approved_callback(callbacks++,slot,CONTROL_STAGE_ACK,&requests[slot]); + } + assert(approved_callback_count == callbacks); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + assert(!native_test_out(slot,NULL,0,false)); + if (final_first[slot]) continue; + // A required zero-length IN must complete before OUT can be ready. + expect_approved_packet(slot,PACKET,requests[slot].wValue-PACKET,false); + complete_ready_status(slot); + } + assert(approved_callback_count == callbacks); + native_test_drain(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + if (final_first[slot]) continue; + expect_approved_callback(callbacks++,slot,CONTROL_STAGE_DATA,&requests[slot]); + expect_approved_callback(callbacks++,slot,CONTROL_STAGE_ACK,&requests[slot]); + } + assert(approved_callback_count == callbacks); + expect_no_control_packets(); // Draining final IN must not rearm consumed OUT. + native_test_drain(); + assert(approved_callback_count == callbacks); + } +} + +static void approved_status_superseded(void) { + tusb_control_request_t old[DEVICES], next[DEVICES]; + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + old[slot] = approved_request(1,1,100u+slot); + next[slot] = approved_request(7,7,200u+slot); + assert(native_test_setup(slot,&old[slot],true)); + } + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + expect_approved_packet(slot,0,1,true); + complete_ready_status(slot); + // SETUP revokes readiness but cannot erase the real, queued DATA/ACK. + assert(native_test_setup(slot,&next[slot],false)); + assert(!native_test_out(slot,NULL,0,false)); + } + assert(approved_callback_count == 0); + native_test_drain(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + expect_approved_callback(2u*slot,slot,CONTROL_STAGE_DATA,&old[slot]); + expect_approved_callback(2u*slot+1u,slot,CONTROL_STAGE_ACK,&old[slot]); + assert(!native_test_out(slot,NULL,0,false)); + expect_approved_packet(slot,0,7,true); + complete_ready_status(slot); + } + assert(approved_callback_count == 2u*DEVICES); + native_test_drain(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + expect_approved_callback(2u*(DEVICES+slot),slot,CONTROL_STAGE_DATA,&next[slot]); + expect_approved_callback(2u*(DEVICES+slot)+1u,slot,CONTROL_STAGE_ACK,&next[slot]); + } + assert(approved_callback_count == 4u*DEVICES); + expect_no_control_packets(); + + // A replacement received before IN completion must revoke the pending + // eligibility, rather than publish the old status for the new SETUP. + const tusb_control_request_t descriptor = descriptor_request(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) + assert(native_test_setup(slot,&old[slot],true)); + for (uint8_t slot = 0; slot < DEVICES; ++slot) + assert(native_test_setup(slot,&descriptor,false)); + expect_no_control_packets(); + native_test_drain(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + assert(!native_test_out(slot,NULL,0,false)); + complete_descriptor(slot); + } + assert(approved_callback_count == 4u*DEVICES); + expect_no_control_packets(); +} + +static void approved_status_reset(void) { + const tusb_control_request_t request = approved_request(1,1,100); + for (uint8_t slot = 0; slot < DEVICES; ++slot) + assert(native_test_setup(slot,&request,true)); + event_t final_in = {0}, status = {0}; + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + expect_approved_packet(slot,0,1,true); + if (slot == 0) final_in = events[event_tail]; + if (slot % 2u == 0) { + const unsigned status_index = event_head; + complete_ready_status(slot); + if (slot == 0) status = events[status_index]; + } + } + // Both already-completed statuses and still-ready inactive statuses lose + // ownership in reset IRQ, before the queued foreground completions run. + assert(approved_callback_count == 0); + native_test_bus_reset(false); + expect_no_control_packets(); + native_test_drain(); + expect_no_control_packets(); + assert(approved_callback_count == 0); + // Restore the fixture's synthetic routes after deferred reset processing, + // just as native_test_bus_reset(true) does after its internal drain. + for (uint8_t slot = 1; slot < DEVICES; ++slot) addresses[slot] = slot*17u; + const tusb_control_request_t descriptor = descriptor_request(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) + assert(native_test_setup(slot,&descriptor,true)); + transfer_complete(&final_in); + transfer_complete(&status); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + uint8_t data[PACKET]; + uint16_t length = 0; + assert(!native_test_out(slot,NULL,0,false)); + assert(native_test_in(slot,data,&length,false) && length == 18); + assert(memcmp(data,hub_device,length) == 0); + complete_ready_status(slot); + } + native_test_drain(); + expect_no_control_packets(); + assert(approved_callback_count == 0); +} + +static void approved_status_reset_during_completion(void) { + const tusb_control_request_t request = approved_request(1,1,100); + assert(native_test_setup(0,&request,true)); + expect_approved_packet(0,0,1,true); + assert(approved_callback_count == 0); + // Interrupt foreground after its first reset-generation check, while its + // diagnostic is emitted, but before it claims the queued final-IN event. + reset_on_root_complete = true; + native_test_drain(); + assert(!reset_on_root_complete); + assert(approved_callback_count == 0); + expect_no_control_packets(); + assert(native_test_setup(0,&request,true)); + assert(!native_test_out(0,NULL,0,false)); + expect_approved_packet(0,0,1,true); + complete_ready_status(0); + native_test_drain(); + expect_approved_callback(0,0,CONTROL_STAGE_DATA,&request); + expect_approved_callback(1,0,CONTROL_STAGE_ACK,&request); + assert(approved_callback_count == 2); + expect_no_control_packets(); +} + +static void approved_status_port_reset(bool queued_status) { + const uint8_t target = CHILDREN; + uint8_t data[PACKET]; + uint16_t length = 0; + const tusb_control_request_t preparation[] = { + {.bRequest = TUSB_REQ_SET_ADDRESS, .wValue = 9}, + {.bmRequestType = 0x23, .bRequest = TUSB_REQ_SET_FEATURE, + .wValue = 8, .wIndex = target}, + }; + for (unsigned i = 0; i < sizeof(preparation)/sizeof(preparation[0]); ++i) { + assert(native_test_setup(0,&preparation[i],true)); + assert(native_test_in(0,data,&length,true) && length == 0); + } + const tusb_control_request_t reset = { + .bmRequestType = 0x23, .bRequest = TUSB_REQ_SET_FEATURE, + .wValue = 4, .wIndex = target, + }; + const tusb_control_request_t request = approved_request(1,1,100); + assert(native_test_setup(0,&reset,true)); + for (uint8_t slot = 1; slot < DEVICES; ++slot) + assert(native_test_setup(slot,&request,true)); + // Queue the real root status-IN action first. Its reset_device invalidates + // the target's later queued completions, without touching sibling owners. + assert(native_test_in(0,data,&length,false) && length == 0); + assert(!native_test_out(0,NULL,0,false)); // Writes never acquire status OUT. + for (uint8_t slot = 1; slot < DEVICES; ++slot) { + expect_approved_packet(slot,0,1,true); + if (queued_status) complete_ready_status(slot); + } + assert(approved_callback_count == 0); + native_test_drain(); + native_test_advance(10000u); + assert(native_test_select(target)); // Reset has returned this child to address zero. + assert(!native_test_in(target,data,&length,false)); + assert(!native_test_out(target,NULL,0,false)); + unsigned callbacks = 0; + for (uint8_t slot = 1; slot < DEVICES; ++slot) { + if (slot == target) continue; + expect_approved_callback(callbacks++,slot,CONTROL_STAGE_DATA,&request); + if (queued_status) + expect_approved_callback(callbacks++,slot,CONTROL_STAGE_ACK,&request); + else complete_ready_status(slot); + } + assert(approved_callback_count == callbacks); + native_test_drain(); + if (!queued_status) { + for (uint8_t slot = 1; slot < DEVICES; ++slot) + if (slot != target) + expect_approved_callback(callbacks++,slot,CONTROL_STAGE_ACK,&request); + } + assert(approved_callback_count == 2u*(CHILDREN-1u) && approved_callback_count == callbacks); + expect_no_control_packets(); +} + +static void approved_status_invalid_length(void) { + const tusb_control_request_t request = approved_request(16,16,100); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + assert(native_test_setup(slot,&request,true)); + assert(native_test_select(slot)); + // Model an actual completed length that disagrees with the armed IN. + const uint16_t actual = slot % 2u ? 17 : 15; + buffer_regs()[0] = (buffer_regs()[0] & ~USB_BUF_CTRL_LEN_MASK) | actual; + uint8_t data[PACKET]; + uint16_t length = 0; + assert(native_test_in(slot,data,&length,false) && length == actual); + assert(!native_test_out(slot,NULL,0,false)); + } + native_test_drain(); + expect_no_control_packets(); + assert(approved_callback_count == 0); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + assert(native_test_select(slot)); + assert(buffer_regs()[0] & USB_BUF_CTRL_STALL); + assert(buffer_regs()[1] & USB_BUF_CTRL_STALL); + assert(native_test_setup(slot,&request,true)); + assert(!native_test_out(slot,NULL,0,false)); + expect_approved_packet(slot,0,16,true); + complete_ready_status(slot); + native_test_drain(); + expect_approved_callback(2u*slot,slot,CONTROL_STAGE_DATA,&request); + expect_approved_callback(2u*slot+1u,slot,CONTROL_STAGE_ACK,&request); + } + assert(approved_callback_count == 2u*DEVICES); + expect_no_control_packets(); +} + +static void approved_status_watch(void) { + const uint8_t slot = CHILDREN; + const tusb_control_request_t request = approved_request(1,1,100); + sio_hw->mtime = 100; + assert(native_test_setup(slot,&request,true)); + assert(!native_test_out(slot,NULL,0,false)); + sio_hw->mtime = 200; + expect_approved_packet(slot,0,1,true); + assert(approved_callback_count == 0); + assert(native_test_select(1)); + sio_hw->mtime = 300; + native_test_drain(); + expect_approved_callback(0,slot,CONTROL_STAGE_DATA,&request); + assert(approved_callback_count == 1); + logger_full = true; + sio_hw->mtime = 400; + const marker_receipt_t armed = capture_marker(slot,0); + sio_hw->mtime = 500; + complete_ready_status(slot); + assert(approved_callback_count == 1); + sio_hw->mtime = 600; + native_test_drain(); + expect_approved_callback(1,slot,CONTROL_STAGE_ACK,&request); + sio_hw->mtime = 700; + const marker_receipt_t completed = capture_marker(slot,0); + assert(completed.generation == armed.generation); + const tusb_control_request_t replacement = descriptor_request(); + sio_hw->mtime = 800; + assert(native_test_setup(slot,&replacement,true)); + sio_hw->mtime = 900; + complete_descriptor(slot); + logger_full = false; + dump_through(2); + expect_marker(0,&armed); + expect_marker(1,&completed); + expect_clock(0,100,100,200,3,1,1); + expect_clock(1,100,100,200,3,1,1); + const char* first = expect_status_out(0,200,0,1,0); + const char* second = expect_status_out(1,200,500,3,0); + assert(field(first," dgen=",10) == armed.generation); + assert(field(second," dgen=",10) == armed.generation); + assert((field(first," shadow_out=",16) & (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_DATA1_PID | + USB_BUF_CTRL_LEN_MASK)) == (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_DATA1_PID)); + assert(field(second," shadow_out=",16) == 0); + assert(field(snapshot_line(0,"[HUB_FLIGHT_CONTROL]")," stage=",10) == STATUS_OUT); + assert(field(snapshot_line(1,"[HUB_FLIGHT_CONTROL]")," stage=",10) == IDLE); + assert(approved_callback_count == 2); + expect_no_control_packets(); +} + +static void prepare_status_read(uint8_t slot) { + const tusb_control_request_t request = { + .bmRequestType = 0xc0, .bRequest = 0x5b, .wLength = 16, + }; + for (unsigned i = 0; i < sizeof(handover_reply[slot]); ++i) + handover_reply[slot][i] = (uint8_t)(slot * 0x31u + i * 3u); + sio_hw->mtime = 100; + assert(native_test_setup(slot,&request,true)); + sio_hw->mtime = 200; + expect_handover_packet(slot,0,16,true); + // This nonpreapproved vendor still requires the foreground DATA callback. + assert(!(buffer_regs()[1] & USB_BUF_CTRL_AVAIL)); + assert(!native_test_out(slot,NULL,0,false)); +} + +static void status_out_rejected_data(void) { + reject_status_in_callback = true; + for (uint8_t slot = 0; slot < DEVICES; ++slot) prepare_status_read(slot); + native_test_drain(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + assert(native_test_select(slot)); + assert(buffer_regs()[1] & USB_BUF_CTRL_STALL); + assert(handover_data_callbacks[slot] == 1 && handover_acks[slot] == 0); + } + expect_no_control_packets(); + native_test_drain(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) + assert(handover_data_callbacks[slot] == 1 && handover_acks[slot] == 0); +} + +static void status_out_lifecycle(void) { + const uint8_t slot = CHILDREN; + prepare_status_read(slot); + assert(native_test_select(1)); + sio_hw->mtime = 300; + native_test_drain(); // Publish to an inactive child's shadow, not root EP0. + logger_full = true; + sio_hw->mtime = 400; + const marker_receipt_t armed = capture_marker(slot,0); + assert(native_test_select(slot)); + assert((buffer_regs()[1] & (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_DATA1_PID | + USB_BUF_CTRL_STALL | USB_BUF_CTRL_LEN_MASK)) == + (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_DATA1_PID)); + sio_hw->mtime = 500; + assert(native_test_out(slot,NULL,0,false)); + assert(handover_acks[slot] == 0); + assert(native_test_select(1)); + sio_hw->mtime = 600; + native_test_drain(); + assert(handover_acks[slot] == 1); + sio_hw->mtime = 700; + const marker_receipt_t completed = capture_marker(slot,0); + assert(completed.generation == armed.generation); + + // Replace the live control/watch and finish it before either host snapshot + // drains. Both headers must retain their own publication/completion image. + const tusb_control_request_t replacement = descriptor_request(); + uint8_t data[PACKET]; + uint16_t length = 0; + sio_hw->mtime = 800; + assert(native_test_setup(slot,&replacement,true)); + sio_hw->mtime = 900; + assert(native_test_in(slot,data,&length,true) && length == 18); + sio_hw->mtime = 1000; + assert(native_test_out(slot,NULL,0,true)); + logger_full = false; + dump_through(2); + expect_marker(0,&armed); + expect_marker(1,&completed); + expect_clock(0,100,100,200,3,16,16); + const char* first = expect_status_out(0,300,0,1,0); + const char* second = expect_status_out(1,300,500,3,0); + assert(field(first," dgen=",10) == armed.generation); + assert(field(second," dgen=",10) == armed.generation); + assert(field(first," shadow_in=",16) == 0 && field(second," shadow_in=",16) == 0); + assert((field(first," shadow_out=",16) & + (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_DATA1_PID | USB_BUF_CTRL_LEN_MASK)) == + (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_DATA1_PID)); + assert(field(second," shadow_out=",16) == 0); + assert(field(snapshot_line(0,"[HUB_FLIGHT_CONTROL]")," stage=",10) == STATUS_OUT); + assert(field(snapshot_line(1,"[HUB_FLIGHT_CONTROL]")," stage=",10) == IDLE); +} + +static void status_out_superseded_arm(void) { + const uint8_t slot = CHILDREN; + prepare_status_read(slot); + supersede_status_in_callback = true; + logger_full = true; + sio_hw->mtime = 300; + native_test_drain(); + // The DATA callback injected a replacement SETUP before the old status + // publication acquired its generation guard. STATUS_OUT alone is no arm. + sio_hw->mtime = 400; + const marker_receipt_t replacement = capture_marker(slot,0); + uint8_t data[PACKET]; + uint16_t length = 0; + assert(native_test_in(slot,data,&length,true) && length == 18); + assert(memcmp(data,hub_device,length) == 0); + assert(native_test_out(slot,NULL,0,true)); + assert(handover_acks[slot] == 0); + logger_full = false; + dump_through(2); + const char* old = expect_status_out(0,0,0,0,0); + assert(field(snapshot_line(0,"[HUB_FLIGHT_FREEZE]")," reason=",10) == 2); + assert(field(snapshot_line(0,"[HUB_FLIGHT_CONTROL]")," stage=",10) == STATUS_OUT); + assert(field(old," gen=",10)+1u == replacement.generation); + assert(field(old," dgen=",10) == replacement.generation); + assert(!(field(old," shadow_out=",16) & USB_BUF_CTRL_AVAIL)); + expect_marker(1,&replacement); + const char* next = expect_status_out(1,0,0,0,0); + assert(field(next," dgen=",10) == replacement.generation); + assert((field(next," shadow_in=",16) & (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_LEN_MASK)) == + (USB_BUF_CTRL_AVAIL | 18u)); +} + +static void status_out_stale_completion(bool reset) { + const uint8_t slot = CHILDREN; + prepare_status_read(slot); + sio_hw->mtime = 300; + native_test_drain(); + sio_hw->mtime = 400; + assert(native_test_out(slot,NULL,0,false)); + assert(event_tail != event_head); + const event_t completion = events[event_tail]; + assert(completion.device == slot && completion.channel == 1 && completion.length == 0); + sio_hw->mtime = 500; + if (reset) native_test_bus_reset(true); + else native_test_drain(); + // A queued status ACK is revoked by reset, not merely by delayed Core0. + assert(handover_acks[slot] == (reset ? 0u : 1u)); + const tusb_control_request_t replacement = descriptor_request(); + sio_hw->mtime = 600; + assert(native_test_setup(slot,&replacement,true)); + // Replay a captured hardware event, not a fabricated watch update: a late + // completion of the prior control/reset generation cannot mark this read. + sio_hw->mtime = 700; + transfer_complete(&completion); + assert(handover_acks[slot] == (reset ? 0u : 1u)); + assert(!native_test_out(slot,NULL,0,false)); + logger_full = true; + const marker_receipt_t receipt = capture_marker(slot,0); + uint8_t data[PACKET]; + uint16_t length = 0; + assert(native_test_in(slot,data,&length,true) && length == 18); + assert(memcmp(data,hub_device,length) == 0); + assert(native_test_out(slot,NULL,0,true)); + logger_full = false; + dump_through(1); + expect_marker(0,&receipt); + const char* status = expect_status_out(0,0,0,0,0); + assert(field(status," dgen=",10) == receipt.generation); + assert((field(status," shadow_in=",16) & (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_LEN_MASK)) == + (USB_BUF_CTRL_AVAIL | 18u)); +} + +static void status_out_reset_watch(bool port_reset) { + const uint8_t slot = CHILDREN; + uint8_t data[PACKET]; + uint16_t length = 0; + if (port_reset) { + // Release address zero and power the port through ordinary root + // requests so the subsequent child reset follows its real guards. + const tusb_control_request_t requests[] = { + {.bRequest = TUSB_REQ_SET_ADDRESS, .wValue = 9}, + {.bmRequestType = 0x23, .bRequest = TUSB_REQ_SET_FEATURE, + .wValue = 8, .wIndex = slot}, + }; + for (unsigned i = 0; i < sizeof(requests)/sizeof(requests[0]); ++i) { + assert(native_test_setup(0,&requests[i],true)); + assert(native_test_in(0,data,&length,true) && length == 0); + } + } + prepare_status_read(slot); + sio_hw->mtime = 300; + native_test_drain(); + sio_hw->mtime = 400; + assert(native_test_out(slot,NULL,0,true)); + assert(handover_acks[slot] == 1); + logger_full = true; + sio_hw->mtime = 500; + const marker_receipt_t completed = capture_marker(slot,0); + assert(completed.generation != 0); + sio_hw->mtime = 600; + if (port_reset) { + const tusb_control_request_t request = { + .bmRequestType = 0x23, .bRequest = TUSB_REQ_SET_FEATURE, + .wValue = 4, .wIndex = slot, + }; + assert(native_test_setup(0,&request,true)); + assert(native_test_in(0,data,&length,true) && length == 0); + native_test_advance(10000u); + } else native_test_bus_reset(true); + // A released notification can outlive its control. Observe it after reset, + // before any replacement SETUP: its ticket must not make the empty watch valid. + control_token(slot,TEST_PID_IN,650); + control_token(slot,TEST_PID_IN,660); + assert(!native_test_in(slot,data,&length,false)); + sio_hw->mtime = 700; + // No child SETUP may clear the old watch on behalf of reset. A root marker + // must already describe an empty control, while the earlier latch stays intact. + const marker_receipt_t reset = capture_marker(slot,0); + assert(reset.generation == 0); + logger_full = false; + dump_through(2); + expect_marker(0,&completed); + expect_marker(1,&reset); + expect_clock(0,100,100,200,3,16,16); + const char* before = expect_status_out(0,300,400,3,0); + const char* after = expect_status_out(1,0,0,0,0); + assert(field(before," dgen=",10) == completed.generation); + assert(field(after," dgen=",10) > field(before," dgen=",10)); + assert(field(after," shadow_in=",16) == 0 && field(after," shadow_out=",16) == 0); + assert(field(snapshot_line(1,"[HUB_FLIGHT_CONTROL]")," stage=",10) == IDLE); + const char* clock = snapshot_line(1,"[HUB_FLIGHT_CONTROL_CLOCK]"); + const char* cleared[] = {" setup="," arm="," complete="," flags="," pid="," arm_len="," len="," pub="}; + for (unsigned i = 0; i < sizeof(cleared)/sizeof(cleared[0]); ++i) + assert(field(clock,cleared[i],16) == 0); + assert(handover_acks[slot] == 1); + const uint32_t prior_ticket = field(snapshot_line(0,"[HUB_FLIGHT_CONTROL_CLOCK]")," pub=",16); + assert(prior_ticket != 0); + assert(field(snapshot_line(1,"[HUB_FLIGHT_FREEZE]")," n=",10) == 1); + const char* stale = expect_observation(1,0,addresses[slot],slot,TEST_PID_IN,650,true); + assert(field(stale," why=",16) == 0x20 && field(stale," pub=",16) == prior_ticket); + assert(!(field(stale," in0=",16) & USB_BUF_CTRL_AVAIL)); + const tusb_control_request_t replacement = descriptor_request(); + sio_hw->mtime = 800; + assert(native_test_setup(slot,&replacement,true)); + control_token(slot,TEST_PID_IN,820); + control_token(slot,TEST_PID_IN,830); + const marker_receipt_t next = capture_marker(slot,0); + complete_descriptor(slot); + dump_through(3); + expect_marker(2,&next); + assert(field(snapshot_line(2,"[HUB_FLIGHT_FREEZE]")," n=",10) == 2); + expect_publication(2,1,slot,820,prior_ticket+1u); + expect_lost(3,0); +} + +static uint32_t observe_commit(uint8_t address, uint8_t owner, bool handover, uint32_t cutoff) { + assert(usb_hw->dev_addr_ctrl != address); + assert(handover == (active_device != owner)); + commit_bank = hardware_bank(); + commit_address = address; + commit_owner = owner; + commit_handover = handover; + if (handover) { + for (unsigned channel = 0; channel < CHANNELS; ++channel) + commit_buffers[channel] = owner == 0 && channel >= 2 ? 0 : + devices[owner].buffers[channel] & ~USB_BUF_CTRL_AVAIL; + for (unsigned i = 0; i < 4; ++i) + commit_controls[i] = devices[owner].endpoint_controls[i]; + commit_root_control = owner == 0 ? hub_endpoint_control : 0; + commit_stall = ((commit_buffers[0] & USB_BUF_CTRL_STALL) ? 1u : 0u) | + ((commit_buffers[1] & USB_BUF_CTRL_STALL) ? 2u : 0u); + } + commit_cycle = 0; + commit_probe = clock_steps = true; + assert(native_hub_select_device(address,owner,cutoff)); + native_hub_note_selected_token(address,owner,cutoff,TEST_PID_OUT); + clock_steps = false; + assert(!commit_probe && commit_cycle != 0); + assert(usb_hw->dev_addr_ctrl == address && active_device == owner); + return commit_cycle; +} + +static void reject_unchanged(uint8_t address, uint8_t owner, uint32_t cutoff) { + const hardware_bank_t bank = hardware_bank(); + const uint32_t previous_address = usb_hw->dev_addr_ctrl; + const uint8_t previous_owner = active_device; + assert(!native_hub_select_device(address,owner,cutoff)); + native_hub_note_failed_select(address,owner,cutoff,TEST_PID_OUT); + expect_bank_unchanged(&bank); + assert(usb_hw->dev_addr_ctrl == previous_address && active_device == previous_owner); +} + +static void coherent_publication(void) { + const tusb_control_request_t configuration = { + .bRequest = TUSB_REQ_SET_CONFIGURATION, .wValue = 1, + }; + const tusb_control_request_t request = { + .bmRequestType = 0xc0, .bRequest = 0x5b, .wLength = 128, + }; + uint8_t data[PACKET]; + uint16_t length = 0; + for (uint8_t slot = 1; slot < DEVICES; ++slot) configure_child(slot); + assert(native_test_setup(0,&configuration,true)); + assert(native_test_in(0,data,&length,true) && length == 0); + ports[0].change = C_RESET; + native_test_drain(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + for (unsigned i = 0; i < sizeof(handover_reply[slot]); ++i) + handover_reply[slot][i] = (uint8_t)(slot * 0x31u + i * 3u); + assert(native_test_setup(slot,&request,true)); + if (slot) { + const uint8_t payload[] = {slot,0xa5,0x5a}; + assert(native_hub_hid_report(slot-1u,0x30,payload,sizeof(payload))); + assert(native_hub_vendor_write(slot-1u,payload,sizeof(payload)) == sizeof(payload)); + assert(native_hub_vendor_write_flush(slot-1u) == sizeof(payload)); + } + } + assert(native_test_select(0)); + assert(buffer_regs()[0] & USB_BUF_CTRL_AVAIL); + assert(buffer_regs()[30] & USB_BUF_CTRL_AVAIL); + assert(usb_dpram->ep_ctrl[14].in & EP_CTRL_ENABLE_BITS); + native_test_sio.mtime = 1000; + const uint32_t first_commit = observe_commit(addresses[1],1,true,0x70000000u); + assert(buffer_regs()[0] & USB_BUF_CTRL_AVAIL); + for (unsigned channel = 2; channel < CHANNELS; ++channel) + assert(buffer_regs()[channel] & USB_BUF_CTRL_AVAIL); + + // A posthook on the same owner refers to the prior selector write, not to + // its later observation clock. Polls must not manufacture fresh commits. + native_test_sio.mtime += 10u; + native_hub_note_selected_token(addresses[1],1,0x70000001u,TEST_PID_SETUP); + native_test_sio.mtime += 10u; + assert(native_test_select(1)); + native_hub_note_selected_token(addresses[1],1,0x70000002u,TEST_PID_IN); + + const uint8_t incoming = CHILDREN; + trace_test_lock_busy = true; + reject_unchanged(addresses[incoming],incoming,0x70000003u); + trace_test_lock_busy = false; + usb_hw->sie_status = USB_SIE_STATUS_SETUP_REC_BITS; + reject_unchanged(addresses[incoming],incoming,0x70000004u); + usb_hw->sie_status = 0; + const uint32_t pending[] = {1u,2u,4u,8u,16u,32u,1u << 30}; + for (unsigned i = 0; i < sizeof(pending)/sizeof(pending[0]); ++i) { + usb_hw->buf_status = pending[i]; + reject_unchanged(addresses[incoming],incoming,0x70000010u+i); + } + usb_hw->buf_status = 0; + reject_unchanged(addresses[incoming],incoming,native_test_sio.mtime); + reject_unchanged(addresses[incoming],DEVICES,0x70000020u); + spin_lock_t* lock = bank_lock; + bank_lock = NULL; + reject_unchanged(addresses[incoming],incoming,0x70000021u); + bank_lock = lock; + + // The unchanged-owner/address fast path remains a no-op even when a bank + // handover would be rejected. An address-only write must not quiesce data. + const hardware_bank_t ready = hardware_bank(); + trace_test_lock_busy = true; + usb_hw->sie_status = USB_SIE_STATUS_SETUP_REC_BITS; + usb_hw->buf_status = 1u << 30; + assert(native_hub_select_device(addresses[1],1,0)); + expect_bank_unchanged(&ready); + trace_test_lock_busy = false; + usb_hw->sie_status = usb_hw->buf_status = 0; + observe_commit(5,1,false,0x70000022u); + assert(native_test_select(1)); + expect_bank_unchanged(&ready); + observe_commit(addresses[incoming],incoming,true,0x70000023u); + observe_commit(addresses[0],0,true,0x70000024u); + + // All replies/private banks were prepared before any of these tokens. + // No Core0 task may repair a handover between selection and consumption. + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + expect_handover_packet(slot,0,slot ? PACKET : 16,true); + if (!slot) continue; + const uint8_t payload[] = {slot,0xa5,0x5a}; + assert(!(buffer_regs()[2] & USB_BUF_CTRL_DATA1_PID)); + assert(native_test_private_in(slot,0x81,data,&length)); + assert(length == sizeof(payload)+1u && data[0] == 0x30); + assert(memcmp(data+1,payload,sizeof(payload)) == 0); + assert(!(buffer_regs()[4] & USB_BUF_CTRL_DATA1_PID)); + assert(native_test_private_in(slot,0x82,data,&length)); + assert(length == sizeof(payload) && memcmp(data,payload,length) == 0); + assert(!(buffer_regs()[3] & USB_BUF_CTRL_DATA1_PID)); + assert(native_test_private_out(slot,0x01,payload,sizeof(payload),false)); + assert(!(buffer_regs()[5] & USB_BUF_CTRL_DATA1_PID)); + assert(native_test_private_out(slot,0x02,payload,sizeof(payload),false)); + } + assert(native_test_private_in(0,0x8f,data,&length)); + assert(length == 1 && data[0] == 2); + native_test_drain(); + for (unsigned instance = 0; instance < CHILDREN; ++instance) { + assert(native_test_hid_completions[instance] == 1); + assert(native_test_bulk_completions[instance] == 1); + assert(native_test_received_count[instance][0] == 1); + assert(native_test_received_count[instance][1] == 1); + } + + // Outgoing root visibility must be gone when a child address commits; + // selecting the root again must already expose its incoming STALL state. + tusb_control_request_t rejected = request; + rejected.bRequest = 0x5c; + assert(!native_test_setup(0,&rejected,true)); + assert(usb_hw->ep_stall_arm == 3u); + assert(buffer_regs()[0] & USB_BUF_CTRL_STALL); + assert(usb_dpram->ep_ctrl[14].in & EP_CTRL_ENABLE_BITS); + observe_commit(addresses[1],1,true,0x70000025u); + expect_handover_packet(1,PACKET,0,false); + observe_commit(addresses[0],0,true,0x70000026u); + assert(usb_hw->ep_stall_arm == 3u); + assert(buffer_regs()[0] & USB_BUF_CTRL_STALL); + assert(buffer_regs()[1] & USB_BUF_CTRL_STALL); + assert(!native_test_in(0,data,&length,false)); + const marker_receipt_t receipt = capture_marker(CHILDREN,0); + dump_through(1); + expect_marker(0,&receipt); + const char* first = record_line(0,0); + uint32_t before, after; + const char* clocks = strstr(first," clock="); + assert(clocks && sscanf(clocks," clock=%"SCNx32"/%"SCNx32,&before,&after) == 2); + assert(field(first," pre=",10) == 0 && before == 0 && first_commit < after); + assert(field(first," commit=",16) == first_commit); + for (unsigned index = 1; index <= 2; ++index) { + const char* line = record_line(0,index); + assert(field(line," pre=",10) == 0); + assert(field(line," commit=",16) == first_commit); + } + unsigned records = field(snapshot_line(0,"[HUB_FLIGHT_FREEZE]")," n=",10); + for (unsigned index = 0; index < records; ++index) { + const char* line = record_line(0,index); + if (!field(line," ok=",10)) assert(field(line," commit=",16) == 0); + } +} + +static uint16_t latched_bulk_packet(uint8_t slot, bool in, bool host_data1, + uint8_t* data, uint16_t length) { + const unsigned channel = in ? 4u : 5u; + const uint32_t control = in ? commit_latched_bank.dpram.ep_ctrl[1].in : + commit_latched_bank.dpram.ep_ctrl[1].out; + const uint32_t packet = in ? commit_latched_bank.dpram.ep_buf_ctrl[2].in : + commit_latched_bank.dpram.ep_buf_ctrl[2].out; + assert(active_device == slot && commit_owner == slot); + assert(usb_hw->dev_addr_ctrl == addresses[slot]); + assert(control & EP_CTRL_ENABLE_BITS); + assert((control & 0xffffu) == data_offset(slot,channel)); + assert(!(packet & (USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_STALL))); + assert(((packet & USB_BUF_CTRL_DATA1_PID) != 0) == host_data1 && + "commit-time DATA PID disagrees with independent host sequence"); + assert(buffer_regs()[channel] == (packet | USB_BUF_CTRL_AVAIL)); + uint8_t* payload = (uint8_t*)USBCTRL_DPRAM_BASE + (control & 0xffffu); + if (in) { + assert(packet & USB_BUF_CTRL_FULL); + assert((packet & USB_BUF_CTRL_LEN_MASK) <= length); + length = packet & USB_BUF_CTRL_LEN_MASK; + copy_from_usb(data,payload,length); + buffer_regs()[channel] = packet; + } else { + assert(!(packet & USB_BUF_CTRL_FULL)); + assert(length <= (packet & USB_BUF_CTRL_LEN_MASK)); + copy_to_usb(payload,data,length); + buffer_regs()[channel] = (packet & ~USB_BUF_CTRL_LEN_MASK) | length; + } + // Deliver only a correctly matched transaction. There is deliberately no + // model of hardware ACK/discard behavior for an incorrect DATA PID. + usb_hw->buf_status |= 1u << channel; + usb_hw->ints |= USB_INTS_BUFF_STATUS_BITS; + native_test_service_interrupt(); + assert(!failed); + return length; +} + +static void bulk_commit_pids(void) { + uint8_t commands[CHILDREN][PACKET], replies[CHILDREN][PACKET], received[PACKET]; + uint16_t command_lengths[CHILDREN], reply_lengths[CHILDREN]; + for (uint8_t slot = 1; slot <= CHILDREN; ++slot) configure_child(slot); + native_test_sio.mtime = 2000; + // Slots name virtual USB child ports, not controller profile identities. + // The host starts each EP2 direction at DATA0 after configuration, then + // expects DATA1 after one completion. Never infer this from ep.next_pid. + for (unsigned round = 0; round < 2; ++round) { + const bool host_data1 = round != 0; + for (uint8_t slot = 1; slot <= CHILDREN; ++slot) { + const unsigned instance = slot-1u; + command_lengths[instance] = round ? 16u+slot : 16u; + reply_lengths[instance] = 19u+slot+round*7u; + for (unsigned i = 0; i < PACKET; ++i) { + commands[instance][i] = (uint8_t)(slot*0x21u+round*0x43u+i); + replies[instance][i] = (uint8_t)(slot*0x31u+round*0x57u+i*3u); + } + assert(native_hub_vendor_write(instance,replies[instance],reply_lengths[instance]) == + reply_lengths[instance]); + assert(native_hub_vendor_write_flush(instance) == reply_lengths[instance]); + } + assert(native_test_select(0)); + for (uint8_t slot = 1; slot <= CHILDREN; ++slot) { + const unsigned instance = slot-1u; + observe_commit(addresses[slot],slot,true,0x70001000u+round*CHILDREN+slot); + assert(latched_bulk_packet(slot,false,host_data1,commands[instance], + command_lengths[instance]) == command_lengths[instance]); + const uint16_t length = latched_bulk_packet(slot,true,host_data1,received,sizeof(received)); + assert(length == reply_lengths[instance]); + assert(memcmp(received,replies[instance],length) == 0); + // IRQs consumed the bank, but callbacks/rearming await Core0. No + // foreground pass may repair a bank before its modeled packets. + assert(native_test_received_count[instance][1] == round); + assert(native_test_bulk_completions[instance] == round); + uint16_t unarmed_length = UINT16_MAX; + assert(!native_test_private_in(slot,0x82,received,&unarmed_length)); + assert(!native_test_private_out(slot,0x02,commands[instance],command_lengths[instance],false)); + } + native_test_drain(); + for (unsigned instance = 0; instance < CHILDREN; ++instance) { + assert(native_test_received_count[instance][1] == round+1u); + assert(native_test_received_length[instance][1] == command_lengths[instance]); + assert(memcmp(native_test_received_data[instance][1],commands[instance], + command_lengths[instance]) == 0); + assert(native_test_bulk_completions[instance] == round+1u); + assert(native_test_received_count[instance][0] == 0); + assert(native_test_hid_completions[instance] == 0); + } + // Draining and selecting every bank again must not replay a completion. + for (uint8_t slot = 1; slot <= CHILDREN; ++slot) { + uint16_t length = UINT16_MAX; + assert(!native_test_private_in(slot,0x82,received,&length)); + } + native_test_drain(); + for (unsigned instance = 0; instance < CHILDREN; ++instance) { + assert(native_test_received_count[instance][1] == round+1u); + assert(native_test_bulk_completions[instance] == round+1u); + } + } +} + +static void ep0_handover(void) { + const tusb_control_request_t request = { + .bmRequestType = 0xc0, .bRequest = 0x5b, .wLength = 128, + }; + uint8_t data[PACKET]; + uint16_t length = 0; + const tusb_control_request_t configuration = { + .bRequest = TUSB_REQ_SET_CONFIGURATION, .wValue = 1, + }; + for (uint8_t slot = 1; slot < DEVICES; ++slot) + assert(native_test_setup(slot,&configuration,true)); + for (uint8_t slot = 1; slot < DEVICES; ++slot) + expect_handover_packet(slot,0,0,true); + native_test_drain(); + const uint8_t input[] = {0xa5,0x6b,0xd2}; + assert(native_hub_hid_report(0,0x30,input,sizeof(input))); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + for (unsigned i = 0; i < sizeof(handover_reply[slot]); ++i) + handover_reply[slot][i] = (uint8_t)(slot * 0x31u + i * 3u); + assert(native_test_setup(slot,&request,true)); + } + // A private completion stays queued while root/child and child/child INs + // consume their already-prepared, distinct EP0 packets without foreground work. + assert(native_test_private_in(1,0x81,data,&length)); + assert(length == sizeof(input)+1u && data[0] == 0x30); + assert(memcmp(data+1,input,sizeof(input)) == 0); + for (uint8_t slot = 0; slot < DEVICES; ++slot) + expect_handover_packet(slot,0,slot == 0 ? 16 : PACKET,true); + for (uint8_t slot = 0; slot < DEVICES; ++slot) + assert(!native_test_in(slot,data,&length,false)); + native_test_drain(); + assert(native_test_hid_completions[0] == 1 && native_hub_hid_ready(0)); + + // Full short replies need a DATA0 padding ZLP; longer replies need their + // exact DATA0 tail. Both are prepared while other owners hold the bank. + for (uint8_t slot = 1; slot < DEVICES; ++slot) + expect_handover_packet(slot,PACKET,handover_length(slot)-PACKET,false); + for (uint8_t slot = 0; slot < DEVICES; ++slot) + assert(!native_test_in(slot,data,&length,false)); + native_test_drain(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + assert(handover_acks[slot] == 0); + assert(native_test_select(slot)); + assert(buffer_regs()[1] & USB_BUF_CTRL_DATA1_PID); + assert(native_test_out(slot,NULL,0,false)); + } + native_test_drain(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + assert(!native_test_in(slot,data,&length,false)); + assert(!native_test_out(slot,NULL,0,false)); + assert(handover_acks[slot] == 1); + } + native_test_drain(); + assert(native_test_hid_completions[0] == 1); + assert(!native_test_private_in(1,0x81,data,&length)); + + // A rejected replacement SETUP must restore STALL, never the old ready + // root reply. Reset then revokes both queued and still-prepared child data. + for (uint8_t slot = 0; slot < DEVICES; ++slot) + assert(native_test_setup(slot,&request,true)); + tusb_control_request_t rejected = request; + rejected.bRequest = 0x5c; + assert(!native_test_setup(0,&rejected,true)); + expect_handover_packet(1,0,PACKET,true); + assert(!native_test_in(0,data,&length,false)); + assert(buffer_regs()[0] & USB_BUF_CTRL_STALL); + assert(native_hub_hid_report(0,0x30,input,sizeof(input))); + native_test_bus_reset(false); + // The reset IRQ must revoke inactive readiness before Core0 consumes reset. + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + assert(!native_test_in(slot,data,&length,false)); + assert(!native_test_out(slot,NULL,0,false)); + if (slot) { + assert(!native_test_private_in(slot,0x81,data,&length)); + assert(!native_test_private_out(slot,0x01,input,sizeof(input),false)); + } + } + native_test_drain(); + for (uint8_t slot = 0; slot < DEVICES; ++slot) { + assert(!native_test_in(slot,data,&length,false)); + assert(!native_test_out(slot,NULL,0,false)); + assert(handover_acks[slot] == 1); + if (slot) assert(native_test_hid_completions[slot-1] == (slot == 1 ? 1u : 0u)); + } +} + +int main(int argc, char** argv) { + assert(argc >= 2); + native_test_initialize(); + for (unsigned i = 0; i < sizeof(vendor_reply); ++i) vendor_reply[i] = (uint8_t)(i ^ 0x5a); + for (uint8_t slot = 0; slot < DEVICES; ++slot) + for (unsigned i = 0; i < sizeof(approved_reply[slot]); ++i) + approved_reply[slot][i] = (uint8_t)(slot * 0x31u + i * 3u); + if (strcmp(argv[1],"live-wrap") == 0) live_wrap(); + else if (strcmp(argv[1],"root-idle") == 0) root_does_not_rearm(); + else if (strcmp(argv[1],"queue-pressure") == 0) queue_pressure(); + else if (strcmp(argv[1],"backpressure") == 0) { + assert(argc == 3); + backpressure(strcmp(argv[2],"full") == 0); + } + else if (strcmp(argv[1],"pending") == 0) pending_one_shot(); + else if (strcmp(argv[1],"superseded") == 0) superseded(); + else if (strcmp(argv[1],"immediate-supersession") == 0) immediate_supersession(); + else if (strcmp(argv[1],"poll-retention") == 0) poll_retention(); + else if (strcmp(argv[1],"selection-history") == 0) selection_history(); + else if (strcmp(argv[1],"frozen-selection-history") == 0) frozen_selection_history(); + else if (strcmp(argv[1],"delayed-publication") == 0) delayed_publication(); + else if (strcmp(argv[1],"publication-isolation") == 0) publication_isolation(); + else if (strcmp(argv[1],"publication-wrap-supersession") == 0) publication_wrap_supersession(); + else if (strcmp(argv[1],"ep0-handover") == 0) ep0_handover(); + else if (strcmp(argv[1],"coherent-publication") == 0) coherent_publication(); + else if (strcmp(argv[1],"bulk-commit-pids") == 0) bulk_commit_pids(); + else if (strcmp(argv[1],"approved-status-handoff") == 0) approved_status_handoff(); + else if (strcmp(argv[1],"approved-status-superseded") == 0) approved_status_superseded(); + else if (strcmp(argv[1],"approved-status-reset") == 0) approved_status_reset(); + else if (strcmp(argv[1],"approved-status-reset-during-completion") == 0) approved_status_reset_during_completion(); + else if (strcmp(argv[1],"approved-status-port-reset-ready") == 0) approved_status_port_reset(false); + else if (strcmp(argv[1],"approved-status-port-reset-queued") == 0) approved_status_port_reset(true); + else if (strcmp(argv[1],"approved-status-invalid-length") == 0) approved_status_invalid_length(); + else if (strcmp(argv[1],"approved-status-watch") == 0) approved_status_watch(); + else if (strcmp(argv[1],"status-out-rejected-data") == 0) status_out_rejected_data(); + else if (strcmp(argv[1],"status-out") == 0) status_out_lifecycle(); + else if (strcmp(argv[1],"status-out-superseded") == 0) status_out_superseded_arm(); + else if (strcmp(argv[1],"status-out-stale") == 0) status_out_stale_completion(false); + else if (strcmp(argv[1],"status-out-reset") == 0) status_out_stale_completion(true); + else if (strcmp(argv[1],"status-out-reset-watch") == 0) status_out_reset_watch(false); + else if (strcmp(argv[1],"status-out-port-reset-watch") == 0) status_out_reset_watch(true); + else if (strcmp(argv[1],"marker-priority") == 0) { + assert(argc == 3); + marker_priority(strcmp(argv[2],"partial") == 0); + } + else if (strcmp(argv[1],"marker-zero") == 0) marker_zero_and_capacity(); + else if (strcmp(argv[1],"marker-active") == 0) marker_active_priority(); + else if (strcmp(argv[1],"marker-rejected") == 0) marker_rejected_requests(); + else assert(false && "unknown trace scenario"); + fprintf(stderr,"native hub trace scenario %s passed (%u children)\n",argv[1],(unsigned)CHILDREN); + return 0; +} diff --git a/tests/native_hub_transport_fixture.c b/tests/native_hub_transport_fixture.c index 147755a..b4dbf8f 100644 --- a/tests/native_hub_transport_fixture.c +++ b/tests/native_hub_transport_fixture.c @@ -1,4 +1,7 @@ #include "hardware_stub.h" +#include +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; } diff --git a/tests/switch2_mouse_bridge_test.cpp b/tests/switch2_mouse_bridge_test.cpp index 3395091..0bd3fcd 100644 --- a/tests/switch2_mouse_bridge_test.cpp +++ b/tests/switch2_mouse_bridge_test.cpp @@ -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 diff --git a/tests/switch2_native_gamepad_bridge_test.cpp b/tests/switch2_native_gamepad_bridge_test.cpp index 888ba2a..48b286f 100644 --- a/tests/switch2_native_gamepad_bridge_test.cpp +++ b/tests/switch2_native_gamepad_bridge_test.cpp @@ -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(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(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; } diff --git a/tests/switch2_usb_probe_protocol_test.c b/tests/switch2_usb_probe_protocol_test.c index 312e900..cc5c5ec 100644 --- a/tests/switch2_usb_probe_protocol_test.c +++ b/tests/switch2_usb_probe_protocol_test.c @@ -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}; diff --git a/tests/switch2_usb_probe_storage_native_stubs/hardware/flash.h b/tests/switch2_usb_probe_storage_native_stubs/hardware/flash.h new file mode 100644 index 0000000..2808dc6 --- /dev/null +++ b/tests/switch2_usb_probe_storage_native_stubs/hardware/flash.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +#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); diff --git a/tests/switch2_usb_probe_storage_native_stubs/pico/btstack_flash_bank.h b/tests/switch2_usb_probe_storage_native_stubs/pico/btstack_flash_bank.h new file mode 100644 index 0000000..a8abc60 --- /dev/null +++ b/tests/switch2_usb_probe_storage_native_stubs/pico/btstack_flash_bank.h @@ -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) diff --git a/tests/switch2_usb_probe_storage_native_stubs/pico/flash.h b/tests/switch2_usb_probe_storage_native_stubs/pico/flash.h new file mode 100644 index 0000000..fc5b238 --- /dev/null +++ b/tests/switch2_usb_probe_storage_native_stubs/pico/flash.h @@ -0,0 +1,7 @@ +#pragma once + +#include + +constexpr int PICO_OK = 0; +int flash_safe_execute(void (*function)(void*), void* parameter, + uint32_t enter_exit_timeout_ms); diff --git a/tests/switch2_usb_probe_storage_native_stubs/pico/platform.h b/tests/switch2_usb_probe_storage_native_stubs/pico/platform.h new file mode 100644 index 0000000..85fa56a --- /dev/null +++ b/tests/switch2_usb_probe_storage_native_stubs/pico/platform.h @@ -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(probe_test_flash)) diff --git a/tests/switch2_usb_probe_storage_test.cpp b/tests/switch2_usb_probe_storage_test.cpp new file mode 100644 index 0000000..71d41f5 --- /dev/null +++ b/tests/switch2_usb_probe_storage_test.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +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; +using Blobs = std::array; +using Image = std::vector; + +struct Mutation { + uint32_t offset; + size_t size; + bool erase; +}; +std::vector mutations; +int safe_calls; +int fail_at = -1; +size_t torn_bytes; +size_t mutation_limit = std::numeric_limits::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(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(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(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::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; +} diff --git a/tests/test_native_gamepad_backend_native.py b/tests/test_native_gamepad_backend_native.py index 28aa85d..c21fdcb 100644 --- a/tests/test_native_gamepad_backend_native.py +++ b/tests/test_native_gamepad_backend_native.py @@ -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) diff --git a/tests/test_native_hub_log_native.py b/tests/test_native_hub_log_native.py new file mode 100644 index 0000000..69f4767 --- /dev/null +++ b/tests/test_native_hub_log_native.py @@ -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" + ) diff --git a/tests/test_native_hub_management_native.py b/tests/test_native_hub_management_native.py index 9000bfa..a6b2b43 100644 --- a/tests/test_native_hub_management_native.py +++ b/tests/test_native_hub_management_native.py @@ -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) diff --git a/tests/test_native_hub_trace_native.py b/tests/test_native_hub_trace_native.py new file mode 100644 index 0000000..703c945 --- /dev/null +++ b/tests/test_native_hub_trace_native.py @@ -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" + ) diff --git a/tests/test_native_joycon_hub_live.py b/tests/test_native_joycon_hub_live.py new file mode 100644 index 0000000..4b578c1 --- /dev/null +++ b/tests/test_native_joycon_hub_live.py @@ -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("= 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() diff --git a/tests/test_native_joycon_hub_recovery.py b/tests/test_native_joycon_hub_recovery.py new file mode 100644 index 0000000..3a8fe48 --- /dev/null +++ b/tests/test_native_joycon_hub_recovery.py @@ -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(" 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", diff --git a/tests/test_switch2_usb_probe_protocol_native.py b/tests/test_switch2_usb_probe_protocol_native.py index 78b9e6a..16c76cc 100644 --- a/tests/test_switch2_usb_probe_protocol_native.py +++ b/tests/test_switch2_usb_probe_protocol_native.py @@ -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}", diff --git a/tests/test_switch2_usb_probe_storage_native.py b/tests/test_switch2_usb_probe_storage_native.py new file mode 100644 index 0000000..d2114c9 --- /dev/null +++ b/tests/test_switch2_usb_probe_storage_native.py @@ -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) diff --git a/tools/native_joycon_hub_check.py b/tools/native_joycon_hub_check.py index 44352be..855b476 100755 --- a/tools/native_joycon_hub_check.py +++ b/tools/native_joycon_hub_check.py @@ -2,10 +2,24 @@ """Bounded, non-pairing qualification of the switch-pico native Joy-Con USB hub. Requires Linux, PyUSB/libusb, and the existing sudo -n setfacl permission policy. -Uses already-paired R/L donors or one full gamepad; it cannot wake or pair them. +Live checks use already-paired R/L donors or one full gamepad per virtual pair; +they cannot wake or pair them. For two pairs, deliberately exercise independent +buttons/sticks on BOTH controllers throughout the check (including controls on +each R/L half); IMU mode also needs distinct deliberate motion on both sources. +Neutral or unassigned pairs are not live input. Shared R/L motion is expected +only within each full-gamepad pair, not across pairs. This cannot prove console +gameplay, physical source isolation, or physical latency. --neutral explicitly +selects standalone transport-only qualification with one or two pairs; it cannot +prove live input, IMU, gameplay, or rumble. The JSON capture is created exclusively before USB access and retains failures. -No reset, configuration change, pairing exchange, profile access, flash write, -or HID output is sent. Motor sample playback requires --rumble-sample explicitly. +Qualification sends no reset, configuration change, pairing exchange, profile +access, flash write, or HID output. Motor playback requires --rumble-sample. +--capture-trace-on-error optionally asks a TRACE-enabled root to retain its current +child EP0 context on the first child control-transfer error, before cleanup. +It never retries the failed request or establishes that its SETUP reached the child. +--reboot-bootsel is a separate, explicit root-only recovery operation: it sends +the private BOOTSEL request and confirms ROM USB enumeration at the same port, +without claiming interfaces or qualifying transport, live input, or gameplay. """ from __future__ import annotations @@ -28,6 +42,11 @@ from switch2_native_imu import decode_block, native_block VID = 0x057E ROOT_PID = 0x2068 SERIAL_PREFIX = "switch-pico-" +BOOTSEL_VID = 0x2E8A +BOOTSEL_PIDS = (0x0003, 0x000F) # RP2040 and RP2350 ROM USB boot devices. +NATIVE_HUB_TRACE_REQUEST = 0x5E +NATIVE_HUB_TRACE_VALUE = 0x5452 +NATIVE_HUB_TRACE_REPLY_SIZE = 16 SIDES = ("R", "L") # Only protocol constants live in source. Device-specific factory/calibration # captures stay in the private build paths configured by CMake. @@ -35,28 +54,132 @@ MODELS = { "R": {"pid": 0x2066, "port": 1, "report": 0x08, "diagnostic_host": "020000000001"}, "L": {"pid": 0x2067, "port": 2, "report": 0x07, "diagnostic_host": "020000000002"}, } +CAPTURE_PREFIXES = ( + "SWITCH2_PROBE", + "SWITCH2_PROBE_SECOND", + "SWITCH2_PROBE_THIRD", + "SWITCH2_PROBE_FOURTH", +) + + +def child_models(pairs: int) -> dict[str, dict[str, Any]]: + if pairs not in (1, 2): + raise ValueError("pair count must be 1 or 2") + models = {} + for slot in range(pairs * 2): + side = SIDES[slot % 2] + pair = "AB"[slot // 2] + child = side if pairs == 1 else f"{pair}_{side}" + models[child] = { + **MODELS[side], + "side": side, + "pair": pair, + "port": slot + 1, + "capture_prefix": CAPTURE_PREFIXES[slot], + "diagnostic_host": f"0200000000{slot + 1:02x}", + } + return models + + +def neutral_calibration(factory: bytes, user: bytes) -> tuple[bytes, str]: + # Match probe_memory_stick_calibration: each child's primary record, valid + # user override before factory; a nominal 0x800 center is not sufficient. + def valid(data: bytes) -> bool: + axes = [] + for offset in (0, 3, 6): + axes.append( + ( + data[offset] | ((data[offset + 1] & 15) << 8), + (data[offset + 1] >> 4) | (data[offset + 2] << 4), + ) + ) + center, positive, negative = axes + return all( + 0 < center[axis] < 4095 + and 0 < positive[axis] <= 4095 - center[axis] + and 0 < negative[axis] <= center[axis] + for axis in (0, 1) + ) + + if user[0x40:0x42] == b"\xb2\xa1" and valid(user[0x42:0x4B]): + return user[0x42:0x45], "user" + if not valid(factory[0xA8:0xB1]): + raise ValueError("no valid captured stick calibration for neutral reports") + return factory[0xA8:0xAB], "factory" def model_references( - build_dir: Path, *, require_imu: bool = True + build_dir: Path, *, require_imu: bool = True, pairs: int = 1, neutral: bool = False ) -> dict[str, dict[str, Any]]: cache = {} for line in (build_dir / "CMakeCache.txt").read_text().splitlines(): - if line.startswith("SWITCH2_") and ":" in line and "=" in line: + if line.startswith("SWITCH") and ":" in line and "=" in line: field, value = line.split("=", 1) cache[field.split(":", 1)[0]] = value + + def enabled(name: str) -> bool: + return cache.get(name, "OFF").upper() not in ( + "", + "0", + "OFF", + "NO", + "FALSE", + "N", + "IGNORE", + "NOTFOUND", + ) and not cache.get(name, "").upper().endswith("-NOTFOUND") + + if int(cache.get("SWITCH2_PROBE_PAIR_COUNT", "1")) != pairs: + raise ValueError( + "--pairs must match SWITCH2_PROBE_PAIR_COUNT in the build cache" + ) + if neutral != enabled("SWITCH2_PROBE_NEUTRAL_INPUT"): + raise ValueError( + "--neutral must match SWITCH2_PROBE_NEUTRAL_INPUT in the build cache" + ) + if ( + pairs == 2 + and not neutral + and ( + not enabled("SWITCH2_PROBE_HUB") + or not enabled("SWITCH_PICO_SWITCH2_USB_BRIDGE") + or cache.get("SWITCH2_BRIDGE_INPUT") not in ("DUALSENSE", "GAMEPAD") + ) + ): + raise ValueError( + "two-pair live references require a GAMEPAD or DUALSENSE Bluetooth bridge HUB" + ) + if neutral and ( + not enabled("SWITCH2_PROBE_HUB") + or not enabled("SWITCH2_PROBE_USB_INIT") + or enabled("SWITCH_PICO_SWITCH2_USB_BRIDGE") + ): + raise ValueError( + "neutral references require an initialized standalone HUB, not the Bluetooth bridge" + ) if ( require_imu + and not neutral and cache.get("SWITCH2_BRIDGE_INPUT") in ("DUALSENSE", "GAMEPAD") and cache.get("SWITCH2_BRIDGE_IMU_TARGET", "BOTH") != "BOTH" ): raise ValueError( - "Full dual-IMU qualification requires SWITCH2_BRIDGE_IMU_TARGET=BOTH; " + "Full paired-IMU qualification requires SWITCH2_BRIDGE_IMU_TARGET=BOTH on every pair; " "use the USB-completion UART trace for LEFT/RIGHT routing comparisons" ) - models = {} - for side, constants in MODELS.items(): - prefix = "SWITCH2_PROBE" if side == "R" else "SWITCH2_PROBE_SECOND" + models = child_models(pairs) + for child, model in models.items(): + prefix = model["capture_prefix"] + fields = ["IDENTITY_FILE", "VERSION_FILE", "FACTORY_FILE", "CONTROLLER_ADDRESS"] + if neutral: + fields.append("USER_CALIBRATION_FILE") + missing = [ + prefix + "_" + field + for field in fields + if not cache.get(prefix + "_" + field) + ] + if missing: + raise ValueError(f"{child} missing build references: {', '.join(missing)}") identity = Path(cache[prefix + "_IDENTITY_FILE"]).read_bytes() version = Path(cache[prefix + "_VERSION_FILE"]).read_bytes() factory = Path(cache[prefix + "_FACTORY_FILE"]).read_bytes() @@ -66,21 +189,44 @@ def model_references( or len(version) != 12 or len(factory) != 8192 or len(address) != 6 + or address in (bytes(6), b"\xff" * 6) ): - raise ValueError(f"{side} build references have invalid native lengths") + raise ValueError( + f"{child} build references have invalid native lengths/address" + ) if factory[:64] != identity or struct.unpack_from(" bytes: class Check: def __init__(self, args: argparse.Namespace, capture: Any) -> None: self.args = args - self.models = {side: model.copy() for side, model in MODELS.items()} + self.models = child_models(args.pairs) + self.children = tuple(self.models) self.capture = capture self.started = time.monotonic() self.deadline = self.started + args.timeout @@ -137,9 +284,36 @@ class Check: self.current_stage = "dependencies" self.last_counter: dict[str, int] = {} self.last_controls: dict[str, tuple[bytes, bytes]] = {} - self.imu_evidence: dict[str, set[bytes]] = {side: set() for side in SIDES} + self.imu_evidence: dict[str, set[bytes]] = { + side: set() for side in self.children + } + self.motion_evidence: dict[str, set[tuple[Any, ...]]] = { + child: set() for child in self.children + } + self.control_evidence: dict[str, set[tuple[bytes, bytes]]] = { + child: set() for child in self.children + } self.result: dict[str, Any] = { "schema_version": 1, + "neutral_transport_only": args.neutral, + "pair_count": args.pairs, + "gameplay_proven": False, + "physical_latency_proven": False, + "physical_source_isolation_proven": False, + "limitations": ( + [ + "No live controls, donor IMU, Bluetooth routing, console gameplay, or motor action is qualified.", + "Neutral reports cannot distinguish same-side HID cross-routing when captured centers match; distinct EP0/bulk identities are checked separately.", + "The build cache is a reference, not proof of which firmware is flashed.", + ] + if args.neutral + else [ + "Console gameplay, physical latency, and physical motor sensation are not qualified.", + "Observed distinct input/motion samples do not prove physical source isolation; independently exercise every source throughout the check.", + "Both virtual halves of every configured pair must show real activity; an unassigned or neutral pair cannot qualify.", + "The build cache is a reference, not proof of which firmware is flashed.", + ] + ), "success": False, "exit_code": 2, "started_utc": datetime.now(timezone.utc).isoformat(), @@ -148,7 +322,13 @@ class Check: "duration_seconds": args.duration, "usb_timeout_ms": args.usb_timeout_ms, "rumble_sample": args.rumble_sample, - "require_imu": not args.input_only, + "require_imu": not args.input_only and not args.neutral, + "input_only": args.input_only, + "pairs": args.pairs, + "neutral": args.neutral, + "capture_trace_on_error": getattr( + args, "capture_trace_on_error", False + ), }, "safety": { "pairing_writes": False, @@ -156,9 +336,20 @@ class Check: "flash_writes": False, "usb_reset": False, "motor_requested": args.rumble_sample is not None, + "trace_marker_requested": False, + "trace_marker_note": "Optional root vendor IN retains volatile trace only; no native initialization, pairing/profile/flash access, or failed-request retry. Device context may predate the failed SETUP; endpoint/physical acceptance is not proven.", + "initialization_note": "03/0d sets volatile diagnostic host/initialized state only; no 15/* pairing exchange or persistent pairing write is requested.", }, - "scope": "USB identity/protocol/input isolation, not console or motor-feel qualification", - "imu_decode_note": "Existing candidate codec; left uses its documented one-byte-earlier IMU boundary. Physical scales are not calibrated by this check.", + "scope": ( + "Standalone neutral USB transport only; no live input/IMU/gameplay qualification" + if args.neutral + else "USB identity/protocol and observed live input/motion evidence, not console gameplay, physical source isolation, or physical latency qualification" + ), + "imu_decode_note": ( + "No live IMU evidence is accepted or claimed; neutral reports must have empty IMU/mouse fields." + if args.neutral + else "Existing candidate codec; left uses its documented one-byte-earlier IMU boundary. Physical scales are not calibrated by this check." + ), "stages": [], "errors": [], "seen_roots": [], @@ -167,6 +358,7 @@ class Check: "acl": [], "interfaces": [], "controls": [], + "failure_trace": None, "bulk": [], "active_rounds": [], "cleanup": [], @@ -176,6 +368,9 @@ class Check: "valid_native_imu": 0, "valid_native_packets": 0, "imu_counter_changes": 0, + "valid_neutral_packets": 0, + "transport_counter_changes": 0, + "last_transport_counter_change_seconds": None, "wrong_side": 0, "unexpected_report": 0, "invalid": 0, @@ -193,7 +388,7 @@ class Check: "last_counter_change_seconds": None, "last_control_change_seconds": None, } - for side in SIDES + for side in self.children }, } self.checkpoint() @@ -274,15 +469,19 @@ class Check: "at_seconds": self.elapsed(), } self.result["controls"].append(entry) + transfer_attempted = False try: + timeout = self.timeout_ms() + device = self.devices[owner] + transfer_attempted = True data = bytes( - self.devices[owner].ctrl_transfer( + device.ctrl_transfer( request_type, request, value, index, length, - timeout=self.timeout_ms(), + timeout=timeout, ) ) entry["response_hex"] = data.hex() @@ -290,9 +489,92 @@ class Check: return data except Exception as error: entry["error"] = str(error) + entry["error_type"] = type(error).__name__ + if ( + transfer_attempted + and isinstance(error, OSError) + and getattr(self.args, "capture_trace_on_error", False) + and owner != "root" + and owner in self.models + and self.result["failure_trace"] is None + ): + self.capture_failure_trace(owner, entry) raise - def discover(self) -> None: + def capture_failure_trace(self, owner: str, failed_control: dict[str, Any]) -> None: + slot = self.models[owner]["port"] + trace: dict[str, Any] = { + "status": "pending", + "request_attempted": False, + "captured": False, + "side": owner, + "slot": slot, + "failed_control": failed_control.copy(), + "marker_setup": [ + 0xC0, + NATIVE_HUB_TRACE_REQUEST, + NATIVE_HUB_TRACE_VALUE, + slot, + NATIVE_HUB_TRACE_REPLY_SIZE, + ], + "at_seconds": self.elapsed(), + "response_hex": None, + "receipt": None, + } + # Latch before USB access: root errors cannot recurse or cause a retry. + self.result["failure_trace"] = trace + self.result["parameters"]["capture_trace_on_error"] = True + try: + try: + timeout = self.timeout_ms() + except TimeoutError as error: + trace["status"] = "deadline_expired" + trace["error"] = str(error) + return + root = self.devices.get("root") + if root is None: + trace["status"] = "root_unavailable" + trace["error"] = "no selected root available for the trace marker" + return + trace["request_attempted"] = True + trace["timeout_ms"] = timeout + self.result["safety"]["trace_marker_requested"] = True + # Deliberately bypass control(): this is one diagnostic IN, not recovery. + data = bytes(root.ctrl_transfer(*trace["marker_setup"], timeout=timeout)) + trace["response_hex"] = data.hex() + trace["status"] = "malformed" + if len(data) != NATIVE_HUB_TRACE_REPLY_SIZE: + raise ValueError("trace reply must be exactly 16 bytes") + magic, version, status, echoed_slot, reserved, time_us, generation = ( + struct.unpack("<4sBBBBII", data) + ) + if magic != b"NHTR" or version != 1 or reserved != 0: + raise ValueError("invalid trace reply header") + if echoed_slot != slot or status not in (0, 1): + raise ValueError("invalid trace reply slot or status") + if status == 1 and (time_us != 0 or generation != 0): + raise ValueError("busy trace reply must have zero time and generation") + trace["receipt"] = { + "version": version, + "status": status, + "slot": echoed_slot, + "time_us": time_us, + "control_generation": generation, + } + trace["captured"] = status == 0 + trace["status"] = "captured" if status == 0 else "busy" + except (OSError, RuntimeError, ValueError, TypeError, struct.error) as error: + if trace["status"] != "malformed": + trace["status"] = "error" + trace["error"] = f"{type(error).__name__}: {error}" + finally: + # Audit failures must not replace the original child-transfer exception. + try: + self.checkpoint() + except (OSError, ValueError, TypeError) as error: + trace["checkpoint_error"] = f"{type(error).__name__}: {error}" + + def discover(self, *, root_only: bool = False) -> None: until = min(self.deadline, self.started + 30) while time.monotonic() < until: devices = list(self.core.find(find_all=True) or []) @@ -324,6 +606,10 @@ class Check: ) if roots: root, root_info = roots[0] + if root_only: + self.devices = {"root": root} + self.result["devices"] = {"root": root_info} + return selected = {} direct_children = [] for device in devices: @@ -334,7 +620,7 @@ class Check: direct_children.append(seen) if seen not in self.result["seen_children"]: self.result["seen_children"].append(seen) - for side in SIDES: + for side in self.children: model = self.models[side] if (device.idVendor, device.idProduct, ports[-1]) == ( VID, @@ -346,24 +632,45 @@ class Check: f"duplicate {side} child on the expected hub port" ) selected[side] = device - if len(selected) == 2: - if len(direct_children) != 2: + if len(selected) == len(self.children): + if len(direct_children) != len(self.children): raise RuntimeError( "target hub has unexpected additional direct children" ) self.devices = {"root": root, **selected} - if len({device.address for device in self.devices.values()}) != 3: + if ( + len({device.address for device in self.devices.values()}) + != len(self.children) + 1 + ): raise RuntimeError( - "root/R/L do not have three distinct USB addresses" + "root and children do not have distinct USB addresses" ) self.result["devices"] = { "root": root_info, - **{side: location(selected[side]) for side in SIDES}, + **{ + side: { + **location(selected[side]), + "side": self.models[side]["side"], + "pair": self.models[side]["pair"], + "capture_prefix": self.models[side]["capture_prefix"], + } + for side in self.children + }, } return time.sleep(min(0.1, max(0, until - time.monotonic()))) + if root_only: + raise TimeoutError( + "discovery: expected one switch-pico 057e:2068 root; " + "no child enumeration is required; inspect seen_roots" + ) raise TimeoutError( - "discovery: expected one switch-pico 057e:2068 with R 2066 at port 1 and L 2067 at port 2 on the same path; inspect seen_roots/seen_children" + "discovery: expected one switch-pico 057e:2068 with " + + ", ".join( + f"{child} {model['pid']:04x} at port {model['port']}" + for child, model in self.models.items() + ) + + " on the same hub path; inspect seen_roots/seen_children" ) def permissions(self) -> None: @@ -411,9 +718,15 @@ class Check: raise RuntimeError("invalid root USB serial descriptor") if serial[2:].decode("utf-16-le") != self.result["devices"]["root"]["serial"]: raise RuntimeError("root USB serial differs from selected sysfs identity") + if self.args.neutral: + hub = self.control("root", "hub_descriptor", 0xA0, 6, 0x2900, 0, 255) + if len(hub) != 9 or hub[:3] != bytes((9, 0x29, len(self.children))): + raise RuntimeError( + "root hub descriptor does not advertise the requested child count" + ) def claim(self) -> None: - for side in SIDES: + for side in self.children: # GET_CONFIGURATION only: do not reset USB or set a configuration. if self.control(side, "active_configuration", 0x80, 8, 0, 0, 1) != b"\x01": raise RuntimeError( @@ -544,7 +857,7 @@ class Check: raise RuntimeError( f"{side} vendor 02 must be 16 bytes with wire MAC tail {self.models[side]['mac_wire']}" ) - # Short-then-full EP0 reads also check transfer length/context teardown. + # Full/short EP0 reads check transfer length/context teardown. short_length = (1, 7, 15)[round_number % 3] short = self.control( side, "vendor_version02_short", request_type, 2, 0, index, short_length @@ -552,17 +865,52 @@ class Check: if short != status[:short_length]: raise RuntimeError(f"{side} short vendor read leaked/truncated incorrectly") - def exchange_pair( + def interleaved_identities(self, round_number: int) -> None: + # Complete short reads on every address before full reads in reverse + # order, including while all child bulk replies are pending. + request_type, index = (0xC0, 0) if round_number % 2 == 0 else (0xC1, 1) + order = ( + self.children if round_number % 2 == 0 else tuple(reversed(self.children)) + ) + for slot, side in enumerate(order): + length = (1, 7, 15)[(round_number + slot) % 3] + for request, expected in ( + (3, bytes.fromhex(self.models[side]["identity"])), + (2, expected_status(self.models[side])), + ): + actual = self.control( + side, + f"vendor{request:02x}_interleaved_short", + request_type, + request, + 0, + index, + length, + ) + if actual != expected[:length]: + raise RuntimeError( + f"{side} interleaved short EP0 identity/status leaked" + ) + for side in reversed(order): + self.identities(side, round_number) + + def exchange_children( self, requests: dict[str, tuple[bytes, bytes]], round_number: int = 0 ) -> None: - order = SIDES if round_number % 2 == 0 else tuple(reversed(SIDES)) + order = ( + self.children if round_number % 2 == 0 else tuple(reversed(self.children)) + ) entries = {} - # Both devices have pending, identical-form commands before either IN is - # consumed. Reverse completion order to expose global reply-buffer reuse. + # All devices have pending commands before any IN is consumed. Reverse + # completion order to expose global reply-buffer reuse. for side in order: request, expected = requests[side] - if request[:4] == b"\x0a\x91\x00\x02" and self.args.rumble_sample is None: - raise RuntimeError("motor command requires explicit --rumble-sample") + if request[:4] == b"\x0a\x91\x00\x02" and ( + self.args.neutral or self.args.rumble_sample is None + ): + raise RuntimeError( + "motor command requires --rumble-sample and live mode" + ) entry = { "stage": self.current_stage, "side": side, @@ -581,6 +929,8 @@ class Check: raise RuntimeError( f"{side} short native bulk OUT ({written}/{len(request)})" ) + if self.args.neutral: + self.interleaved_identities(round_number) for side in reversed(order): expected = requests[side][1] actual = bytearray() @@ -611,7 +961,7 @@ class Check: "feature_enable", ): requests = {} - for side in SIDES: + for side in self.children: if operation == "initialize": request = command( 3, @@ -634,37 +984,83 @@ class Check: ) response = reply(request, bytes(4)) requests[side] = (request, response) - self.exchange_pair(requests) + self.exchange_children(requests) def queries(self, round_number: int) -> None: request = command(0x10, 1) - self.exchange_pair( + self.exchange_children( { side: ( request, reply(request, bytes.fromhex(self.models[side]["version"])), ) - for side in SIDES + for side in self.children }, round_number, ) - requests = {} - for side_index, side in enumerate(SIDES): - offset = (round_number + side_index) % 2 - address = 0x13000 + offset - request = command(2, 4, b"\x50\x7e\x00\x00" + struct.pack(" str | None: + stream = self.result["streams"][side] + center = bytes.fromhex(self.models[side]["stick_center"]) + if any(payload[2:4]): + rejection = "neutral transport emitted nonzero buttons" + elif payload[5:8] != center: + rejection = "neutral transport stick does not match the selected captured calibration center" + elif any(payload[8:]): + rejection = "neutral transport emitted nonzero reserved/mouse/IMU fields" + else: + rejection = None + if rejection: + stream["invalid"] += 1 + return rejection + now = self.elapsed() + counter = payload[0] + sample["transport_counter"] = counter + stream["valid_neutral_packets"] += 1 + stream["zero_length_imu"] += 1 + stream["last_valid_seconds"] = now + if stream["first_valid_seconds"] is None: + stream["first_valid_seconds"] = now + if side in self.last_counter and self.last_counter[side] != counter: + stream["transport_counter_changes"] += 1 + stream["last_transport_counter_change_seconds"] = now + self.last_counter[side] = counter + samples = stream["samples"] + if len(samples) < 4 or ( + len(samples) < 32 and now - samples[-1]["at_seconds"] >= 0.5 + ): + samples.append(sample) + stream["last_sample"] = sample + return None def poll(self, side: str, until: float) -> None: stream = self.result["streams"][side] @@ -690,7 +1086,10 @@ class Check: "packet_hex": packet.hex(), } rejection = None - if report_id == self.models["L" if side == "R" else "R"]["report"]: + if ( + report_id + == MODELS["L" if self.models[side]["side"] == "R" else "R"]["report"] + ): stream["wrong_side"] += 1 rejection = "wrong-side native report on this device's HID pipe" elif report_id != self.models[side]["report"]: @@ -711,14 +1110,18 @@ class Check: if any(controls[0]): stream["buttons_nonzero"] += 1 if side in self.last_controls and self.last_controls[side] != controls: + if any(controls[0]) and len(self.control_evidence[side]) < 512: + self.control_evidence[side].add(controls) stream["control_changes"] += 1 stream["last_control_change_seconds"] = self.elapsed() self.last_controls[side] = controls - length_offset = 14 if side == "L" else 15 + length_offset = 14 if self.models[side]["side"] == "L" else 15 length = payload[length_offset] key = str(length) stream["imu_lengths"][key] = stream["imu_lengths"].get(key, 0) + 1 - if length == 0: + if self.args.neutral: + rejection = self.neutral_report(side, payload, sample) + elif length == 0: stream["zero_length_imu"] += 1 if self.args.input_only: if len(stream["samples"]) < 4: @@ -730,7 +1133,7 @@ class Check: try: block = ( native_block({"native_hex": packet.hex()}) - if side == "R" + if self.models[side]["side"] == "R" else payload[length_offset + 1 : length_offset + 1 + length] ) decoded = decode_block(block) @@ -763,6 +1166,21 @@ class Check: self.last_counter[side] = counter if len(self.imu_evidence[side]) < 512: self.imu_evidence[side].add(block) + if len(self.motion_evidence[side]) < 512: + # Counters, elapsed ticks and temperature are not motion. + self.motion_evidence[side].add( + ( + tuple(decoded["quaternion_wire"]), + tuple( + tuple(vector["raw"]) + for vector in decoded["accelerations"] + ), + tuple( + tuple(vector["raw"]) + for vector in decoded["rotation_triplets"] + ), + ) + ) format_key = f"{decoded['format']:02x}" first_format = format_key not in stream["imu_formats"] stream["imu_formats"][format_key] = ( @@ -783,8 +1201,8 @@ class Check: if len(stream["rejected_samples"]) < 8: stream["rejected_samples"].append(sample) - def poll_pair(self, until: float, reverse: bool = False) -> None: - for side in reversed(SIDES) if reverse else SIDES: + def poll_children(self, until: float, reverse: bool = False) -> None: + for side in reversed(self.children) if reverse else self.children: if time.monotonic() >= until: break self.poll(side, until) @@ -792,43 +1210,76 @@ class Check: def counts(self) -> dict[str, int]: return { side: self.result["streams"][side][ - "valid_native_packets" if self.args.input_only else "valid_native_imu" + "valid_neutral_packets" + if self.args.neutral + else "valid_native_packets" + if self.args.input_only + else "valid_native_imu" ] - for side in SIDES + for side in self.children } - def donors_ready(self) -> None: + def streams_ready(self) -> None: until = min(self.deadline, time.monotonic() + 60) iteration = 0 while time.monotonic() < until: - self.poll_pair(until, bool(iteration % 2)) + self.poll_children(until, bool(iteration % 2)) iteration += 1 if all( - (stream["buttons_nonzero"] > 0 and stream["control_changes"] >= 2) + ( + stream["valid_neutral_packets"] >= 3 + and stream["transport_counter_changes"] >= 2 + ) + if self.args.neutral + else (stream["buttons_nonzero"] > 0 and stream["control_changes"] >= 2) if self.args.input_only else ( stream["valid_native_imu"] >= 3 and stream["imu_counter_changes"] >= 2 + and ( + self.args.pairs == 1 + or ( + stream["buttons_nonzero"] > 0 + and stream["control_changes"] >= 2 + ) + ) ) for stream in self.result["streams"].values() ): return details = "; ".join( - f"{side}: imu={stream['valid_native_imu']}, counter_changes={stream['imu_counter_changes']}, buttons={stream['buttons_nonzero']}, control_changes={stream['control_changes']}, zero_imu={stream['zero_length_imu']}, invalid={stream['invalid']}, timeouts={stream['timeouts']}" + f"{side}: neutral={stream['valid_neutral_packets']}, transport_changes={stream['transport_counter_changes']}, imu={stream['valid_native_imu']}, counter_changes={stream['imu_counter_changes']}, buttons={stream['buttons_nonzero']}, control_changes={stream['control_changes']}, zero_imu={stream['zero_length_imu']}, invalid={stream['invalid']}, timeouts={stream['timeouts']}" for side, stream in self.result["streams"].items() ) raise RuntimeError( - f"sources did not satisfy the requested live-input evidence during the manual-input window; {details}; no pairing/wake/reset was attempted" + f"neutral reports/counters did not become ready on every child; {details}" + if self.args.neutral + else f"sources did not satisfy live-input evidence on every child during the manual-input window; both pairs must be assigned and independently exercised for --pairs 2; {details}; no pairing/wake/reset was attempted" ) def active(self) -> None: until = min(self.deadline, time.monotonic() + self.args.duration) initial_counts = self.counts() + initial_controls = { + child: self.result["streams"][child]["control_changes"] + for child in self.children + } + for evidence in ( + self.imu_evidence, + self.motion_evidence, + self.control_evidence, + ): + for samples in evidence.values(): + samples.clear() + change_key = ( + "transport_counter_changes" + if self.args.neutral + else "control_changes" + if self.args.input_only + else "imu_counter_changes" + ) initial_changes = { - side: self.result["streams"][side][ - "control_changes" if self.args.input_only else "imu_counter_changes" - ] - for side in SIDES + side: self.result["streams"][side][change_key] for side in self.children } next_query = time.monotonic() round_number = 0 @@ -837,13 +1288,25 @@ class Check: if time.monotonic() >= next_query and until - time.monotonic() >= 0.25: if pending is not None: pending["after"] = self.counts() + if self.args.neutral: + pending["after_changes"] = { + side: self.result["streams"][side][change_key] + for side in self.children + } pending = { "round": round_number, "before": self.counts(), "started_seconds": self.elapsed(), } + if self.args.neutral: + pending["before_changes"] = { + side: self.result["streams"][side][change_key] + for side in self.children + } self.result["active_rounds"].append(pending) - for side in SIDES if round_number % 2 == 0 else reversed(SIDES): + for side in ( + self.children if round_number % 2 == 0 else reversed(self.children) + ): self.descriptors(side, report=round_number % 2 == 0) self.identities(side, round_number) self.queries(round_number) @@ -851,9 +1314,14 @@ class Check: next_query = time.monotonic() + 0.25 round_number += 1 self.checkpoint() - self.poll_pair(until, bool(round_number % 2)) + self.poll_children(until, bool(round_number % 2)) if pending is not None: pending["after"] = self.counts() + if self.args.neutral: + pending["after_changes"] = { + side: self.result["streams"][side][change_key] + for side in self.children + } if round_number < 2: self.error( "fewer than two interleaved control/bulk rounds completed during streaming" @@ -861,67 +1329,150 @@ class Check: if not any( all( entry.get("after", {}).get(side, 0) > entry["before"][side] - for side in SIDES + and ( + not self.args.neutral + or entry.get("after_changes", {}).get(side, 0) + > entry["before_changes"][side] + ) + for side in self.children ) for entry in self.result["active_rounds"] if entry.get("reads_matched") ): self.error( - "no interleaved control/bulk round was bracketed by valid input from both donors" + "no interleaved control/bulk round was bracketed by valid reports and advancing counters on every child" + if self.args.neutral + else "no interleaved control/bulk round was bracketed by valid input from every child" ) - shared = self.imu_evidence["R"] & self.imu_evidence["L"] - shared_source = self.models["R"].get("source_mode") in ("DUALSENSE", "GAMEPAD") - self.result["imu_isolation"] = { - "sample_limit_per_side": 512, - "policy": "not_required_input_only" - if self.args.input_only - else "shared_physical_source" - if shared_source - else "independent_physical_sources", - "identical_blocks_seen_on_both_sides": len(shared), - "unique_blocks": { - side: len(blocks) for side, blocks in self.imu_evidence.items() - }, - "side_exclusive_blocks": { - side: len(blocks - shared) for side, blocks in self.imu_evidence.items() - }, - } - for side in () if self.args.input_only else SIDES: - evidence = ( - self.imu_evidence[side] - if shared_source - else self.imu_evidence[side] - shared - ) - if len(evidence) < 2: - self.error( - "IMU evidence is frozen" - if shared_source - else "donor IMU evidence is frozen or duplicated across child devices", - side, + if self.args.neutral: + self.result["imu_isolation"] = { + "policy": "not_proven_neutral_transport_only" + } + else: + pair_results = {} + self.result["imu_isolation"] = { + "sample_limit_per_child": 512, + "policy": "not_required_input_only" + if self.args.input_only + else "pair_local_source_policy", + "pairs": pair_results, + } + for offset in range(0, len(self.children), 2): + pair_children = self.children[offset : offset + 2] + right, left = pair_children + shared = self.imu_evidence[right] & self.imu_evidence[left] + shared_source = self.models[right].get("source_mode") in ( + "DUALSENSE", + "GAMEPAD", ) - for side in SIDES: + pair_results[self.models[right]["pair"]] = { + "policy": "not_required_input_only" + if self.args.input_only + else "shared_physical_source" + if shared_source + else "independent_physical_sources", + "identical_blocks_seen_on_both_sides": len(shared), + "unique_blocks": { + child: len(self.imu_evidence[child]) for child in pair_children + }, + "side_exclusive_blocks": { + child: len(self.imu_evidence[child] - shared) + for child in pair_children + }, + "unique_motion_samples": { + child: len(self.motion_evidence[child]) + for child in pair_children + }, + } + if not self.args.input_only: + for child in pair_children: + evidence = self.imu_evidence[child] + if not shared_source: + evidence = evidence - shared + if len(evidence) < 2 or len(self.motion_evidence[child]) < 2: + self.error( + "IMU evidence is frozen or lacks deliberate motion" + if shared_source + else "donor IMU evidence is frozen or duplicated within its virtual pair", + child, + ) + if self.args.pairs == 2: + comparison = { + "policy": "distinct_exercised_samples_required_not_physical_source_isolation", + "physical_source_isolation_proven": False, + "children": {}, + } + self.result["inter_pair_evidence"] = comparison + for child in self.children: + other_pair = [ + other + for other in self.children + if self.models[other]["pair"] != self.models[child]["pair"] + ] + other_side = next( + other + for other in other_pair + if self.models[other]["side"] == self.models[child]["side"] + ) + controls = ( + self.control_evidence[child] - self.control_evidence[other_side] + ) + motion = self.motion_evidence[child] - set().union( + *(self.motion_evidence[other] for other in other_pair) + ) + comparison["children"][child] = { + "pair_exclusive_pressed_control_states": len(controls), + "pair_exclusive_motion_samples": len(motion), + } + if len(controls) < 2: + self.error( + "insufficient pair-distinct pressed control states; independently press buttons and vary sticks on both controllers, including every R/L half; neutral/static or mirrored input cannot qualify", + child, + ) + if not self.args.input_only and len(motion) < 2: + self.error( + "insufficient pair-distinct motion; deliberately move both sources differently; shared/static samples or advancing counters alone cannot qualify", + child, + ) + for side in self.children: stream = self.result["streams"][side] + if self.args.pairs == 2 and not self.args.neutral: + last_control = stream["last_control_change_seconds"] + if ( + stream["control_changes"] - initial_controls[side] < 2 + or last_control is None + or self.elapsed() - last_control > 2 + ): + self.error( + "real controls must keep changing on every child of both pairs during active reads", + side, + ) if ( self.counts()[side] - initial_counts[side] < 3 - or stream[ - "control_changes" if self.args.input_only else "imu_counter_changes" - ] - - initial_changes[side] - < 2 + or stream[change_key] - initial_changes[side] < 2 ): self.error( - "insufficient fresh controller transitions" + "insufficient fresh neutral reports/transport counters during active control/bulk reads" + if self.args.neutral + else "insufficient fresh controller transitions" if self.args.input_only else "insufficient fresh native source IMU during active control/bulk reads", side, ) last_change = stream[ - "last_control_change_seconds" + "last_transport_counter_change_seconds" + if self.args.neutral + else "last_control_change_seconds" if self.args.input_only else "last_counter_change_seconds" ] if last_change is None or self.elapsed() - last_change > 2: - self.error("source stopped advancing before streaming finished", side) + self.error( + "neutral transport counter stopped advancing before streaming finished" + if self.args.neutral + else "source stopped advancing before streaming finished", + side, + ) if stream["wrong_side"] or stream["unexpected_report"] or stream["invalid"]: self.error( f"rejected wrong-side={stream['wrong_side']}, unexpected={stream['unexpected_report']}, malformed={stream['invalid']} HID packets", @@ -968,8 +1519,31 @@ class Check: with self.stage("references"): self.models = model_references( - self.args.build_dir, require_imu=not self.args.input_only + self.args.build_dir, + require_imu=not self.args.input_only and not self.args.neutral, + pairs=self.args.pairs, + neutral=self.args.neutral, ) + self.result["references"] = { + "build_dir": str(self.args.build_dir.resolve()), + "children": { + side: { + field: model[field] + for field in ( + "pair", + "side", + "port", + "capture_prefix", + "mac_wire", + "source_mode", + "stick_center", + "calibration_source", + ) + if field in model + } + for side, model in self.models.items() + }, + } self.core, self.util = usb.core, usb.util with self.stage("discovery"): self.discover() @@ -979,7 +1553,11 @@ class Check: self.claim() with self.stage("descriptor_and_ep0_isolation"): for round_number in range(20): - for side in SIDES if round_number % 2 == 0 else reversed(SIDES): + for side in ( + self.children + if round_number % 2 == 0 + else reversed(self.children) + ): self.descriptors(side, report=round_number in (0, 19)) self.identities(side, round_number) with self.stage("native_initialization"): @@ -987,17 +1565,27 @@ class Check: with self.stage("bulk_isolation"): for round_number in range(2): self.queries(round_number) - with self.stage("donor_startup"): - self.donors_ready() - with self.stage("active_input_and_read_isolation"): + with self.stage( + "neutral_stream_startup" if self.args.neutral else "donor_startup" + ): + self.streams_ready() + with self.stage( + "active_neutral_transport_and_read_isolation" + if self.args.neutral + else "active_input_and_read_isolation" + ): self.active() - if self.args.rumble_sample is not None and not self.result["errors"]: + if ( + not self.args.neutral + and self.args.rumble_sample is not None + and not self.result["errors"] + ): with self.stage("explicit_motor_sample_ack"): request = command( 0x0A, 2, bytes((self.args.rumble_sample, 0, 0, 0)) ) - self.exchange_pair( - {side: (request, reply(request)) for side in SIDES} + self.exchange_children( + {side: (request, reply(request)) for side in self.children} ) self.result["motor_result"] = ( "native sample ACK received for each side; physical sensation is not measured" @@ -1006,6 +1594,10 @@ class Check: self.result["stages"].append( {"name": "explicit_motor_sample_ack", "status": "skipped"} ) + if self.args.neutral: + self.result["motor_result"] = ( + "not_requested_neutral_transport_only; no motor action or ACK qualified" + ) completed = True except KeyboardInterrupt: self.result["interrupted"] = True @@ -1029,11 +1621,58 @@ class Check: self.result["exit_code"] = 0 if self.result["success"] else 2 failed = sorted({entry["stage"] for entry in self.result["errors"]}) streams = self.result["streams"] + self.result["child_results"] = { + side: { + "pair": self.models[side]["pair"], + "side": self.models[side]["side"], + "port": self.models[side]["port"], + "qualified": self.result["success"], + "neutral_transport_only": self.args.neutral, + "live_input_proven": self.result["success"] + and not self.args.neutral, + "live_imu_proven": self.result["success"] + and not self.args.neutral + and not self.args.input_only, + "gameplay_proven": False, + "physical_latency_proven": False, + "physical_source_isolation_proven": False, + "valid_reports": self.counts()[side], + "transport_counter_changes": streams[side][ + "transport_counter_changes" + ], + "control_changes": streams[side]["control_changes"], + "imu_counter_changes": streams[side]["imu_counter_changes"], + "last_sample": streams[side].get("last_sample"), + "errors": [ + entry + for entry in self.result["errors"] + if entry["side"] in (side, None, "root") + ], + } + for side in self.children + } + if self.args.neutral: + stream_summary = ( + f"NEUTRAL_TRANSPORT_ONLY pairs={self.args.pairs} gameplay=not_proven " + + " ".join( + f"{side}={streams[side]['valid_neutral_packets']} {side}_transport_counter_changes={streams[side]['transport_counter_changes']}" + for side in self.children + ) + + " " + ) + else: + stream_summary = ( + f"{'LIVE_INPUT_ONLY' if self.args.input_only else 'LIVE_INPUT_AND_IMU'} " + f"pairs={self.args.pairs} gameplay=not_proven physical_latency=not_proven " + + " ".join( + f"{child}={self.counts()[child]} {child}_control_changes={streams[child]['control_changes']} {child}_imu_counter_changes={streams[child]['imu_counter_changes']}" + for child in self.children + ) + + " " + ) self.result["summary"] = ( f"{'PASS' if self.result['success'] else 'FAIL'} " - f"R={streams['R']['valid_native_imu']} L={streams['L']['valid_native_imu']} " - f"R_counter_changes={streams['R']['imu_counter_changes']} " - f"L_counter_changes={streams['L']['imu_counter_changes']} " + f"{stream_summary}" f"active_rounds={len(self.result['active_rounds'])} " f"errors={len(self.result['errors'])} failed_stages={','.join(failed) or 'none'}" ) @@ -1043,6 +1682,191 @@ class Check: return self.result["exit_code"] +class BootselRecovery(Check): + """Reuse bounded auditing/root identity checks, never the qualification run.""" + + def __init__(self, args: argparse.Namespace, capture: Any) -> None: + self.args = args + self.capture = capture + self.started = time.monotonic() + self.deadline = self.started + args.timeout + self.core: Any = None + self.util: Any = None + self.devices: dict[str, Any] = {} + self.claimed: list[tuple[str, int]] = [] + self.detached: list[tuple[str, int]] = [] + self.current_stage = "dependencies" + self.result: dict[str, Any] = { + "schema_version": 1, + "operation": "bootsel_recovery", + "scope": "Root-only ROM BOOTSEL recovery; no controller or transport qualification", + "success": False, + "recovery_success": False, + "qualification_success": False, + "live_input_proven": False, + "live_imu_proven": False, + "gameplay_proven": False, + "exit_code": 2, + "started_utc": datetime.now(timezone.utc).isoformat(), + "parameters": { + "timeout_seconds": args.timeout, + "usb_timeout_ms": args.usb_timeout_ms, + "reboot_bootsel": True, + }, + "safety": { + "pairing_writes": False, + "profile_access": False, + "flash_writes": False, + "usb_reset": False, + "motor_requested": False, + "native_initialization": False, + "interface_claims": False, + }, + "recovery": { + "request_attempted": False, + "request_acknowledged": False, + "root_disappeared": False, + "rom_confirmed": False, + }, + "stages": [], + "errors": [], + "seen_roots": [], + "seen_bootsel": [], + "devices": {}, + "acl": [], + "controls": [], + "cleanup": [], + } + self.checkpoint() + + def ctrl_transfer( + self, + request_type: int, + request: int, + value: int, + index: int, + data: bytes, + timeout: int, + ) -> int: + # config_manager owns the envelope/setup encoding; this transport adds + # the scenario deadline, audit trail, and short-write detection. + entry = { + "stage": self.current_stage, + "side": "root", + "name": "bootsel_reboot", + "setup": [request_type, request, value, index, len(data)], + "request_hex": data.hex(), + "at_seconds": self.elapsed(), + } + self.result["controls"].append(entry) + self.result["recovery"]["request_attempted"] = True + self.checkpoint() + try: + written = self.devices["root"].ctrl_transfer( + request_type, + request, + value, + index, + data, + timeout=self.timeout_ms(min(timeout, self.args.usb_timeout_ms)), + ) + entry["length"] = written + if written != len(data): + raise RuntimeError( + f"short BOOTSEL control write: {written} of {len(data)} bytes; " + "reboot outcome is unconfirmed" + ) + self.result["recovery"]["request_acknowledged"] = True + return written + except Exception as error: + entry["error"] = str(error) + raise + + def confirm_bootsel(self) -> None: + root = self.result["devices"]["root"] + recovery = self.result["recovery"] + while time.monotonic() < self.deadline: + at_port = [] + for device in self.core.find(find_all=True) or []: + same_port = device.bus == root["bus"] and tuple( + device.port_numbers or () + ) == tuple(root["ports"]) + if same_port: + at_port.append(device) + if device.idVendor == BOOTSEL_VID and device.idProduct in BOOTSEL_PIDS: + seen = location(device) + if seen not in self.result["seen_bootsel"]: + self.result["seen_bootsel"].append(seen) + if not any( + (device.idVendor, device.idProduct) == (VID, ROOT_PID) + for device in at_port + ): + recovery["root_disappeared"] = True + if len(at_port) == 1: + device = at_port[0] + if device.idVendor == BOOTSEL_VID and device.idProduct in BOOTSEL_PIDS: + self.result["devices"]["bootsel"] = location(device) + recovery["rom_confirmed"] = True + return + self.checkpoint() + time.sleep(min(0.1, max(0, self.deadline - time.monotonic()))) + raise TimeoutError( + "BOOTSEL request acknowledged, but ROM USB did not replace the root " + "at the same physical bus/port before the deadline; recovery is unconfirmed" + ) + + def run(self) -> int: + completed = False + try: + with self.stage("dependencies"): + import usb.core + import usb.util + + from switch_pico_bridge.config_manager import request_bootsel_reboot + + self.core, self.util = usb.core, usb.util + with self.stage("root_only_discovery"): + self.discover(root_only=True) + with self.stage("permissions_and_root_identity"): + self.permissions() + with self.stage("explicit_bootsel_request"): + request_bootsel_reboot(self) + with self.stage("same_port_rom_enumeration"): + self.confirm_bootsel() + completed = True + except KeyboardInterrupt: + self.result["interrupted"] = True + except ( + OSError, + RuntimeError, + ValueError, + TypeError, + ImportError, + subprocess.SubprocessError, + struct.error, + ) as error: + self.result["failure"] = str(error) + finally: + try: + with self.stage("cleanup"): + self.cleanup() + finally: + success = completed and not self.result["errors"] + self.result["success"] = self.result["recovery_success"] = success + self.result["exit_code"] = 0 if success else 2 + recovery = self.result["recovery"] + self.result["summary"] = ( + f"{'RECOVERY_CONFIRMED' if success else 'RECOVERY_INCOMPLETE'} " + f"BOOTSEL request_acknowledged={recovery['request_acknowledged']} " + f"same_port_rom_confirmed={recovery['rom_confirmed']} " + "qualification=not_run live_input=not_proven gameplay=not_proven" + ) + self.checkpoint() + print(f"[NATIVEHUB] {self.result['summary']}", flush=True) + print(f"[NATIVEHUB] capture={self.args.output}", flush=True) + return self.result["exit_code"] + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -1075,10 +1899,28 @@ def main() -> int: default=500, help="per control/bulk transfer timeout, 20..3000 ms (default: 500)", ) - parser.add_argument( + input_mode = parser.add_mutually_exclusive_group() + input_mode.add_argument( "--input-only", action="store_true", - help="qualify controllers without IMU; requires real button presses and continued control changes on both halves", + help="qualify without IMU; requires real button presses and continued control changes on every R/L half of every pair; use distinct independent controls for two pairs", + ) + input_mode.add_argument( + "--neutral", + action="store_true", + help="OPT-IN: standalone neutral transport only; requires calibrated neutral controls and advancing USB counters, never proves live input/IMU/gameplay", + ) + input_mode.add_argument( + "--reboot-bootsel", + action="store_true", + help="OPT-IN: reboot only the identified root into ROM BOOTSEL and confirm the same physical port; no qualification, children, build references, or interface claims", + ) + parser.add_argument( + "--pairs", + type=int, + choices=(1, 2), + default=1, + help="hub pair count matching the build cache; two live pairs require GAMEPAD/DUALSENSE and independent activity on both controllers (default: 1)", ) parser.add_argument( "--rumble-sample", @@ -1086,15 +1928,33 @@ def main() -> int: choices=range(8), help="OPT-IN: play native motor sample 0..7 once on each donor and require its ACK", ) + parser.add_argument( + "--capture-trace-on-error", + action="store_true", + help="OPT-IN: request one volatile child EP0 snapshot from a TRACE-enabled root after the first child control-transfer error, before cleanup; never retry the failed request", + ) args = parser.parse_args() + if args.reboot_bootsel and args.capture_trace_on_error: + parser.error( + "--reboot-bootsel forbids --capture-trace-on-error; recovery never captures child traces" + ) + if args.reboot_bootsel and args.rumble_sample is not None: + parser.error( + "--reboot-bootsel forbids --rumble-sample; recovery never actuates motors" + ) + if args.neutral and args.rumble_sample is not None: + parser.error( + "--neutral forbids --rumble-sample; transport qualification must not actuate motors" + ) if not math.isfinite(args.timeout) or not 0 < args.timeout <= 600: parser.error("timeout must be finite and in (0,600]") - if not math.isfinite(args.duration) or not 2 <= args.duration <= 120: - parser.error("duration must be finite and in [2,120]") - if args.timeout < args.duration + 10: - parser.error( - "timeout must allow at least duration + 10 seconds for discovery/initialization" - ) + if not args.reboot_bootsel: + if not math.isfinite(args.duration) or not 2 <= args.duration <= 120: + parser.error("duration must be finite and in [2,120]") + if args.timeout < args.duration + 10: + parser.error( + "timeout must allow at least duration + 10 seconds for discovery/initialization" + ) if not 20 <= args.usb_timeout_ms <= 3000: parser.error("usb-timeout-ms must be in [20,3000]") try: @@ -1109,7 +1969,8 @@ def main() -> int: previous = signal.signal(signal.SIGTERM, interrupted) try: with capture: - return Check(args, capture).run() + scenario = BootselRecovery if args.reboot_bootsel else Check + return scenario(args, capture).run() finally: signal.signal(signal.SIGTERM, previous) diff --git a/tools/pico_usb_address_probe/router.c b/tools/pico_usb_address_probe/router.c index 4cdb421..882cc7f 100644 --- a/tools/pico_usb_address_probe/router.c +++ b/tools/pico_usb_address_probe/router.c @@ -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 diff --git a/tools/pico_usb_address_probe/router.h b/tools/pico_usb_address_probe/router.h index d68903d..8bc826f 100644 --- a/tools/pico_usb_address_probe/router.h +++ b/tools/pico_usb_address_probe/router.h @@ -3,7 +3,11 @@ #include #include +#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 { diff --git a/tools/switch2_usb_probe/CMakeLists.txt b/tools/switch2_usb_probe/CMakeLists.txt index 49bf8c5..70e804b 100644 --- a/tools/switch2_usb_probe/CMakeLists.txt +++ b/tools/switch2_usb_probe/CMakeLists.txt @@ -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) diff --git a/tools/switch2_usb_probe/bootsel.cpp b/tools/switch2_usb_probe/bootsel.cpp index f3f579f..438d1ae 100644 --- a/tools/switch2_usb_probe/bootsel.cpp +++ b/tools/switch2_usb_probe/bootsel.cpp @@ -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(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) { diff --git a/tools/switch2_usb_probe/bootsel.h b/tools/switch2_usb_probe/bootsel.h index 4bfd396..f4f7461 100644 --- a/tools/switch2_usb_probe/bootsel.h +++ b/tools/switch2_usb_probe/bootsel.h @@ -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. diff --git a/tools/switch2_usb_probe/descriptors.h b/tools/switch2_usb_probe/descriptors.h index 1196ae4..b03857e 100644 --- a/tools/switch2_usb_probe/descriptors.h +++ b/tools/switch2_usb_probe/descriptors.h @@ -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 diff --git a/tools/switch2_usb_probe/main.c b/tools/switch2_usb_probe/main.c index c5a028c..1aaeaae 100644 --- a/tools/switch2_usb_probe/main.c +++ b/tools/switch2_usb_probe/main.c @@ -7,14 +7,23 @@ #include #include #include +#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 diff --git a/tools/switch2_usb_probe/model.h b/tools/switch2_usb_probe/model.h index 77dd152..bfd9bf1 100644 --- a/tools/switch2_usb_probe/model.h +++ b/tools/switch2_usb_probe/model.h @@ -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; diff --git a/tools/switch2_usb_probe/native_gamepad_input.cpp b/tools/switch2_usb_probe/native_gamepad_input.cpp index ebed5f0..20bfb81 100644 --- a/tools/switch2_usb_probe/native_gamepad_input.cpp +++ b/tools/switch2_usb_probe/native_gamepad_input.cpp @@ -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(-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(source[2]) * scale; - switch (g_mapped.native_joycon_layout) { + switch (layout) { case ControllerProfileNativeJoyconLayout::kLeftSolo: output[0] = static_cast(source[1]) * scale; output[2] = -static_cast(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( ((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( (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( @@ -189,66 +202,84 @@ void pack_controls(uint8_t instance) { child.input.stick[2] = static_cast(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(g_source.battery) * 9u + 127u) / 255u; + const unsigned battery_level = (static_cast(pair.source.battery) * 9u + 127u) / 255u; child.pending_report[1] = static_cast((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(elapsed <= 0xfff ? elapsed : 1); child.pending_motion = motion_ready && probe_native_imu_pack( - g_motion.quaternion(), g_motion.acceleration(), static_cast(child.pending_ticks & 0xfff), + pair.motion.quaternion(), pair.motion.acceleration(), static_cast(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; diff --git a/tools/switch2_usb_probe/native_gamepad_input.h b/tools/switch2_usb_probe/native_gamepad_input.h index 3e8cd3d..fb467a4 100644 --- a/tools/switch2_usb_probe/native_gamepad_input.h +++ b/tools/switch2_usb_probe/native_gamepad_input.h @@ -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); diff --git a/tools/switch2_usb_probe/probe_build.cmake b/tools/switch2_usb_probe/probe_build.cmake index 22bf3b6..4bd0904 100644 --- a/tools/switch2_usb_probe/probe_build.cmake +++ b/tools/switch2_usb_probe/probe_build.cmake @@ -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=$ + 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=$) +add_compile_definitions(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) diff --git a/tools/switch2_usb_probe/storage.cpp b/tools/switch2_usb_probe/storage.cpp index a8a1b24..bcc2750 100644 --- a/tools/switch2_usb_probe/storage.cpp +++ b/tools/switch2_usb_probe/storage.cpp @@ -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(&__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(kRightStorageOffset - instance * kStorageSize); +#else return probe_model_is_left(instance) ? kLeftStorageOffset : kRightStorageOffset; +#endif } diff --git a/tools/switch2_usb_probe/storage.h b/tools/switch2_usb_probe/storage.h index c3e335a..2b5b5e6 100644 --- a/tools/switch2_usb_probe/storage.h +++ b/tools/switch2_usb_probe/storage.h @@ -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 diff --git a/tools/switch2_usb_probe/transport.h b/tools/switch2_usb_probe/transport.h index 3487ed3..867887e 100644 --- a/tools/switch2_usb_probe/transport.h +++ b/tools/switch2_usb_probe/transport.h @@ -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 diff --git a/tools/switch2_usb_probe/tusb_config.h b/tools/switch2_usb_probe/tusb_config.h index 066137c..ef1b1ce 100644 --- a/tools/switch2_usb_probe/tusb_config.h +++ b/tools/switch2_usb_probe/tusb_config.h @@ -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 }