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

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

View file

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

104
tests/native_hub_log_test.c Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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