feat(native-usb): route gameplay rumble and attach only after startup readiness
This commit is contained in:
parent
9f8678af96
commit
ae1ca51b9a
18 changed files with 1158 additions and 43 deletions
70
README.md
70
README.md
|
|
@ -886,6 +886,15 @@ 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.
|
||||
|
||||
GAMEPAD/DUALSENSE builds from 0.101 translate native gameplay vibration into
|
||||
conventional source-driver rumble, including a Wii Remote used as a GAMEPAD
|
||||
source. Each virtual R/L half controls only its assigned source's right/weak
|
||||
or left/strong contribution; mono actuators combine those contributions.
|
||||
Source profile rumble gains apply. This does not require the optional DualSense
|
||||
HD-haptics experiment and does not reproduce HD carrier-wave frequencies.
|
||||
The separate JOYCON2 relay and dedicated Wii-IR builds retain their existing
|
||||
built-in sample support; this gameplay translation is for full-controller mode.
|
||||
|
||||
With the four private capture sets described below prepared, build separately:
|
||||
|
||||
```sh
|
||||
|
|
@ -1236,8 +1245,65 @@ 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.
|
||||
That 0.100 image did not implement gameplay rumble: HID output reports were
|
||||
logged, while built-in vibration samples used a separate cue path.
|
||||
|
||||
**0.101 gameplay-rumble candidate:** native Output Report `0x01` now reaches the
|
||||
existing source-driver scheduler in GAMEPAD/DUALSENSE mode. Interrupt reports
|
||||
including the ID and SET_REPORT payloads excluding it are normalized without
|
||||
copying their padding. The decoder requires a complete 16-byte LRA block and
|
||||
format `01`, preserves one to three samples, and converts the larger of the two
|
||||
10-bit amplitudes to a conventional 8-bit magnitude. Count-zero HOLD does not
|
||||
change output or refresh its watchdog; an explicit zero-amplitude sample stops
|
||||
only that side. Unknown formats, wrong IDs and truncated frames do not dispatch.
|
||||
|
||||
For compatibility playback, samples divide a 12 ms envelope; the last magnitude
|
||||
holds only until the 50 ms receipt deadline. The existing 5 ms rumble timer skips
|
||||
missed sample boundaries rather than replaying stale pulses. Physical drivers
|
||||
receive finite durations. New gameplay and built-in cue requests replace older
|
||||
work on the same side; local/profile feedback has priority and interrupted game
|
||||
output cannot resume later. Driver calls run outside the backend lock with
|
||||
source-generation and output-revision checks. Reset, suspend, disconnect and
|
||||
source reassignment retire affected work without stopping another pair.
|
||||
|
||||
The layout is grounded in [native report research](https://github.com/ndeadly/switch2_controller_research/blob/master/hid_reports.md#output-report-0x01),
|
||||
[SDL's Switch 2 encoder](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch2.c),
|
||||
and existing public Pro Controller USB blocks with the shared LRA layout.
|
||||
Host smoke coverage runs the actual HID callback, decoder and backend against
|
||||
instrumented source drivers; it checks independent pairs, side stops, watchdog
|
||||
expiry and reset/suspend cancellation. It does not qualify physical motor
|
||||
sensation, HD fidelity or console transport timing. The authorized 0.101 flash
|
||||
preserved persistent bytes; the user subsequently reported working rumble, but
|
||||
also second-player latency and failure to enumerate on the first cold connection
|
||||
to the Switch. HD reproduction is deferred. Picotool and management labels are
|
||||
synchronized from this candidate onward.
|
||||
|
||||
**0.102 attach-last startup candidate:** the native initializer previously
|
||||
forced the physical D+ pull-up on before configuring the controller/EP0,
|
||||
starting the Core 1 observer, publishing routing and installing the USB IRQ.
|
||||
The physical override made the later logical SIE pull-up write insufficient
|
||||
as an attach gate. A warm replug occurs after that initialization has finished.
|
||||
|
||||
Startup now forces the physical pull-up off while those dependencies initialize.
|
||||
After the observer is ready, address routing and IRQ handling are enabled, and
|
||||
the started/watchdog timestamp state is published, the physical pull-up is
|
||||
asserted last. Observer timeout leaves the device detached. No retry, arbitrary
|
||||
startup delay, USB identity replacement or storage change is introduced.
|
||||
|
||||
The host register model fails with the old initializer and passes with the new
|
||||
one for two/four children, traced/untraced builds, immediate/delayed observer
|
||||
readiness and timeout. It also services a root descriptor, SET_ADDRESS and the
|
||||
next descriptor from the first attach edge without replugging. This proves the
|
||||
software attach-before-ready defect; it is not a physical USB timing trace.
|
||||
After the authorized 0.102 flash preserved persistent bytes, the user reported
|
||||
that the cold-start connection now works. That is user qualification, not an
|
||||
instrumented electrical measurement or a long-run reliability claim.
|
||||
|
||||
The Pico is running 0.102. Gameplay rumble, USB runtime scheduling and player
|
||||
routing are unchanged by the startup correction. Second-player latency
|
||||
remains a separate open investigation: a 20-second concurrent PC capture showed
|
||||
roughly 231–232 reports/second across all four children, which does not establish
|
||||
equal physical input-to-display latency on the Switch.
|
||||
|
||||
**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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
#endif
|
||||
#include "configuration/configuration_service.h"
|
||||
#include "profile/profile_service.h"
|
||||
#if SWITCH2_BRIDGE_FULL_INPUT
|
||||
#include "profile/controller_profile_runtime.h"
|
||||
#endif
|
||||
#include <limits.h>
|
||||
#include <math.h>
|
||||
#include <stddef.h>
|
||||
|
|
@ -291,6 +294,9 @@ struct NativeGamepadCue {
|
|||
uint32_t started_ms = 0;
|
||||
uint8_t slot = 0xff;
|
||||
uint8_t sample_id = 0;
|
||||
// Nonzero selects host gameplay instead of the built-in sample vocabulary.
|
||||
uint8_t rumble_count = 0;
|
||||
uint8_t rumble_magnitude[3]{};
|
||||
int result = -1;
|
||||
bool active = false;
|
||||
bool consumed = false;
|
||||
|
|
@ -298,7 +304,7 @@ struct NativeGamepadCue {
|
|||
};
|
||||
|
||||
struct NativeGamepadMotorOutput {
|
||||
uint32_t deadline_ms = 0;
|
||||
uint32_t deadline_ms[2]{};
|
||||
uint8_t magnitude[2]{};
|
||||
bool owned = false;
|
||||
};
|
||||
|
|
@ -321,6 +327,10 @@ constexpr NativeGamepadCuePattern kNativeGamepadCuePatterns[8] = {
|
|||
{{120}, 1, 220},
|
||||
};
|
||||
constexpr uint32_t kNativeGamepadCueDeadlineMs = 2000;
|
||||
// One host block spans 12 ms; the last magnitude holds only until the 50 ms
|
||||
// receipt watchdog. ERM compatibility ignores carrier frequencies, not timing.
|
||||
constexpr uint32_t kNativeGameplayFrameMs = 12;
|
||||
constexpr uint32_t kNativeGameplayWatchdogMs = 50;
|
||||
#endif
|
||||
|
||||
|
||||
|
|
@ -498,6 +508,8 @@ struct NativeGamepadBinding {
|
|||
constexpr uint8_t kNativeChildCount = BLUEPAD32_NATIVE_PAIR_COUNT * 2;
|
||||
NativeGamepadBinding g_native_bindings[BLUEPAD32_NATIVE_PAIR_COUNT]{};
|
||||
NativeGamepadCue g_native_cues[kNativeChildCount]{};
|
||||
uint32_t g_native_output_revision[kNativeChildCount]{};
|
||||
uint32_t g_native_slot_epoch[kSlotCount]{};
|
||||
uint64_t g_next_native_token = 1;
|
||||
uni_hid_device_t* g_native_pending_devices[kSlotCount]{};
|
||||
NativeGamepadReportIngress g_native_reports[kSlotCount]{};
|
||||
|
|
@ -569,11 +581,26 @@ bool native_rumble_capable(const BackendSlot& slot, uint8_t side) {
|
|||
}
|
||||
|
||||
void cancel_native_cue_locked(NativeGamepadCue& cue) {
|
||||
if (cue.active || cue.result == 0)
|
||||
++g_native_output_revision[&cue - g_native_cues];
|
||||
cue.active = false;
|
||||
cue.result = -1;
|
||||
// The slot's last motor output remains owned until the timer replaces it.
|
||||
}
|
||||
|
||||
bool native_feedback_owns(const BackendSlot& slot, uint32_t now_ms) {
|
||||
return slot.feedback_pending || slot.pending_profile_feedback_count != 0 ||
|
||||
slot.profile_feedback.active ||
|
||||
(slot.feedback_until_ms != 0 &&
|
||||
static_cast<int32_t>(now_ms - slot.feedback_until_ms) < 0);
|
||||
}
|
||||
|
||||
void cancel_native_gameplay_locked(const BackendSlot& slot) {
|
||||
for (NativeGamepadCue& cue : g_native_cues)
|
||||
if (cue.rumble_count != 0 && cue.slot == &slot - g_slots)
|
||||
cancel_native_cue_locked(cue);
|
||||
}
|
||||
|
||||
void refresh_native_source_locked(uint8_t reselected_pair = 0xff) {
|
||||
uint8_t candidates[BLUEPAD32_NATIVE_PAIR_COUNT]{};
|
||||
uint8_t reserved = 0;
|
||||
|
|
@ -671,14 +698,18 @@ void refresh_native_source_locked(uint8_t reselected_pair = 0xff) {
|
|||
}
|
||||
|
||||
void retire_native_slot(uint8_t index) {
|
||||
++g_native_slot_epoch[index];
|
||||
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 = {};
|
||||
for (NativeGamepadCue& cue : g_native_cues) {
|
||||
if (cue.slot != index) continue;
|
||||
cancel_native_cue_locked(cue);
|
||||
cue = {};
|
||||
}
|
||||
g_slots[index].native_motion = {};
|
||||
g_slots[index].native_output = {};
|
||||
}
|
||||
|
|
@ -2099,6 +2130,9 @@ struct HotkeyDecision {
|
|||
void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms,
|
||||
uint8_t weak_magnitude,
|
||||
uint8_t strong_magnitude) {
|
||||
#if SWITCH2_BRIDGE_FULL_INPUT
|
||||
cancel_native_gameplay_locked(slot);
|
||||
#endif
|
||||
slot.feedback_pending = true;
|
||||
slot.pending_feedback = {
|
||||
slot.connection_generation, duration_ms, weak_magnitude,
|
||||
|
|
@ -2108,6 +2142,9 @@ void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms,
|
|||
|
||||
void queue_profile_feedback(BackendSlot& slot,
|
||||
const ProfileFeedbackEnvelope& feedback) {
|
||||
#if SWITCH2_BRIDGE_FULL_INPUT
|
||||
cancel_native_gameplay_locked(slot);
|
||||
#endif
|
||||
if (slot.pending_profile_feedback_count < kProfileFeedbackQueueCapacity) {
|
||||
slot.pending_profile_feedback[
|
||||
slot.pending_profile_feedback_count++] = feedback;
|
||||
|
|
@ -2925,6 +2962,8 @@ struct NativeGamepadCueDispatch {
|
|||
uint32_t connection_generation = 0;
|
||||
uint32_t prepared_ms = 0;
|
||||
uint64_t token[2]{};
|
||||
uint32_t revision[2]{};
|
||||
uint32_t slot_epoch = 0;
|
||||
uint16_t duration_ms = 0;
|
||||
uint8_t magnitude[2]{};
|
||||
uint8_t slot = 0xff;
|
||||
|
|
@ -2948,11 +2987,13 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms,
|
|||
NativeGamepadCue& cue = g_native_cues[pair * 2 + side];
|
||||
if (cue.slot != index) continue;
|
||||
if (!native_cue_current(pair, cue) ||
|
||||
(cue.result == 0 && now_ms - cue.requested_ms >= kNativeGamepadCueDeadlineMs) ||
|
||||
(cue.active && now_ms - cue.started_ms >= kNativeGamepadCueDeadlineMs))
|
||||
(cue.rumble_count != 0
|
||||
? now_ms - cue.requested_ms >= kNativeGameplayWatchdogMs
|
||||
: ((cue.result == 0 && now_ms - cue.requested_ms >= kNativeGamepadCueDeadlineMs) ||
|
||||
(cue.active && now_ms - cue.started_ms >= kNativeGamepadCueDeadlineMs))))
|
||||
cancel_native_cue_locked(cue);
|
||||
if (local_active || local_dispatch) {
|
||||
if (cue.active) cancel_native_cue_locked(cue);
|
||||
if (cue.active || cue.rumble_count != 0) cancel_native_cue_locked(cue);
|
||||
busy |= cue.result == 0;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -2960,6 +3001,19 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms,
|
|||
if (cue.result != 0 && !cue.active) continue;
|
||||
command->token[side] = cue.token;
|
||||
pending |= cue.result == 0;
|
||||
if (cue.rumble_count != 0) {
|
||||
const uint32_t elapsed = now_ms - cue.requested_ms;
|
||||
const uint32_t phase_ms = kNativeGameplayFrameMs / cue.rumble_count;
|
||||
const uint8_t phase = elapsed >= kNativeGameplayFrameMs
|
||||
? cue.rumble_count - 1 : static_cast<uint8_t>(elapsed / phase_ms);
|
||||
magnitude[side] = cue.rumble_magnitude[phase];
|
||||
const uint32_t boundary = phase + 1u < cue.rumble_count
|
||||
? (phase + 1u) * phase_ms : kNativeGameplayWatchdogMs;
|
||||
const uint16_t remaining = static_cast<uint16_t>(boundary - elapsed);
|
||||
if (magnitude[side] != 0 && remaining < duration) duration = remaining;
|
||||
busy = true;
|
||||
continue;
|
||||
}
|
||||
if (cue.sample_id == 0) continue;
|
||||
const NativeGamepadCuePattern& pattern = kNativeGamepadCuePatterns[cue.sample_id];
|
||||
uint32_t elapsed = cue.active ? now_ms - cue.started_ms : 0;
|
||||
|
|
@ -2987,7 +3041,8 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms,
|
|||
const uint32_t deadline = duration == 0 ? 0 : now_ms + duration;
|
||||
const bool changed = magnitude[0] != previous.magnitude[0] ||
|
||||
magnitude[1] != previous.magnitude[1] ||
|
||||
(duration != 0 && deadline != previous.deadline_ms);
|
||||
(magnitude[0] != 0 && deadline != previous.deadline_ms[0]) ||
|
||||
(magnitude[1] != 0 && deadline != previous.deadline_ms[1]);
|
||||
if (!pending && !changed) return busy || previous.owned;
|
||||
if (!slot.active || slot.device == nullptr ||
|
||||
(!native_rumble_capable(slot, 0) && !native_rumble_capable(slot, 1))) {
|
||||
|
|
@ -3005,6 +3060,10 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms,
|
|||
command->magnitude[1] = magnitude[1];
|
||||
command->slot = index;
|
||||
command->pair = pair;
|
||||
command->slot_epoch = g_native_slot_epoch[index];
|
||||
if (pair != 0xff)
|
||||
for (uint8_t side = 0; side < 2; ++side)
|
||||
command->revision[side] = g_native_output_revision[pair * 2 + side];
|
||||
for (uint8_t side = 0; side < 2; ++side)
|
||||
if (command->token[side] != 0) g_native_cues[pair * 2 + side].in_flight = true;
|
||||
return true;
|
||||
|
|
@ -3018,6 +3077,8 @@ bool submit_native_rumble(uni_hid_device_t* device, uint16_t duration,
|
|||
device->report_parser.parse_input_report == uni_hid_parser_ds5_parse_input_report) {
|
||||
return uni_hid_parser_ds5_bridge_rumble(device, duration, right, left);
|
||||
}
|
||||
if (device->controller_type == CONTROLLER_TYPE_WiiController &&
|
||||
!uni_hid_parser_wii_rumble_ready(device)) return false;
|
||||
// Existing finite-duration dispatch is the strongest observable result
|
||||
// most drivers expose. It is not transport acceptance or a remote ACK.
|
||||
dispatch_rumble(device, duration, right, left);
|
||||
|
|
@ -3034,9 +3095,15 @@ void dispatch_native_cues(const NativeGamepadCueDispatch& command) {
|
|||
state_lock_enter();
|
||||
bool current = slot.active && slot.device == command.device &&
|
||||
slot.companion == command.companion &&
|
||||
slot.connection_generation == command.connection_generation;
|
||||
slot.connection_generation == command.connection_generation &&
|
||||
g_native_slot_epoch[command.slot] == command.slot_epoch &&
|
||||
!native_feedback_owns(slot, btstack_run_loop_get_time_ms());
|
||||
for (uint8_t side = 0; side < 2; ++side) {
|
||||
if ((paired && side != target) || command.token[side] == 0) continue;
|
||||
if (paired && side != target) continue;
|
||||
if (command.pair != 0xff)
|
||||
current &= command.revision[side] ==
|
||||
g_native_output_revision[command.pair * 2 + side];
|
||||
if (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);
|
||||
|
|
@ -3063,18 +3130,18 @@ void dispatch_native_cues(const NativeGamepadCueDispatch& command) {
|
|||
|
||||
state_lock_enter();
|
||||
if (slot.active && slot.device == command.device &&
|
||||
slot.companion == command.companion) {
|
||||
slot.companion == command.companion &&
|
||||
g_native_slot_epoch[command.slot] == command.slot_epoch) {
|
||||
// Retain each actual submission even if USB canceled/reselected
|
||||
// during its driver call, or the other half cannot be submitted.
|
||||
NativeGamepadMotorOutput& output = slot.native_output;
|
||||
if (paired) output.magnitude[target] = command.magnitude[target];
|
||||
else {
|
||||
output.magnitude[0] = command.magnitude[0];
|
||||
output.magnitude[1] = command.magnitude[1];
|
||||
for (uint8_t side = 0; side < 2; ++side) {
|
||||
if (paired && side != target) continue;
|
||||
output.magnitude[side] = command.magnitude[side];
|
||||
output.deadline_ms[side] = command.magnitude[side] != 0
|
||||
? command.prepared_ms + command.duration_ms : 0;
|
||||
}
|
||||
output.owned = (output.magnitude[0] | output.magnitude[1]) != 0;
|
||||
output.deadline_ms = output.owned
|
||||
? command.prepared_ms + command.duration_ms : 0;
|
||||
}
|
||||
state_lock_exit();
|
||||
}
|
||||
|
|
@ -3085,12 +3152,15 @@ void dispatch_native_cues(const NativeGamepadCueDispatch& command) {
|
|||
if (cue.token != command.token[side]) continue;
|
||||
cue.in_flight = false;
|
||||
if (!native_cue_current(command.pair, cue) ||
|
||||
(cue.result == 0 && dispatch_ms - cue.requested_ms >= kNativeGamepadCueDeadlineMs)) {
|
||||
(cue.result == 0 && dispatch_ms - cue.requested_ms >=
|
||||
(cue.rumble_count != 0 ? kNativeGameplayWatchdogMs : kNativeGamepadCueDeadlineMs))) {
|
||||
cancel_native_cue_locked(cue);
|
||||
} else if (submitted[side] && cue.result == 0) {
|
||||
cue.result = 1; // Accepted source submission, never a native ACK.
|
||||
cue.started_ms = command.prepared_ms;
|
||||
cue.active = cue.sample_id != 0;
|
||||
if (cue.rumble_count == 0) {
|
||||
cue.started_ms = command.prepared_ms;
|
||||
cue.active = cue.sample_id != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
state_lock_exit();
|
||||
|
|
@ -3321,6 +3391,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
|
|||
}
|
||||
|
||||
const bool feedback_active =
|
||||
slot.feedback_until_ms != 0 &&
|
||||
static_cast<int32_t>(now_ms - slot.feedback_until_ms) < 0;
|
||||
if (!slot.profile_feedback.active && !feedback_active &&
|
||||
slot.pending_profile_feedback_count != 0) {
|
||||
|
|
@ -3386,8 +3457,8 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
|
|||
|
||||
const bool local_feedback_active =
|
||||
slot.profile_feedback.active ||
|
||||
static_cast<int32_t>(
|
||||
now_ms - slot.feedback_until_ms) < 0;
|
||||
(slot.feedback_until_ms != 0 && static_cast<int32_t>(
|
||||
now_ms - slot.feedback_until_ms) < 0);
|
||||
#ifdef SWITCH2_BRIDGE_WII_INPUT
|
||||
const bool wii_cue_owns_rumble = prepare_wii_cue(
|
||||
slot_index, now_ms, local_feedback_active,
|
||||
|
|
@ -4847,6 +4918,75 @@ void bluepad32_input_backend_native_snapshot(
|
|||
state_lock_exit();
|
||||
}
|
||||
|
||||
bool bluepad32_input_backend_native_rumble_submit(
|
||||
uint8_t instance, const uint8_t* magnitudes, uint8_t count) {
|
||||
if (!g_initialized || instance >= kNativeChildCount || magnitudes == nullptr ||
|
||||
count == 0 || count > 3) return false;
|
||||
const uint32_t received_ms = btstack_run_loop_get_time_ms();
|
||||
uint8_t samples[3]{};
|
||||
memcpy(samples, magnitudes, count);
|
||||
state_lock_enter();
|
||||
const NativeGamepadBinding& binding = g_native_bindings[instance / 2];
|
||||
const uint8_t index = binding.slot;
|
||||
if (index >= kSlotCount || !g_slots[index].active ||
|
||||
g_slots[index].connection_generation != binding.generation ||
|
||||
!native_rumble_capable(g_slots[index], instance & 1u) ||
|
||||
native_feedback_owns(g_slots[index], received_ms)) {
|
||||
state_lock_exit();
|
||||
return false;
|
||||
}
|
||||
const BackendSlot& slot = g_slots[index];
|
||||
const Bluepad32SlotSnapshot snapshot{
|
||||
slot.active, slot.connection_generation, slot.identity,
|
||||
slot.pre_hotkey_button_mask, slot.state, slot.accelerometer,
|
||||
slot.nunchuk_accelerometer};
|
||||
const uint32_t revision = g_native_output_revision[instance];
|
||||
state_lock_exit();
|
||||
|
||||
// Profile resolution may call services: never hold the backend lock over
|
||||
// it. Full-scale input obtains the existing weak/strong gain once per block.
|
||||
const ControllerRumbleOutput gains = controller_profile_runtime_scale_host_rumble(
|
||||
index, snapshot, ControllerRumbleOutput{UINT8_MAX, UINT8_MAX});
|
||||
const uint8_t gain = (instance & 1u) == 0
|
||||
? gains.high_frequency_magnitude : gains.low_frequency_magnitude;
|
||||
for (uint8_t sample = 0; sample < count; ++sample)
|
||||
samples[sample] = controller_profile_scale_rumble_magnitude(samples[sample], gain);
|
||||
|
||||
state_lock_enter();
|
||||
const uint32_t now_ms = btstack_run_loop_get_time_ms();
|
||||
const bool accepted = binding.slot == index && slot.active &&
|
||||
binding.generation == snapshot.connection_generation &&
|
||||
slot.connection_generation == snapshot.connection_generation &&
|
||||
g_native_output_revision[instance] == revision && g_next_native_token != 0 &&
|
||||
native_rumble_capable(slot, instance & 1u) &&
|
||||
!native_feedback_owns(slot, now_ms) &&
|
||||
now_ms - received_ms < kNativeGameplayWatchdogMs;
|
||||
if (accepted) {
|
||||
++g_native_output_revision[instance];
|
||||
NativeGamepadCue& cue = g_native_cues[instance];
|
||||
cue = {};
|
||||
cue.token = g_next_native_token++;
|
||||
cue.slot = index;
|
||||
cue.connection_generation = snapshot.connection_generation;
|
||||
cue.requested_ms = received_ms;
|
||||
cue.rumble_count = count;
|
||||
memcpy(cue.rumble_magnitude, samples, count);
|
||||
cue.result = 0;
|
||||
cue.active = true;
|
||||
__atomic_add_fetch(&g_host_rumble_requests, 1, __ATOMIC_RELAXED);
|
||||
}
|
||||
state_lock_exit();
|
||||
return accepted;
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_native_rumble_cancel(uint8_t instance) {
|
||||
if (!g_initialized || instance >= kNativeChildCount) return;
|
||||
state_lock_enter();
|
||||
NativeGamepadCue& cue = g_native_cues[instance];
|
||||
if (cue.rumble_count != 0) cancel_native_cue_locked(cue);
|
||||
state_lock_exit();
|
||||
}
|
||||
|
||||
bool bluepad32_input_backend_native_sample_request(
|
||||
uint8_t instance, uint8_t sample_id, uint64_t* token) {
|
||||
if (token == nullptr) return false;
|
||||
|
|
@ -4857,9 +4997,10 @@ bool bluepad32_input_backend_native_sample_request(
|
|||
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 & 1u) &&
|
||||
!cue.in_flight && (sample_id == 0 || (cue.result != 0 && !cue.active));
|
||||
g_slots[index].active && g_slots[index].connection_generation == binding.generation &&
|
||||
native_rumble_capable(g_slots[index], instance & 1u);
|
||||
if (accepted) {
|
||||
++g_native_output_revision[instance];
|
||||
cue = {};
|
||||
cue.token = g_next_native_token++;
|
||||
cue.slot = index;
|
||||
|
|
@ -4893,7 +5034,8 @@ 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 >= kNativeChildCount) return;
|
||||
state_lock_enter();
|
||||
cancel_native_cue_locked(g_native_cues[instance]);
|
||||
NativeGamepadCue& cue = g_native_cues[instance];
|
||||
if (cue.rumble_count == 0) cancel_native_cue_locked(cue);
|
||||
state_lock_exit();
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -154,6 +154,15 @@ bool bluepad32_input_backend_native_sample_request(
|
|||
uint8_t instance, uint8_t sample_id, uint64_t* token);
|
||||
int bluepad32_input_backend_native_sample_result(uint8_t instance, uint64_t token);
|
||||
void bluepad32_input_backend_native_sample_cancel(uint8_t instance);
|
||||
// Gameplay blocks replace older gameplay/cues only on the addressed side.
|
||||
// Samples divide a 12 ms frame; the last holds up to a 50 ms receipt watchdog.
|
||||
// Magnitudes use that source profile's weak/right or strong/left host gain.
|
||||
// Submission copies 1..3 samples; false means invalid/unbound/unsupported,
|
||||
// higher-priority local feedback, or a source change during profile resolution.
|
||||
// Cancellation retires gameplay only; callers resetting a child also cancel its cue.
|
||||
bool bluepad32_input_backend_native_rumble_submit(
|
||||
uint8_t instance, const uint8_t* magnitudes, uint8_t count);
|
||||
void bluepad32_input_backend_native_rumble_cancel(uint8_t instance);
|
||||
#endif
|
||||
|
||||
// Side-effect-free raw input snapshot for management telemetry. Unlike the
|
||||
|
|
|
|||
|
|
@ -1294,7 +1294,9 @@ bool native_hub_init(void) {
|
|||
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);
|
||||
// GPIO-observer mode needs the physical override, but asserting it here
|
||||
// would advertise attachment before the SIE, router and IRQ are ready.
|
||||
hw_clear_bits(&usb_hw->phy_direct,USB_USBPHY_DIRECT_DP_PULLUP_EN_BITS);
|
||||
hw_set_bits(&usb_hw->phy_direct_override,USB_USBPHY_DIRECT_OVERRIDE_DP_PULLUP_EN_OVERRIDE_EN_BITS);
|
||||
usb_hw->pwr = USB_USB_PWR_VBUS_DETECT_BITS | USB_USB_PWR_VBUS_DETECT_OVERRIDE_EN_BITS;
|
||||
usb_hw->main_ctrl = USB_MAIN_CTRL_CONTROLLER_EN_BITS;
|
||||
|
|
@ -1316,8 +1318,12 @@ bool native_hub_init(void) {
|
|||
#endif
|
||||
irq_set_priority(USBCTRL_IRQ,0);
|
||||
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();
|
||||
__dmb();
|
||||
hw_set_bits(&usb_hw->sie_ctrl,USB_SIE_CTRL_PULLUP_EN_BITS);
|
||||
// The forced physical pull-up is the actual attach edge. Publish it last:
|
||||
// even an immediate host reset/SETUP now has an initialized receiver.
|
||||
hw_set_bits(&usb_hw->phy_direct,USB_USBPHY_DIRECT_DP_PULLUP_EN_BITS);
|
||||
#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
|
||||
|
|
|
|||
|
|
@ -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, 100, 0, 2,
|
||||
0, 102, 0, 2,
|
||||
kNativeHubActiveMode,
|
||||
USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE |
|
||||
USB_OUTPUT_CAPABILITY_MOTION,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,29 @@
|
|||
// Reuse the backend's transport/storage fixture; these scenarios exercise only
|
||||
// the native gamepad contract, not a second implementation of its scheduler.
|
||||
#include <uni.h>
|
||||
extern "C" bool uni_hid_parser_wii_rumble_ready(uni_hid_device_t*);
|
||||
#define profile_service_active_profile_snapshot fixture_active_profile_snapshot
|
||||
#define main backend_fixture_main
|
||||
#include "bluepad32_backend_lifecycle_test.cpp"
|
||||
#undef main
|
||||
#undef profile_service_active_profile_snapshot
|
||||
|
||||
namespace {
|
||||
void (*during_profile_resolution)() = nullptr;
|
||||
bool native_wii_ready = true;
|
||||
}
|
||||
|
||||
void profile_service_active_profile_snapshot(
|
||||
const ControllerIdentity& identity, ProfileServiceActiveProfileSnapshot* output) {
|
||||
require(state_lock_depth == 0, "profile service callbacks must not hold the backend lock");
|
||||
if (during_profile_resolution) during_profile_resolution();
|
||||
fixture_active_profile_snapshot(identity, output);
|
||||
}
|
||||
|
||||
extern "C" bool uni_hid_parser_wii_rumble_ready(uni_hid_device_t*) {
|
||||
require(state_lock_depth == 0, "Wii readiness must not hold the backend lock");
|
||||
return native_wii_ready;
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct SensorFixture {
|
||||
|
|
@ -600,6 +621,316 @@ void mono_rumble() {
|
|||
"retired mono work must never enter a replacement connection");
|
||||
}
|
||||
|
||||
void gameplay_timeline() {
|
||||
start_pairing_backend();
|
||||
auto pad = dualsense(0);
|
||||
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "gameplay source must connect");
|
||||
uint8_t right[] = {40, 80, 120};
|
||||
const uint8_t left[] = {60, 180};
|
||||
const uint8_t stop = 0;
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, right, 3) &&
|
||||
bluepad32_input_backend_native_rumble_submit(1, left, 2),
|
||||
"mixed sample counts must be accepted independently");
|
||||
right[0] = 255;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 40 && pad.last_low == 60 && pad.last_rumble_duration_ms == 4,
|
||||
"copied right/weak and left/strong samples share the earliest finite boundary");
|
||||
now_ms = 5;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 80 && pad.last_low == 60 && pad.last_rumble_duration_ms == 1,
|
||||
"5 ms polling skips elapsed time rather than replaying the first sample");
|
||||
now_ms = 10;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 120 && pad.last_low == 180 && pad.last_rumble_duration_ms == 40,
|
||||
"last samples hold only to their original receipt watchdog");
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, &stop, 1),
|
||||
"explicit zero magnitude must be a valid side stop");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 0 && pad.last_low == 180 && pad.last_rumble_duration_ms == 40,
|
||||
"right stop must preserve the left contribution and deadline");
|
||||
now_ms = 49;
|
||||
const int calls = pad.rumble_calls;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.rumble_calls == calls, "unchanged finite holds must not produce duplicate writes");
|
||||
now_ms = 50;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_rumble_duration_ms == 0, "watchdog expiry must stop without further host packets");
|
||||
now_ms = 500;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
const int stopped = pad.rumble_calls;
|
||||
const uint8_t delayed[] = {21, 42, 84};
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, delayed, 3), "delayed block must queue");
|
||||
now_ms = 520;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.rumble_calls == stopped + 1 && pad.last_high == 84 &&
|
||||
pad.last_rumble_duration_ms == 30,
|
||||
"a stalled timer dispatches only the current sample with its remaining lifetime");
|
||||
bluepad32_input_backend_native_rumble_cancel(0);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_rumble_duration_ms == 0, "explicit cancellation must stop its live hold");
|
||||
now_ms = UINT32_MAX - 9u;
|
||||
const uint8_t pulse = 99;
|
||||
require(bluepad32_input_backend_native_rumble_submit(1, &pulse, 1), "pre-wrap request must queue");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_low == 99 && pad.last_rumble_duration_ms == 50,
|
||||
"finite host lifetime must remain valid before clock wrap");
|
||||
now_ms = 39;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_low == 99, "wrap must not cause early watchdog expiry");
|
||||
now_ms = 40;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_rumble_duration_ms == 0, "watchdog must expire exactly across clock wrap");
|
||||
}
|
||||
|
||||
void gameplay_availability() {
|
||||
start_pairing_backend();
|
||||
auto pad = dualsense(0);
|
||||
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "busy gameplay source must connect");
|
||||
const uint8_t old[] = {10, 20, 30};
|
||||
const uint8_t newest[] = {70, 140};
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, old, 3), "old block must queue");
|
||||
dualsense_transport_available = false;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
now_ms = 5;
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, newest, 2), "busy source must retain a new block");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.rumble_calls == 0, "driver rejection cannot count as a successful output");
|
||||
now_ms = 15;
|
||||
dualsense_transport_available = true;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.rumble_calls == 1 && pad.last_high == 140 && pad.last_rumble_duration_ms == 40,
|
||||
"driver recovery must dispatch only the newest current phase, not old samples");
|
||||
const uint8_t next = 210;
|
||||
during_dualsense_dispatch = [] {
|
||||
const uint8_t replacement = 33;
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, &replacement, 1),
|
||||
"gameplay must replace work while an earlier driver call is in flight");
|
||||
};
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, &next, 1), "dispatch race must queue");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
during_dualsense_dispatch = nullptr;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 33 && pad.last_rumble_duration_ms == 50,
|
||||
"older dispatch completion cannot consume a newly accepted revision");
|
||||
bluepad32_input_backend_native_rumble_cancel(0);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
const int stopped = pad.rumble_calls;
|
||||
dualsense_transport_available = false;
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, old, 3), "stale block must queue");
|
||||
now_ms += 50;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
dualsense_transport_available = true;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.rumble_calls == stopped, "expired rejected work must never replay after driver recovery");
|
||||
}
|
||||
|
||||
void gameplay_priority() {
|
||||
start_pairing_backend();
|
||||
auto pad = dualsense(0);
|
||||
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "priority source must connect");
|
||||
report_dualsense(pad);
|
||||
const uint8_t game = 45;
|
||||
uint64_t old_cue, new_cue;
|
||||
require(bluepad32_input_backend_native_sample_request(0, 1, &old_cue) &&
|
||||
bluepad32_input_backend_native_rumble_submit(0, &game, 1),
|
||||
"gameplay must replace a pending cue on the same side");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 45 &&
|
||||
bluepad32_input_backend_native_sample_result(0, old_cue) == -1,
|
||||
"replaced cue cannot dispatch or complete after gameplay");
|
||||
require(bluepad32_input_backend_native_sample_request(0, 6, &new_cue),
|
||||
"a new cue must replace live gameplay");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 96 && pad.last_rumble_duration_ms == 60 &&
|
||||
bluepad32_input_backend_native_sample_result(0, new_cue) == 1,
|
||||
"built-in cue completion must retain its driver-dispatch semantics");
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1) &&
|
||||
bluepad32_input_backend_native_rumble_submit(1, &game, 1),
|
||||
"fresh gameplay must replace the playing cue");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
const auto source = bridge_snapshot();
|
||||
bluepad32_input_backend_queue_profile_feedback(source.slot,
|
||||
source.controller.connection_generation, 1, ControllerProfileConfirmationPolicy::kRumble);
|
||||
require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
|
||||
"queued higher-priority feedback must not admit gameplay for later replay");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == UINT8_MAX && pad.last_low == UINT8_MAX &&
|
||||
pad.last_rumble_duration_ms == 75, "profile feedback must own the only motor writer");
|
||||
now_ms = 150;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
const int after_feedback = pad.rumble_calls;
|
||||
now_ms = 155;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.rumble_calls == after_feedback, "profile completion must never resurrect interrupted gameplay");
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "fresh post-feedback gameplay must resume");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(bluepad32_input_backend_toggle_motion(source.slot, source.controller.connection_generation),
|
||||
"motion feedback must queue through the existing local-feedback path");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
const int local_calls = pad.rumble_calls;
|
||||
now_ms += 3000;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.rumble_calls == local_calls, "local feedback must cancel rather than retain interrupted gameplay");
|
||||
}
|
||||
|
||||
void gameplay_source_epochs() {
|
||||
start_pairing_backend();
|
||||
auto pad = dualsense(0);
|
||||
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "epoch source must connect");
|
||||
const uint8_t game = 170;
|
||||
require(!bluepad32_input_backend_native_rumble_submit(0, nullptr, 1) &&
|
||||
!bluepad32_input_backend_native_rumble_submit(0, &game, 0) &&
|
||||
!bluepad32_input_backend_native_rumble_submit(0, &game, 4) &&
|
||||
!bluepad32_input_backend_native_rumble_submit(PROBE_CONTROLLER_COUNT, &game, 1),
|
||||
"invalid gameplay frames must fail before dispatch");
|
||||
pad.report_parser.play_dual_rumble = nullptr;
|
||||
require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
|
||||
"source without a rumble driver must reject gameplay");
|
||||
pad.report_parser.play_dual_rumble = observe_dualsense_rumble;
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "epoch block must queue");
|
||||
during_dualsense_dispatch = [] { bluepad32_input_backend_select_native_source(0, nullptr); };
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
during_dualsense_dispatch = nullptr;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_rumble_duration_ms == 0, "same-source reselection must stop an in-flight retired epoch");
|
||||
require(bluepad32_input_backend_native_rumble_submit(1, &game, 1), "disconnect block must queue");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
platform_on_device_disconnected(&pad);
|
||||
require(pad.last_rumble_duration_ms == 0 &&
|
||||
!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
|
||||
"disconnect must retire the driver's finite timer and refuse new work");
|
||||
pad = dualsense(0);
|
||||
platform_on_device_connected(&pad);
|
||||
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "same-address slot replacement must connect");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.rumble_calls == 0, "slot memory reuse cannot inherit old samples or stop obligations");
|
||||
controller_profile_runtime_reset();
|
||||
during_profile_resolution = [] { bluepad32_input_backend_select_native_source(0, nullptr); };
|
||||
require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
|
||||
"source generation must be rechecked after unlocked profile callbacks");
|
||||
during_profile_resolution = nullptr;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.rumble_calls == 0, "a profile-resolution race cannot reach the replacement epoch");
|
||||
}
|
||||
|
||||
void gameplay_profile_gain() {
|
||||
start_pairing_backend();
|
||||
initialize_runtime_profile_storage();
|
||||
auto pad = dualsense(0);
|
||||
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "profile source must connect");
|
||||
const auto identity = identity_for_device(&pad);
|
||||
auto profile = controller_profile_default(identity, 0);
|
||||
profile.weak_rumble_scale = 128;
|
||||
profile.strong_rumble_scale = 64;
|
||||
require(runtime_profile_storage.set(identity, 0, profile) == ProfileStorageResult::kOk,
|
||||
"profile must configure independent host motor gains");
|
||||
const uint8_t maximum = 255;
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, &maximum, 1) &&
|
||||
bluepad32_input_backend_native_rumble_submit(1, &maximum, 1), "scaled gameplay must queue");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 128 && pad.last_low == 64,
|
||||
"right/weak and left/strong magnitudes must use their matching persisted profile gains");
|
||||
profile.weak_rumble_scale = 0;
|
||||
profile.strong_rumble_scale = 0;
|
||||
require(runtime_profile_storage.set(identity, 0, profile) == ProfileStorageResult::kOk,
|
||||
"profile mute must update its generation");
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, &maximum, 1) &&
|
||||
bluepad32_input_backend_native_rumble_submit(1, &maximum, 1), "muted gameplay must queue");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 0 && pad.last_low == 0 && pad.last_rumble_duration_ms == 0,
|
||||
"muting a profile must stop live host output rather than retaining unscaled samples");
|
||||
uint64_t cue;
|
||||
require(bluepad32_input_backend_native_sample_request(0, 7, &cue), "muted profile still permits local cues");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 220 && bluepad32_input_backend_native_sample_result(0, cue) == 1,
|
||||
"host gain must not scale built-in local confirmation cues");
|
||||
}
|
||||
|
||||
void gameplay_two_pairs() {
|
||||
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 gameplay pairs must connect");
|
||||
const uint8_t values[] = {31, 62, 93, 124};
|
||||
for (uint8_t instance = 0; instance < 4; ++instance)
|
||||
require(bluepad32_input_backend_native_rumble_submit(instance, values + instance, 1),
|
||||
"each gameplay child must bind its own pair");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(first.last_high == 31 && first.last_low == 62 &&
|
||||
second.last_high == 93 && second.last_low == 124,
|
||||
"four logical contributions must route to two independent physical sources");
|
||||
bluepad32_input_backend_native_rumble_cancel(0);
|
||||
now_ms = 10;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(first.last_high == 0 && first.last_low == 62 && first.last_rumble_duration_ms == 40 &&
|
||||
second.rumble_calls == 1, "side cancellation cannot dispatch into another pair");
|
||||
bluepad32_input_backend_select_native_source(0, nullptr);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(first.last_rumble_duration_ms == 0 && second.rumble_calls == 1,
|
||||
"source reselection must retire only its own pair's output");
|
||||
platform_on_device_disconnected(&first);
|
||||
now_ms = 50;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(second.last_rumble_duration_ms == 0 && second.rumble_calls == 2,
|
||||
"the surviving pair must expire on its original independent watchdog");
|
||||
}
|
||||
|
||||
void gameplay_paired_revision() {
|
||||
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);
|
||||
right.report_parser.play_dual_rumble = [](
|
||||
uni_hid_device_t* pad, uint16_t delay, uint16_t duration, uint8_t weak, uint8_t strong) {
|
||||
play_rumble(pad, delay, duration, weak, strong);
|
||||
const uint8_t fresh = 150;
|
||||
require(bluepad32_input_backend_native_rumble_submit(1, &fresh, 1),
|
||||
"right dispatch may accept a newer left revision");
|
||||
};
|
||||
const uint8_t game = 75;
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "paired gameplay must queue");
|
||||
const int previous_left = left.rumble_calls;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(right.last_high == 75 && right.last_low == 75 && left.rumble_calls == previous_left,
|
||||
"new left work must invalidate even a prepared zero-output command before left dispatch");
|
||||
right.report_parser.play_dual_rumble = play_rumble;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(left.last_high == 150 && left.last_low == 150,
|
||||
"the newer paired-side revision must remain pending until its real driver dispatch");
|
||||
const uint8_t stop = 0;
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, &stop, 1), "paired right stop must queue");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(right.last_rumble_duration_ms == 0 && left.last_high == 150 &&
|
||||
left.last_rumble_duration_ms == 50, "paired stop must preserve only the live sibling contribution");
|
||||
}
|
||||
|
||||
void gameplay_wii() {
|
||||
start_pairing_backend();
|
||||
auto remote = wii_device(0);
|
||||
remote.report_parser.play_dual_rumble = observe_mono_rumble;
|
||||
require(platform_on_device_ready(&remote) == UNI_ERROR_SUCCESS, "Wii GAMEPAD source must connect");
|
||||
const uint8_t right = 70, left = 140;
|
||||
require(bluepad32_input_backend_native_rumble_submit(0, &right, 1) &&
|
||||
bluepad32_input_backend_native_rumble_submit(1, &left, 1), "Wii contributions must queue");
|
||||
native_wii_ready = false;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(remote.rumble_calls == 0, "Wii topology setup must not consume pending output");
|
||||
native_wii_ready = true;
|
||||
now_ms = 10;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(mono_magnitude == 140 && remote.last_rumble_duration_ms == 40,
|
||||
"Wii compatibility combines both contributions on its finite mono motor");
|
||||
bluepad32_input_backend_native_rumble_cancel(1);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(mono_magnitude == 70 && remote.last_rumble_duration_ms == 40,
|
||||
"Wii side cancellation must not stop its sibling's mono contribution");
|
||||
now_ms = 50;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(mono_magnitude == 0, "Wii gameplay must stop at its original watchdog");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t*, const uint8_t*, uint16_t) {}
|
||||
|
|
@ -993,6 +1324,14 @@ int main(int argc, char** argv) {
|
|||
if (scenario == "source-isolation") source_isolation();
|
||||
else if (scenario == "cue-lifetime") cue_lifetime();
|
||||
else if (scenario == "cue-races") cue_races();
|
||||
else if (scenario == "gameplay-timeline") gameplay_timeline();
|
||||
else if (scenario == "gameplay-availability") gameplay_availability();
|
||||
else if (scenario == "gameplay-priority") gameplay_priority();
|
||||
else if (scenario == "gameplay-source-epochs") gameplay_source_epochs();
|
||||
else if (scenario == "gameplay-profile-gain") gameplay_profile_gain();
|
||||
else if (scenario == "gameplay-two-pairs") gameplay_two_pairs();
|
||||
else if (scenario == "gameplay-paired-revision") gameplay_paired_revision();
|
||||
else if (scenario == "gameplay-wii") gameplay_wii();
|
||||
else if (scenario == "stable-logical-slot") stable_logical_slot();
|
||||
else if (scenario == "sensorless-admission") sensorless_admission();
|
||||
else if (scenario == "independent-motion") independent_motion();
|
||||
|
|
|
|||
270
tests/native_hub_startup_test.c
Normal file
270
tests/native_hub_startup_test.c
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "hardware_stub.h"
|
||||
|
||||
static void startup_set_bits(volatile uint32_t* address, uint32_t bits);
|
||||
static void startup_clear_bits(volatile uint32_t* address, uint32_t bits);
|
||||
static void startup_reset(uint32_t mask);
|
||||
static void startup_launch(void (*entry)(void));
|
||||
static void startup_irq_handler(unsigned irq, void (*handler)(void));
|
||||
static void startup_irq_enable(unsigned irq, bool enabled);
|
||||
static void startup_wait(void);
|
||||
|
||||
#define hw_set_bits(address, bits) startup_set_bits(address, bits)
|
||||
#define hw_clear_bits(address, bits) startup_clear_bits(address, bits)
|
||||
#define reset_block(mask) startup_reset(mask)
|
||||
#define multicore_launch_core1(entry) startup_launch(entry)
|
||||
#define irq_set_exclusive_handler(irq, handler) startup_irq_handler(irq, handler)
|
||||
#define irq_set_enabled(irq, enabled) startup_irq_enable(irq, enabled)
|
||||
#define tight_loop_contents() startup_wait()
|
||||
#define NATIVE_TEST_EXTERNAL_ROUTER 1
|
||||
#define NATIVE_TEST_EXTERNAL_IRQ 1
|
||||
#include "native_hub_transport_fixture.c"
|
||||
|
||||
// This is a register/IRQ model, not a physical USB bus or Core1 timing model.
|
||||
// The physical pull-up follows DIRECT when its override is enabled; otherwise
|
||||
// it follows SIE_CTRL. In particular, a disabled SIE pull-up cannot mask a
|
||||
// forced physical pull-up. Register bit writes and startup hardware/router
|
||||
// boundaries sample this signal. Each scenario starts in fresh process BSS.
|
||||
static bool attached, observer_initialized, observer_launched, observer_ready;
|
||||
static bool routing_enabled, addresses_published, irq_enabled, enumeration_done;
|
||||
static uint8_t routed_addresses[PROBE_ROUTER_SLOTS], routed_default;
|
||||
static void (*installed_irq)(void);
|
||||
static uint32_t ready_delay_us, observer_start_us, wait_us, attach_time_us;
|
||||
static unsigned attach_edges, detach_edges, controller_resets, setup_irqs;
|
||||
static bool observer_never_ready;
|
||||
|
||||
static bool physical_pullup(void) {
|
||||
if (usb_hw->phy_direct_override & USB_USBPHY_DIRECT_OVERRIDE_DP_PULLUP_EN_OVERRIDE_EN_BITS)
|
||||
return (usb_hw->phy_direct & USB_USBPHY_DIRECT_DP_PULLUP_EN_BITS) != 0;
|
||||
return (usb_hw->sie_ctrl & USB_SIE_CTRL_PULLUP_EN_BITS) != 0;
|
||||
}
|
||||
|
||||
static uint8_t route_address(uint8_t address) {
|
||||
assert(routing_enabled && addresses_published);
|
||||
if (!address) return routed_default;
|
||||
for (uint8_t slot = 0; slot < PROBE_ROUTER_SLOTS; ++slot)
|
||||
if (routed_addresses[slot] == address) return slot;
|
||||
return NONE;
|
||||
}
|
||||
|
||||
void native_test_service_interrupt(void) {
|
||||
if (native_test_interrupt_mask || servicing_interrupt || !irq_enabled ||
|
||||
!installed_irq || !(usb_hw->ints & usb_hw->inte)) return;
|
||||
servicing_interrupt = true;
|
||||
if (usb_hw->ints & USB_INTS_SETUP_REQ_BITS) ++setup_irqs;
|
||||
installed_irq();
|
||||
usb_hw->ints = 0;
|
||||
servicing_interrupt = false;
|
||||
}
|
||||
|
||||
static void host_setup(uint8_t address, const tusb_control_request_t* request) {
|
||||
assert(attached && physical_pullup());
|
||||
uint8_t slot = route_address(address);
|
||||
assert(slot == 0 && "root request must route through the published address table");
|
||||
assert(native_hub_select_device(address,slot,UINT32_MAX / 2u));
|
||||
memcpy(usb_dpram->setup_packet,request,sizeof(*request));
|
||||
usb_hw->sie_status = USB_SIE_STATUS_SETUP_REC_BITS;
|
||||
usb_hw->ints = USB_INTS_SETUP_REQ_BITS;
|
||||
unsigned previous = setup_irqs;
|
||||
native_test_service_interrupt();
|
||||
assert(setup_irqs == previous+1u && "first host SETUP must reach the installed IRQ");
|
||||
native_hub_task();
|
||||
assert(!failed);
|
||||
}
|
||||
|
||||
static void receive_descriptor(uint8_t type, uint16_t expected_length) {
|
||||
uint8_t response[PACKET];
|
||||
uint16_t length = 0;
|
||||
assert(native_test_in(0,response,&length,true));
|
||||
assert(length == expected_length && response[1] == type);
|
||||
if (type == TUSB_DESC_DEVICE) {
|
||||
assert(response[0] == 18 && response[4] == 9 && response[7] == PACKET);
|
||||
} else {
|
||||
assert(response[0] == 9 && response[2] == expected_length && response[3] == 0);
|
||||
assert(response[4] == 1 && response[5] == 1);
|
||||
}
|
||||
assert(native_test_out(0,NULL,0,true));
|
||||
assert(devices[0].control.stage == IDLE && !failed);
|
||||
}
|
||||
|
||||
static void enumerate_at_attach(void) {
|
||||
// Do not reset/reinitialize the transport here: that could repair the very
|
||||
// startup state being tested. Deliver the first SETUP inside the physical
|
||||
// attach write, before native_hub_init has even returned to its caller.
|
||||
const tusb_control_request_t descriptor = {
|
||||
.bmRequestType = 0x80, .bRequest = TUSB_REQ_GET_DESCRIPTOR,
|
||||
.wValue = TUSB_DESC_DEVICE << 8, .wLength = 18,
|
||||
};
|
||||
host_setup(0,&descriptor);
|
||||
receive_descriptor(TUSB_DESC_DEVICE,18);
|
||||
|
||||
const tusb_control_request_t set_address = {
|
||||
.bmRequestType = 0, .bRequest = TUSB_REQ_SET_ADDRESS, .wValue = 9,
|
||||
};
|
||||
host_setup(0,&set_address);
|
||||
assert(route_address(0) == 0 && route_address(9) == NONE);
|
||||
uint8_t response[PACKET];
|
||||
uint16_t length = 1;
|
||||
assert(native_test_in(0,response,&length,true) && length == 0);
|
||||
assert(route_address(9) == 0 && route_address(0) == NONE);
|
||||
assert(usb_hw->dev_addr_ctrl == 9);
|
||||
|
||||
const tusb_control_request_t configuration = {
|
||||
.bmRequestType = 0x80, .bRequest = TUSB_REQ_GET_DESCRIPTOR,
|
||||
.wValue = TUSB_DESC_CONFIGURATION << 8, .wLength = 25,
|
||||
};
|
||||
host_setup(9,&configuration);
|
||||
receive_descriptor(TUSB_DESC_CONFIGURATION,25);
|
||||
enumeration_done = true;
|
||||
}
|
||||
|
||||
static void observe_pullup(void) {
|
||||
bool visible = physical_pullup();
|
||||
if (visible == attached) return;
|
||||
attached = visible;
|
||||
if (!visible) {
|
||||
++detach_edges;
|
||||
return;
|
||||
}
|
||||
++attach_edges;
|
||||
attach_time_us = native_test_time_us;
|
||||
assert((usb_hw->main_ctrl & USB_MAIN_CTRL_CONTROLLER_EN_BITS) &&
|
||||
"physical attach preceded controller readiness");
|
||||
assert((usb_hw->sie_ctrl & USB_SIE_CTRL_EP0_INT_1BUF_BITS) &&
|
||||
"physical attach preceded EP0 readiness");
|
||||
assert(observer_ready && routing_enabled && addresses_published &&
|
||||
"physical attach preceded observer/address routing readiness");
|
||||
assert(routed_addresses[0] == 0 && routed_default == 0);
|
||||
for (unsigned slot = 1; slot < PROBE_ROUTER_SLOTS; ++slot)
|
||||
assert(routed_addresses[slot] == NONE);
|
||||
assert(irq_enabled && installed_irq && !native_test_interrupt_mask &&
|
||||
"physical attach preceded IRQ readiness");
|
||||
assert((usb_hw->inte & (USB_INTS_SETUP_REQ_BITS | USB_INTS_BUFF_STATUS_BITS |
|
||||
USB_INTS_BUS_RESET_BITS)) ==
|
||||
(USB_INTS_SETUP_REQ_BITS | USB_INTS_BUFF_STATUS_BITS | USB_INTS_BUS_RESET_BITS));
|
||||
assert(started && "physical attach preceded foreground/pending-IRQ readiness");
|
||||
enumerate_at_attach();
|
||||
}
|
||||
|
||||
static void startup_set_bits(volatile uint32_t* address, uint32_t bits) {
|
||||
(hw_set_bits)(address,bits);
|
||||
observe_pullup();
|
||||
}
|
||||
|
||||
static void startup_clear_bits(volatile uint32_t* address, uint32_t bits) {
|
||||
(hw_clear_bits)(address,bits);
|
||||
observe_pullup();
|
||||
}
|
||||
|
||||
static void startup_reset(uint32_t mask) {
|
||||
assert(mask == RESETS_RESET_USBCTRL_BITS);
|
||||
++controller_resets;
|
||||
memset(usb_hw,0,sizeof(*usb_hw));
|
||||
observe_pullup();
|
||||
}
|
||||
|
||||
static void startup_launch(void (*entry)(void)) {
|
||||
observe_pullup();
|
||||
assert(observer_initialized && entry == probe_router_core1);
|
||||
observer_launched = true;
|
||||
}
|
||||
|
||||
static void startup_irq_handler(unsigned irq, void (*handler)(void)) {
|
||||
observe_pullup();
|
||||
assert(irq == USBCTRL_IRQ);
|
||||
installed_irq = handler;
|
||||
}
|
||||
|
||||
static void startup_irq_enable(unsigned irq, bool enabled) {
|
||||
observe_pullup();
|
||||
assert(irq == USBCTRL_IRQ);
|
||||
irq_enabled = enabled;
|
||||
native_test_service_interrupt();
|
||||
}
|
||||
|
||||
static void startup_wait(void) {
|
||||
observe_pullup();
|
||||
assert(!attached && "host must remain detached throughout observer startup");
|
||||
native_test_time_us += 1000u;
|
||||
wait_us += 1000u;
|
||||
assert(wait_us <= 100000u && "observer startup must have a bounded timeout");
|
||||
}
|
||||
|
||||
void probe_router_init(uint32_t hz) {
|
||||
observe_pullup();
|
||||
assert(hz == 240000000u);
|
||||
observer_initialized = true;
|
||||
observer_start_us = native_test_time_us;
|
||||
memset(routed_addresses,NONE,sizeof(routed_addresses));
|
||||
routed_default = NONE;
|
||||
}
|
||||
|
||||
void probe_router_core1(void) {}
|
||||
|
||||
void probe_router_publish(const uint8_t values[PROBE_ROUTER_SLOTS], uint8_t slot) {
|
||||
observe_pullup();
|
||||
memcpy(routed_addresses,values,sizeof(routed_addresses));
|
||||
routed_default = slot;
|
||||
addresses_published = true;
|
||||
}
|
||||
|
||||
void probe_router_enable(bool enabled) {
|
||||
observe_pullup();
|
||||
routing_enabled = enabled;
|
||||
}
|
||||
|
||||
bool probe_router_set_phase(uint32_t phase) {
|
||||
(void)phase;
|
||||
observe_pullup();
|
||||
return true;
|
||||
}
|
||||
|
||||
void probe_router_snapshot(probe_router_stats* snapshot) {
|
||||
observe_pullup();
|
||||
assert(observer_initialized && observer_launched);
|
||||
if (!observer_never_ready && native_test_time_us-observer_start_us >= ready_delay_us)
|
||||
observer_ready = true;
|
||||
memset(snapshot,0,sizeof(*snapshot));
|
||||
snapshot->ready = observer_ready;
|
||||
}
|
||||
|
||||
bool tud_vendor_control_xfer_cb(uint8_t slot, uint8_t stage, const tusb_control_request_t* request) {
|
||||
(void)slot; (void)stage; (void)request;
|
||||
assert(false && "root standard enumeration must not invoke vendor handling");
|
||||
return false;
|
||||
}
|
||||
|
||||
void reset_usb_boot(uint32_t gpio_mask, uint32_t disable_mask) {
|
||||
(void)gpio_mask; (void)disable_mask;
|
||||
assert(false && "cold startup must not enter BOOTSEL");
|
||||
abort();
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
if (strcmp(argv[1],"delayed") == 0) ready_delay_us = 75000u;
|
||||
else if (strcmp(argv[1],"timeout") == 0) observer_never_ready = true;
|
||||
else assert(strcmp(argv[1],"ready") == 0);
|
||||
|
||||
// No native_test_initialize/startup helper: call the actual initializer
|
||||
// from cold BSS, rather than inheriting the transport fixture's ready state.
|
||||
assert(!physical_pullup());
|
||||
bool initialized = native_hub_init();
|
||||
observe_pullup();
|
||||
assert(controller_resets == 1 && detach_edges == 0);
|
||||
if (observer_never_ready) {
|
||||
assert(!initialized && !started && !physical_pullup());
|
||||
assert(!attach_edges && !enumeration_done && !setup_irqs);
|
||||
assert(wait_us == 100000u);
|
||||
} else {
|
||||
assert(initialized && attached && attach_edges == 1);
|
||||
assert(enumeration_done && setup_irqs == 3);
|
||||
assert(wait_us == ready_delay_us && attach_time_us-observer_start_us == ready_delay_us);
|
||||
assert(startup_time == attach_time_us && !failed);
|
||||
}
|
||||
printf("native cold startup %s passed for %u children\n",argv[1],CHILDREN);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ uint16_t native_test_received_length[CHILDREN][2];
|
|||
uint8_t native_test_received_data[CHILDREN][2][PACKET];
|
||||
static bool servicing_interrupt;
|
||||
|
||||
#ifndef NATIVE_TEST_EXTERNAL_IRQ
|
||||
void native_test_service_interrupt(void) {
|
||||
if (native_test_interrupt_mask || servicing_interrupt || !usb_hw->ints) return;
|
||||
servicing_interrupt = true;
|
||||
|
|
@ -22,13 +23,16 @@ void native_test_service_interrupt(void) {
|
|||
usb_hw->ints = 0;
|
||||
servicing_interrupt = false;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifndef NATIVE_TEST_EXTERNAL_ROUTER
|
||||
void probe_router_init(uint32_t hz) { (void)hz; }
|
||||
void probe_router_core1(void) {}
|
||||
void probe_router_publish(const uint8_t values[PROBE_ROUTER_SLOTS], uint8_t slot) { (void)values; (void)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; }
|
||||
#endif
|
||||
#ifndef NATIVE_TEST_EXTERNAL_LOG
|
||||
int probe_debug_printf(const char* format, ...) { (void)format; return 0; }
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -76,6 +76,13 @@ void bluepad32_input_backend_native_sample_cancel(uint8_t instance) {
|
|||
assert(instance < PROBE_CONTROLLER_COUNT);
|
||||
cue_tokens[instance] = 0;
|
||||
}
|
||||
bool bluepad32_input_backend_native_rumble_submit(uint8_t, const uint8_t*, uint8_t) {
|
||||
assert(false && "gameplay motor dispatch belongs to the native backend fixture");
|
||||
return false;
|
||||
}
|
||||
void bluepad32_input_backend_native_rumble_cancel(uint8_t) {
|
||||
assert(false && "gameplay motor cancellation belongs to the native backend fixture");
|
||||
}
|
||||
void bluepad32_input_backend_queue_profile_feedback(uint8_t, uint32_t, uint8_t, ControllerProfileConfirmationPolicy) {}
|
||||
void controller_profile_runtime_reset() {
|
||||
for (ControllerProfile& value : profiles)
|
||||
|
|
|
|||
|
|
@ -680,7 +680,122 @@ static void test_indexed_memory(void) {
|
|||
}
|
||||
}
|
||||
|
||||
static void expect_invalid_rumble(uint8_t report_id, const uint8_t* data, size_t length) {
|
||||
probe_rumble_frame output = {.count = 3, .magnitude = {17, 93, 241}};
|
||||
assert(!probe_protocol_decode_rumble(report_id, data, length, &output));
|
||||
assert(output.count == 3);
|
||||
assert(output.magnitude[0] == 17 && output.magnitude[1] == 93 && output.magnitude[2] == 241);
|
||||
}
|
||||
|
||||
static void test_native_rumble(void) {
|
||||
// Independent block bytes from public rumble-procon-gccon.pcapng.gz,
|
||||
// packets 9970 and 257200. These are Pro Controller report 02 LRA blocks;
|
||||
// the report 01 wrapper below is synthetic, not a captured Joy-Con packet.
|
||||
static const uint8_t captured_blocks[][16] = {
|
||||
{0x50, 0x81, 0x01, 0x10, 0x1e, 0x00},
|
||||
{0x52, 0x9f, 0x19, 0xe0, 0x9d, 0x00},
|
||||
};
|
||||
probe_rumble_frame output;
|
||||
uint8_t wire[65];
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
memset(wire, 0xa5, sizeof(wire));
|
||||
wire[0] = 0x01;
|
||||
memcpy(wire + 1, captured_blocks[i], sizeof(captured_blocks[i]));
|
||||
assert(probe_protocol_decode_rumble(0, wire, 64, &output));
|
||||
assert(output.count == 1 && output.magnitude[0] == i);
|
||||
assert(probe_protocol_decode_rumble(1, wire + 1, 63, &output));
|
||||
assert(output.count == 1 && output.magnitude[0] == i);
|
||||
}
|
||||
|
||||
// Manually specified byte boundaries, not an encoder/decoder roundtrip.
|
||||
// Frequencies are both 1023: they must not leak into either amplitude,
|
||||
// nor be rejected merely because this compatibility decoder ignores them.
|
||||
static const struct {
|
||||
uint8_t sample[5];
|
||||
uint8_t expected;
|
||||
} boundaries[] = {
|
||||
{{0xff, 0x03, 0xf0, 0x3f, 0x00}, 0}, // amplitudes 0, 0
|
||||
{{0xff, 0x0b, 0xf0, 0x3f, 0x00}, 0}, // 2, 0 rounds down
|
||||
{{0xff, 0x03, 0xf0, 0xff, 0x00}, 1}, // 0, 3 rounds up
|
||||
{{0xff, 0xff, 0xf0, 0x3f, 0x00}, 16}, // 63, 0
|
||||
{{0xff, 0x03, 0xf1, 0x3f, 0x00}, 16}, // 64, 0
|
||||
{{0xff, 0xff, 0xf7, 0x3f, 0x80}, 128}, // 511, 512
|
||||
{{0xff, 0x03, 0xf8, 0xff, 0x7f}, 128}, // 512, 511
|
||||
{{0xff, 0xff, 0xff, 0x3f, 0x00}, 255}, // 1023, 0
|
||||
{{0xff, 0x03, 0xf0, 0xff, 0xff}, 255}, // 0, 1023
|
||||
};
|
||||
wire[1] = 0x5f;
|
||||
for (unsigned i = 0; i < sizeof(boundaries) / sizeof(boundaries[0]); ++i) {
|
||||
memcpy(wire + 2, boundaries[i].sample, 5);
|
||||
assert(probe_protocol_decode_rumble(0, wire, 17, &output));
|
||||
assert(output.count == 1 && output.magnitude[0] == boundaries[i].expected);
|
||||
}
|
||||
|
||||
// Three distinguishable samples retain wire order; a shorter count ignores
|
||||
// stale later samples. Both callback envelopes accept minimal/compact/USB sizes.
|
||||
static const uint8_t ordered[16] = {
|
||||
0x70,
|
||||
0x00, 0xfc, 0x0f, 0x00, 0x00, // amplitudes 1023, 0 -> 255
|
||||
0x00, 0x00, 0x00, 0xc0, 0x3f, // amplitudes 0, 255 -> 64
|
||||
0xff, 0xff, 0xf7, 0x3f, 0x80, // amplitudes 511, 512 -> 128
|
||||
};
|
||||
static const size_t lengths[] = {17, 42, 64};
|
||||
memcpy(wire + 1, ordered, sizeof(ordered));
|
||||
for (unsigned count = 1; count <= 3; ++count) {
|
||||
for (unsigned sequence = 0; sequence < 16; ++sequence) {
|
||||
wire[1] = (uint8_t)(0x40u | (count << 4) | sequence);
|
||||
for (unsigned i = 0; i < sizeof(lengths) / sizeof(lengths[0]); ++i) {
|
||||
for (unsigned form = 0; form < 2; ++form) {
|
||||
assert(probe_protocol_decode_rumble((uint8_t)form, wire + form,
|
||||
lengths[i] - form, &output));
|
||||
assert(output.count == count && output.magnitude[0] == 255);
|
||||
if (count >= 2) assert(output.magnitude[1] == 64);
|
||||
if (count == 3) assert(output.magnitude[2] == 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wire[1] = 0x4f; // HOLD: nonzero stale samples must not become a stop/update.
|
||||
assert(probe_protocol_decode_rumble(0, wire, 17, &output));
|
||||
assert(output.count == 0);
|
||||
assert(probe_protocol_decode_rumble(1, wire + 1, 16, &output));
|
||||
assert(output.count == 0);
|
||||
|
||||
// Complete 16-byte block required even for HOLD or a one-sample update.
|
||||
for (unsigned count = 0; count <= 3; ++count) {
|
||||
wire[1] = (uint8_t)(0x40u | (count << 4));
|
||||
for (size_t length = 0; length < 17; ++length)
|
||||
expect_invalid_rumble(0, wire, length);
|
||||
for (size_t length = 0; length < 16; ++length)
|
||||
expect_invalid_rumble(1, wire + 1, length);
|
||||
}
|
||||
expect_invalid_rumble(0, wire, 65);
|
||||
expect_invalid_rumble(1, wire + 1, 64);
|
||||
expect_invalid_rumble(0, wire, SIZE_MAX);
|
||||
expect_invalid_rumble(1, wire + 1, SIZE_MAX);
|
||||
expect_invalid_rumble(0, NULL, 64);
|
||||
expect_invalid_rumble(1, NULL, 63);
|
||||
assert(!probe_protocol_decode_rumble(0, wire, 64, NULL));
|
||||
assert(!probe_protocol_decode_rumble(1, wire + 1, 63, NULL));
|
||||
for (unsigned id = 0; id <= UINT8_MAX; ++id) {
|
||||
if (id != 1) {
|
||||
wire[0] = (uint8_t)id;
|
||||
expect_invalid_rumble(0, wire, 64);
|
||||
}
|
||||
if (id > 1) expect_invalid_rumble((uint8_t)id, wire + 1, 63);
|
||||
}
|
||||
wire[0] = 1;
|
||||
for (unsigned header = 0; header <= UINT8_MAX; ++header) {
|
||||
if ((header & 0xc0u) == 0x40u) continue;
|
||||
wire[1] = (uint8_t)header;
|
||||
expect_invalid_rumble(0, wire, 64);
|
||||
expect_invalid_rumble(1, wire + 1, 63);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_native_rumble();
|
||||
test_indexed_memory();
|
||||
test_descriptors();
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
|
|
|
|||
|
|
@ -53,13 +53,37 @@ def test_native_gamepad_backend_native(
|
|||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
scenarios = ["stable-logical-slot", "cue-lifetime", "cue-races"]
|
||||
scenarios = [
|
||||
"stable-logical-slot",
|
||||
"cue-lifetime",
|
||||
"cue-races",
|
||||
"gameplay-timeline",
|
||||
"gameplay-availability",
|
||||
"gameplay-priority",
|
||||
"gameplay-source-epochs",
|
||||
"gameplay-profile-gain",
|
||||
]
|
||||
if controller_count == 2:
|
||||
scenarios.append("source-isolation")
|
||||
else:
|
||||
scenarios.extend(("two-pair-sources", "two-pair-cues", "explicit-precedence"))
|
||||
scenarios.extend(
|
||||
(
|
||||
"two-pair-sources",
|
||||
"two-pair-cues",
|
||||
"explicit-precedence",
|
||||
"gameplay-two-pairs",
|
||||
)
|
||||
)
|
||||
if source == "GAMEPAD":
|
||||
scenarios.extend(("paired-source", "pair-cue-races", "mono-rumble"))
|
||||
scenarios.extend(
|
||||
(
|
||||
"paired-source",
|
||||
"pair-cue-races",
|
||||
"mono-rumble",
|
||||
"gameplay-paired-revision",
|
||||
"gameplay-wii",
|
||||
)
|
||||
)
|
||||
if controller_count == 2:
|
||||
scenarios.extend(("sensorless-admission", "independent-motion"))
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -93,3 +93,43 @@ def test_native_hub_management_native(
|
|||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(router_executable)], check=True, cwd=root)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("controller_count", [2, 4], ids=["one-pair", "two-pair"])
|
||||
@pytest.mark.parametrize("trace_enabled", [False, True], ids=["plain", "trace"])
|
||||
def test_native_hub_cold_startup(
|
||||
tmp_path: Path, controller_count: int, trace_enabled: bool
|
||||
) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
cc = shutil.which("cc") or shutil.which("gcc")
|
||||
assert cc is not None, "a host C compiler is required"
|
||||
executable = tmp_path / "native_hub_startup_test"
|
||||
flags = [f"-DPROBE_CONTROLLER_COUNT={controller_count}"]
|
||||
if trace_enabled:
|
||||
flags.append("-DSWITCH2_PROBE_TRACE_NATIVE_INPUT=1")
|
||||
subprocess.run(
|
||||
[
|
||||
cc,
|
||||
"-std=c11",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
"-ffunction-sections",
|
||||
"-fdata-sections",
|
||||
"-DSWITCH2_PROBE_HUB=1",
|
||||
*flags,
|
||||
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_startup_test.c"),
|
||||
"-Wl,--gc-sections",
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
for scenario in ("ready", "delayed", "timeout"):
|
||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||
|
|
|
|||
|
|
@ -489,6 +489,17 @@ extern "C" void probe_controller_input_set_full_stick_calibration(
|
|||
uint8_t instance, const uint8_t calibration[9]) {
|
||||
probe_native_gamepad_input_set_stick_calibration(instance, calibration);
|
||||
}
|
||||
|
||||
extern "C" bool probe_controller_input_submit_rumble(
|
||||
uint8_t instance, const uint8_t* magnitudes, uint8_t count) {
|
||||
return g_flash_ready && instance < PROBE_CONTROLLER_COUNT &&
|
||||
bluepad32_input_backend_native_rumble_submit(instance, magnitudes, count);
|
||||
}
|
||||
|
||||
extern "C" void probe_controller_input_cancel_rumble(uint8_t instance) {
|
||||
if (instance < PROBE_CONTROLLER_COUNT)
|
||||
bluepad32_input_backend_native_rumble_cancel(instance);
|
||||
}
|
||||
#endif
|
||||
|
||||
extern "C" void probe_controller_input_set_native_stream(uint8_t instance, bool enabled) {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,11 @@ void probe_controller_input_set_native_features(uint8_t features);
|
|||
// Supply each child's advertised, validated nine-byte stick record. Native
|
||||
// output stays unavailable until that child's calibration has been supplied.
|
||||
void probe_controller_input_set_full_stick_calibration(uint8_t instance, const uint8_t calibration[9]);
|
||||
// Native single-actuator gameplay envelope for this virtual half. Backend owns
|
||||
// binding/generation checks, profile gain, finite playback and stale-stream stop.
|
||||
bool probe_controller_input_submit_rumble(uint8_t instance, const uint8_t* magnitudes,
|
||||
uint8_t count);
|
||||
void probe_controller_input_cancel_rumble(uint8_t instance);
|
||||
#endif
|
||||
// Core0 native07/08 output. Disable discards queued/prepared data; repeated
|
||||
// enable preserves it. Joy-Con mode relays its bounded FIFO; right-only Wii
|
||||
|
|
@ -66,7 +71,7 @@ uint32_t probe_controller_input_peek_native_report(uint8_t instance, uint32_t no
|
|||
// Remove only the exact current head once. A stale/replaced token cannot pop a
|
||||
// new stream's packet. Before flash-ready startup peek/commit return 0/false.
|
||||
bool probe_controller_input_commit_native_report(uint8_t instance, uint32_t serial);
|
||||
// Built-in vibration samples only; raw HD-rumble output is not forwarded.
|
||||
// Built-in vibration samples; separate from full-controller gameplay envelopes.
|
||||
// A nonzero token means queued, not completed. Result:0 pending,1 completion,
|
||||
// -1 failed/stale. Joy-Con completion is its application ACK; Wii/DualSense
|
||||
// completion is actual bounded rumble-driver dispatch, not a source application
|
||||
|
|
|
|||
|
|
@ -79,6 +79,9 @@ typedef struct {
|
|||
bool native_stream_ready;
|
||||
uint32_t last_hid_complete_ms;
|
||||
bool hid_completion_seen;
|
||||
#if SWITCH2_BRIDGE_FULL_INPUT
|
||||
bool gameplay_rumble_seen;
|
||||
#endif
|
||||
uint32_t mouse_delivered_reports, mouse_logged_reports;
|
||||
int64_t mouse_delivered_x, mouse_delivered_y;
|
||||
#ifdef SWITCH2_PROBE_TRACE_NATIVE_INPUT
|
||||
|
|
@ -298,6 +301,21 @@ void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id,
|
|||
hid_report_type_t report_type, const uint8_t* buffer,
|
||||
uint16_t length) {
|
||||
++hid_packets;
|
||||
#if defined(SWITCH2_PROBE_USB_INIT) && SWITCH2_BRIDGE_FULL_INPUT
|
||||
if (instance < PROBE_CONTROLLER_COUNT && report_type == HID_REPORT_TYPE_OUTPUT &&
|
||||
controllers[instance].protocol.initialized &&
|
||||
probe_transport_mounted(instance) && !probe_transport_suspended(instance)) {
|
||||
probe_rumble_frame frame;
|
||||
if (probe_protocol_decode_rumble(report_id, buffer, length, &frame)) {
|
||||
// Count-zero HOLD leaves both motor state and watchdog untouched.
|
||||
// Valid gameplay traffic must not fill the slow UART log ring.
|
||||
if (frame.count && probe_controller_input_submit_rumble(
|
||||
instance, frame.magnitude, frame.count))
|
||||
controllers[instance].gameplay_rumble_seen = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
probe_debug_printf("[PROBE] HID_REPORT_TYPE=%u\n", report_type);
|
||||
log_packet("HID_OUT", instance, report_id, buffer, length);
|
||||
}
|
||||
|
|
@ -329,6 +347,9 @@ static void reset_controller_protocol(uint8_t instance) {
|
|||
probe_usb_controller* controller = &controllers[instance];
|
||||
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
|
||||
probe_controller_input_cancel_sample(instance);
|
||||
#if SWITCH2_BRIDGE_FULL_INPUT
|
||||
probe_controller_input_cancel_rumble(instance);
|
||||
#endif
|
||||
probe_controller_input_set_native_stream(instance, false);
|
||||
#endif
|
||||
memset(controller, 0, sizeof(*controller));
|
||||
|
|
@ -483,8 +504,16 @@ static void controller_input_task(probe_usb_controller* controller, uint32_t now
|
|||
probe_protocol_state* protocol = &controller->protocol;
|
||||
probe_controller_input source = {0};
|
||||
probe_controller_input_poll(instance, now, &source);
|
||||
const bool output_active = source.active && probe_transport_mounted(instance) &&
|
||||
!probe_transport_suspended(instance);
|
||||
const bool usb_active = probe_transport_mounted(instance) &&
|
||||
!probe_transport_suspended(instance);
|
||||
const bool output_active = source.active && usb_active;
|
||||
#if SWITCH2_BRIDGE_FULL_INPUT
|
||||
if (!usb_active) probe_controller_input_cancel_sample(instance);
|
||||
if (controller->gameplay_rumble_seen && (!output_active || !protocol->initialized)) {
|
||||
probe_controller_input_cancel_rumble(instance);
|
||||
controller->gameplay_rumble_seen = false;
|
||||
}
|
||||
#endif
|
||||
if (protocol->controller_active != output_active)
|
||||
probe_debug_printf("[PROBE] Controller input itf=%u %s\n",
|
||||
instance, output_active ? "active" : "neutral (disconnected/stale)");
|
||||
|
|
@ -783,6 +812,11 @@ void tud_suspend_cb(bool remote_wakeup_en) {
|
|||
controller->protocol.controller_active = false;
|
||||
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
|
||||
probe_controller_input_set_native_stream(instance, false);
|
||||
#if SWITCH2_BRIDGE_FULL_INPUT
|
||||
probe_controller_input_cancel_sample(instance);
|
||||
probe_controller_input_cancel_rumble(instance);
|
||||
controller->gameplay_rumble_seen = false;
|
||||
#endif
|
||||
controller->native_stream_ready = false;
|
||||
controller->hid_completion_seen = false;
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -417,27 +417,27 @@ function(switch2_usb_probe_configure target)
|
|||
endif()
|
||||
if(SWITCH2_PROBE_NEUTRAL_INPUT)
|
||||
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
|
||||
pico_set_program_version(${target} "0.97-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair-trace")
|
||||
pico_set_program_version(${target} "0.102-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair-trace")
|
||||
else()
|
||||
pico_set_program_version(${target} "0.97-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair")
|
||||
pico_set_program_version(${target} "0.102-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")
|
||||
pico_set_program_version(${target} "0.102-live-two-pair-trace")
|
||||
else()
|
||||
pico_set_program_version(${target} "0.97-live-two-pair")
|
||||
pico_set_program_version(${target} "0.102-live-two-pair")
|
||||
endif()
|
||||
elseif(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
|
||||
pico_set_program_version(${target} "0.97-native-digital-stick-trace")
|
||||
pico_set_program_version(${target} "0.102-native-digital-stick-trace")
|
||||
else()
|
||||
pico_set_program_version(${target} "0.97-native-digital-stick")
|
||||
pico_set_program_version(${target} "0.102-native-digital-stick")
|
||||
endif()
|
||||
elseif(SWITCH2_PROBE_HUB)
|
||||
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
|
||||
pico_set_program_version(${target} "0.97-native-hub-profiles-trace")
|
||||
pico_set_program_version(${target} "0.102-native-hub-profiles-trace")
|
||||
else()
|
||||
pico_set_program_version(${target} "0.97-native-hub-profiles")
|
||||
pico_set_program_version(${target} "0.102-native-hub-profiles")
|
||||
endif()
|
||||
elseif(SWITCH2_PROBE_JOIN_CHORD_GATE)
|
||||
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
|
||||
|
|
|
|||
|
|
@ -407,3 +407,34 @@ void probe_protocol_gate_native_report(const probe_protocol_state* state,
|
|||
memset(input + imu_length_offset, 0, 41);
|
||||
#endif
|
||||
}
|
||||
|
||||
bool probe_protocol_decode_rumble(uint8_t report_id, const uint8_t* data,
|
||||
size_t length, probe_rumble_frame* output) {
|
||||
if (!data || !output) return false;
|
||||
if (report_id == 0) {
|
||||
if (length < 17 || length > 64 || data[0] != 0x01) return false;
|
||||
++data;
|
||||
} else if (report_id != 0x01 || length < 16 || length > 63) {
|
||||
return false;
|
||||
}
|
||||
if ((data[0] & 0xc0u) != 0x40u) return false;
|
||||
|
||||
// Wire block: ndeadly/switch2_controller_research hid_reports.md#output-report-0x01.
|
||||
// SDL src/joystick/hidapi/SDL_hidapi_switch2.c (EncodeHDRumble / UpdateRumble)
|
||||
// packs frequency/amplitude/frequency/amplitude as four 10-bit LE fields.
|
||||
// Header: format 01 [7:6], sample count [5:4], sequence [3:0].
|
||||
// Sequence is informational; unused sample bytes and USB padding may be stale.
|
||||
probe_rumble_frame decoded = {.count = (data[0] >> 4) & 3u};
|
||||
for (unsigned i = 0; i < decoded.count; ++i) {
|
||||
const uint8_t* sample = data + 1u + 5u * i;
|
||||
const unsigned first = (sample[1] >> 2) | ((sample[2] & 0x0fu) << 6);
|
||||
const unsigned second = (sample[3] >> 6) | ((unsigned)sample[4] << 2);
|
||||
const unsigned amplitude = first > second ? first : second;
|
||||
// ERM compatibility, not HD waveform reproduction: ignore frequencies
|
||||
// and round max(amplitudes) across the full 10-bit range to 0..255.
|
||||
// SDL's conservative outbound clamp is not an inbound validity limit.
|
||||
decoded.magnitude[i] = (uint8_t)((amplitude * 255u + 511u) / 1023u);
|
||||
}
|
||||
*output = decoded;
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,18 @@ typedef struct {
|
|||
uint32_t report_counter;
|
||||
} probe_protocol_state;
|
||||
|
||||
typedef struct {
|
||||
uint8_t count;
|
||||
uint8_t magnitude[3];
|
||||
} probe_rumble_frame;
|
||||
|
||||
// Stateless Output 01 compatibility rumble: report_id 0 includes the leading
|
||||
// wire ID (17..64 bytes); report_id 1 omits it (16..63 bytes).
|
||||
// Count zero means HOLD/no update, not stop or watchdog refresh. A nonempty
|
||||
// zero-amplitude sample is stop. Malformed input leaves output unchanged.
|
||||
bool probe_protocol_decode_rumble(uint8_t report_id, const uint8_t* data,
|
||||
size_t length, probe_rumble_frame* output);
|
||||
|
||||
void probe_protocol_reset(probe_protocol_state* state, bool is_left);
|
||||
// Blob: own address[6], count[1], zero-padded host addresses[42][6], AES key[16].
|
||||
// Rejects other identities, invalid counts/padding/lengths without mutation.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue