fix(native-usb): isolate two-pair transport and hand off read status in IRQ
This commit is contained in:
parent
c26ea25f93
commit
9f8678af96
51 changed files with 8159 additions and 815 deletions
|
|
@ -56,9 +56,9 @@ void report_dualsense(uni_hid_device_t& pad, bool fresh_motion = true) {
|
|||
platform_on_controller_data(&pad, &pad.controller);
|
||||
}
|
||||
|
||||
Bluepad32NativeGamepadSnapshot bridge_snapshot() {
|
||||
Bluepad32NativeGamepadSnapshot bridge_snapshot(uint8_t pair = 0) {
|
||||
Bluepad32NativeGamepadSnapshot result{};
|
||||
bluepad32_input_backend_native_snapshot(&result);
|
||||
bluepad32_input_backend_native_snapshot(pair, &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -72,10 +72,10 @@ void source_isolation() {
|
|||
#endif
|
||||
require(platform_on_device_ready(&ordinary) == UNI_ERROR_INVALID_CONTROLLER,
|
||||
"an ineligible controller must not enter dedicated output slots");
|
||||
bluepad32_input_backend_select_native_source(ordinary.conn.btaddr);
|
||||
bluepad32_input_backend_select_native_source(0, ordinary.conn.btaddr);
|
||||
require(!bridge_snapshot().controller.active, "an ineligible device cannot become the native source");
|
||||
platform_on_device_disconnected(&ordinary);
|
||||
bluepad32_input_backend_select_native_source(nullptr);
|
||||
bluepad32_input_backend_select_native_source(0, nullptr);
|
||||
auto first = dualsense(0);
|
||||
auto second = dualsense(1);
|
||||
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS, "first DS5 must connect");
|
||||
|
|
@ -121,7 +121,7 @@ void source_isolation() {
|
|||
"a missed ambiguous interval still needs a new adapter epoch");
|
||||
platform_on_device_connected(&second);
|
||||
require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "Edge reconnect must succeed");
|
||||
bluepad32_input_backend_select_native_source(first.conn.btaddr);
|
||||
bluepad32_input_backend_select_native_source(0, first.conn.btaddr);
|
||||
report_dualsense(first);
|
||||
report_dualsense(second);
|
||||
require(bridge_snapshot().slot == 0, "explicit source must ignore another live PS5");
|
||||
|
|
@ -265,7 +265,7 @@ void cue_races() {
|
|||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_rumble_duration_ms == 0, "in-flight cancellation must retain a bounded stop obligation");
|
||||
require(bluepad32_input_backend_native_sample_request(1, 1, &token), "reselection race must queue");
|
||||
during_dualsense_dispatch = [] { bluepad32_input_backend_select_native_source(nullptr); };
|
||||
during_dualsense_dispatch = [] { bluepad32_input_backend_select_native_source(0, nullptr); };
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
during_dualsense_dispatch = nullptr;
|
||||
require(bluepad32_input_backend_native_sample_result(1, token) == -1,
|
||||
|
|
@ -329,7 +329,7 @@ void sensorless_admission() {
|
|||
"unknown-family normal AIO gamepads must not face a native brand whitelist");
|
||||
report_gamepad(generic);
|
||||
require(!bridge_snapshot().controller.active, "two logical gamepads are ambiguous");
|
||||
bluepad32_input_backend_select_native_source(xbox.conn.btaddr);
|
||||
bluepad32_input_backend_select_native_source(0, xbox.conn.btaddr);
|
||||
now_ms = 110;
|
||||
report_gamepad(xbox);
|
||||
require(bridge_snapshot().controller.active && bridge_snapshot().slot == 0 &&
|
||||
|
|
@ -338,7 +338,7 @@ void sensorless_admission() {
|
|||
platform_on_device_disconnected(&xbox);
|
||||
report_gamepad(generic);
|
||||
require(!bridge_snapshot().controller.active, "explicit selection cannot migrate on disconnect");
|
||||
bluepad32_input_backend_select_native_source(nullptr);
|
||||
bluepad32_input_backend_select_native_source(0, nullptr);
|
||||
generic.controller.gamepad.buttons = BUTTON_B;
|
||||
report_gamepad(generic);
|
||||
require(bridge_snapshot().controller.active && bridge_snapshot().controller.state.button_east &&
|
||||
|
|
@ -379,7 +379,7 @@ void independent_motion() {
|
|||
report_gamepad(ds4);
|
||||
require(!bridge_snapshot().gyro_valid && bridge_snapshot().accel_valid,
|
||||
"gyro capability loss must not suppress working acceleration or controls");
|
||||
bluepad32_input_backend_select_native_source(nullptr);
|
||||
bluepad32_input_backend_select_native_source(0, nullptr);
|
||||
++ds.report_sequence;
|
||||
ds.gyro_valid = true;
|
||||
report_gamepad(ds4);
|
||||
|
|
@ -458,7 +458,7 @@ void paired_source() {
|
|||
bridge_snapshot().gyro_received_us == 100000 &&
|
||||
bridge_snapshot().gyro_q10[2] == initial.gyro_q10[2],
|
||||
"left controls merge without refreshing or replacing the right motion owner");
|
||||
bluepad32_input_backend_select_native_source(right.conn.btaddr);
|
||||
bluepad32_input_backend_select_native_source(0, right.conn.btaddr);
|
||||
++l.report_sequence;
|
||||
report_gamepad(left);
|
||||
require(bridge_snapshot().controller.active && !bridge_snapshot().gyro_valid,
|
||||
|
|
@ -487,12 +487,12 @@ void paired_source() {
|
|||
require(right.last_rumble_duration_ms == 0 && left.last_rumble_duration_ms == 990 &&
|
||||
bluepad32_input_backend_native_sample_result(0, stop) == 1,
|
||||
"stopping one paired side preserves the other side's original finite deadline");
|
||||
bluepad32_input_backend_select_native_source(nullptr);
|
||||
bluepad32_input_backend_select_native_source(0, nullptr);
|
||||
set_runtime_joycon_mode(JoyConMode::kIndividual);
|
||||
require(!bridge_snapshot().controller.active &&
|
||||
bluepad32_input_backend_native_sample_result(1, lc) == -1,
|
||||
"live split retires the pair immediately and fails auto selection closed");
|
||||
bluepad32_input_backend_select_native_source(right.conn.btaddr);
|
||||
bluepad32_input_backend_select_native_source(0, right.conn.btaddr);
|
||||
++r.report_sequence;
|
||||
++r.accel_sequence;
|
||||
++r.gyro_sequence;
|
||||
|
|
@ -535,7 +535,7 @@ void pair_cue_races() {
|
|||
uni_hid_device_t* pad, uint16_t delay, uint16_t duration, uint8_t weak, uint8_t strong) {
|
||||
play_rumble(pad, delay, duration, weak, strong);
|
||||
now_ms += 2000;
|
||||
bluepad32_input_backend_select_native_source(nullptr);
|
||||
bluepad32_input_backend_select_native_source(0, nullptr);
|
||||
};
|
||||
require(bluepad32_input_backend_native_sample_request(0, 1, &rc) &&
|
||||
bluepad32_input_backend_native_sample_request(1, 1, &lc), "reselection race cues must queue");
|
||||
|
|
@ -624,6 +624,369 @@ extern "C" bool uni_hid_parser_native_motion_snapshot(
|
|||
return false;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
void two_pair_sources() {
|
||||
start_pairing_backend();
|
||||
auto first = dualsense(2);
|
||||
auto second = dualsense(0);
|
||||
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
|
||||
platform_on_device_ready(&second) == UNI_ERROR_SUCCESS,
|
||||
"two independent physical pads must be admitted");
|
||||
auto& a = motion_fixture(first).metadata;
|
||||
auto& b = motion_fixture(second).metadata;
|
||||
first.controller.gamepad.buttons = BUTTON_A | BUTTON_SHOULDER_L;
|
||||
second.controller.gamepad.buttons = BUTTON_B | BUTTON_SHOULDER_R;
|
||||
a.gyro_q10[2] = 10000;
|
||||
b.gyro_q10[2] = -20000;
|
||||
now_ms = 100;
|
||||
report_gamepad(first);
|
||||
now_ms = 110;
|
||||
report_gamepad(second);
|
||||
const auto initial_a = bridge_snapshot(0);
|
||||
const auto initial_b = bridge_snapshot(1);
|
||||
require(initial_a.controller.active && initial_b.controller.active &&
|
||||
initial_a.slot != initial_b.slot &&
|
||||
initial_a.controller.state.button_south && !initial_a.controller.state.button_east &&
|
||||
initial_b.controller.state.button_east && !initial_b.controller.state.button_south &&
|
||||
initial_a.gyro_q10[2] == 10000 && initial_b.gyro_q10[2] == -20000,
|
||||
"each pair must publish only its own controls and calibrated motion");
|
||||
initialize_runtime_profile_storage();
|
||||
auto profile_a = controller_profile_default(initial_a.controller.identity, 2);
|
||||
auto profile_b = controller_profile_default(initial_b.controller.identity, 5);
|
||||
profile_a.confirmation_policy = profile_b.confirmation_policy = ControllerProfileConfirmationPolicy::kNone;
|
||||
profile_a.button_map[static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth)] =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kNorth);
|
||||
profile_b.button_map[static_cast<uint8_t>(ControllerProfileLogicalButton::kEast)] =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kWest);
|
||||
require(runtime_profile_storage.set(initial_a.controller.identity, 2, profile_a) == ProfileStorageResult::kOk &&
|
||||
runtime_profile_storage.activate(initial_a.controller.identity, 2) == ProfileStorageResult::kOk &&
|
||||
runtime_profile_storage.set(initial_b.controller.identity, 5, profile_b) == ProfileStorageResult::kOk &&
|
||||
runtime_profile_storage.activate(initial_b.controller.identity, 5) == ProfileStorageResult::kOk,
|
||||
"independent identities must retain distinct active mapping banks");
|
||||
controller_profile_runtime_reset();
|
||||
const auto mapped_a = controller_profile_runtime_transform(
|
||||
initial_a.slot, initial_a.controller, now_ms, AdapterUsbMode::kXInput);
|
||||
const auto mapped_b = controller_profile_runtime_transform(
|
||||
initial_b.slot, initial_b.controller, now_ms, AdapterUsbMode::kXInput);
|
||||
require(mapped_a.state.button_north && !mapped_a.state.button_south &&
|
||||
mapped_b.state.button_west && !mapped_b.state.button_east,
|
||||
"each published source must use its own saved profile mapping");
|
||||
now_ms = 120;
|
||||
++a.report_sequence;
|
||||
++a.accel_sequence;
|
||||
first.controller.gamepad.buttons = BUTTON_X;
|
||||
report_gamepad(first);
|
||||
require(bridge_snapshot(0).controller.state.button_west &&
|
||||
bridge_snapshot(0).accel_received_us == 120000 &&
|
||||
bridge_snapshot(0).gyro_received_us == 100000 &&
|
||||
bridge_snapshot(1).controller.state.button_east &&
|
||||
bridge_snapshot(1).received_us == 110000 &&
|
||||
bridge_snapshot(1).gyro_received_us == 110000,
|
||||
"one source's input and independent sensor clocks must not freshen the other source");
|
||||
require(bluepad32_input_backend_capture_start(
|
||||
initial_b.slot, initial_b.controller.connection_generation, CaptureOptions{}),
|
||||
"Pair B must be recordable while Pair A changes connections");
|
||||
uint64_t old_a, live_b;
|
||||
require(bluepad32_input_backend_native_sample_request(0, 1, &old_a) &&
|
||||
bluepad32_input_backend_native_sample_request(3, 1, &live_b),
|
||||
"both sources must accept independent pending feedback");
|
||||
platform_on_device_disconnected(&first);
|
||||
auto extra = dualsense(1);
|
||||
require(platform_on_device_ready(&extra) == UNI_ERROR_SUCCESS, "third source may connect without assignment");
|
||||
report_dualsense(extra);
|
||||
require(!bridge_snapshot(0).controller.active &&
|
||||
bridge_snapshot(1).controller.connection_generation == initial_b.controller.connection_generation &&
|
||||
bluepad32_input_backend_native_sample_result(0, old_a) == -1 &&
|
||||
bluepad32_input_backend_native_sample_result(3, live_b) == 0,
|
||||
"a new third pad cannot steal a disconnected reservation or retire the independent pair");
|
||||
auto reconnected = dualsense(3);
|
||||
memcpy(reconnected.conn.btaddr, first.conn.btaddr, sizeof(first.conn.btaddr));
|
||||
reconnected.product_id = first.product_id;
|
||||
platform_on_device_connected(&reconnected);
|
||||
require(platform_on_device_ready(&reconnected) == UNI_ERROR_SUCCESS, "reserved source must reconnect");
|
||||
now_ms = 130;
|
||||
report_dualsense(reconnected);
|
||||
require(bridge_snapshot(0).controller.active &&
|
||||
controller_identity_equal(bridge_snapshot(0).controller.identity, initial_a.controller.identity) &&
|
||||
bridge_snapshot(0).slot != initial_a.slot &&
|
||||
bridge_snapshot(1).slot == initial_b.slot &&
|
||||
bridge_snapshot(1).controller.connection_generation == initial_b.controller.connection_generation,
|
||||
"stable reservations must restore Pair A across physical and logical slot changes without moving Pair B");
|
||||
++b.report_sequence;
|
||||
second.controller.gamepad.buttons = BUTTON_Y;
|
||||
report_gamepad(second);
|
||||
Bluepad32CaptureSnapshot capture{};
|
||||
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
|
||||
capture.state == CaptureState::kRecording && capture.total_events == 2,
|
||||
"Pair B capture must keep recording real changes across Pair A's disconnect and rebind");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(extra.rumble_calls == 0 && reconnected.rumble_calls == 0 &&
|
||||
second.last_high == 0 && second.last_low == 160 &&
|
||||
bluepad32_input_backend_native_sample_result(3, live_b) == 1,
|
||||
"pending Pair B work must reach only its original physical source after Pair A reconnects");
|
||||
const auto restored = bridge_snapshot(0);
|
||||
const auto remapped = controller_profile_runtime_transform(
|
||||
restored.slot, restored.controller, now_ms, AdapterUsbMode::kXInput);
|
||||
require(remapped.state.button_north && !remapped.state.button_south &&
|
||||
runtime_profile_storage.find(initial_b.controller.identity)->active_profile == 5,
|
||||
"reconnecting at another logical slot must preserve A's saved mapping and B's active profile");
|
||||
}
|
||||
|
||||
void two_pair_cues() {
|
||||
start_pairing_backend();
|
||||
auto first = dualsense(0);
|
||||
auto second = dualsense(1);
|
||||
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
|
||||
platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "both cue sources must connect");
|
||||
report_dualsense(first);
|
||||
report_dualsense(second);
|
||||
const auto before_b = bridge_snapshot(1);
|
||||
uint64_t ar, al, br, bl;
|
||||
require(bluepad32_input_backend_native_sample_request(0, 6, &ar) &&
|
||||
bluepad32_input_backend_native_sample_request(1, 7, &al) &&
|
||||
bluepad32_input_backend_native_sample_request(2, 3, &br) &&
|
||||
bluepad32_input_backend_native_sample_request(3, 1, &bl),
|
||||
"all four virtual sides must accept independent cues");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(first.last_high == 96 && first.last_low == 220 && first.last_rumble_duration_ms == 60 &&
|
||||
second.last_high == 96 && second.last_low == 160 && second.last_rumble_duration_ms == 25 &&
|
||||
bluepad32_input_backend_native_sample_result(2, ar) == -1 &&
|
||||
bluepad32_input_backend_native_sample_result(0, br) == -1,
|
||||
"R/L contributions and completion tokens must be scoped to their physical pair");
|
||||
now_ms = 25;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(second.last_high == 0 && second.last_low == 160 && second.last_rumble_duration_ms == 975 &&
|
||||
first.last_high == 96 && first.last_low == 220,
|
||||
"Pair B's pulse boundary must not replace Pair A's independently timed motors");
|
||||
const uint8_t absent[6] = {0xee, 0, 0, 0, 0, 1};
|
||||
bluepad32_input_backend_select_native_source(0, absent);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(first.last_rumble_duration_ms == 0 && second.last_low == 160 &&
|
||||
bluepad32_input_backend_native_sample_result(0, ar) == -1 &&
|
||||
bluepad32_input_backend_native_sample_result(1, al) == -1 &&
|
||||
bluepad32_input_backend_native_sample_result(2, br) == 1 &&
|
||||
bluepad32_input_backend_native_sample_result(3, bl) == 1 &&
|
||||
bridge_snapshot(1).controller.connection_generation == before_b.controller.connection_generation,
|
||||
"disabling Pair A must stop only A and preserve B's accepted cues and input epoch");
|
||||
bluepad32_input_backend_native_sample_cancel(2);
|
||||
now_ms = 40;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_select_native_source(0, nullptr);
|
||||
require(bluepad32_input_backend_native_sample_request(0, 1, &ar) &&
|
||||
bluepad32_input_backend_native_sample_request(2, 6, &br),
|
||||
"retired sides can accept fresh boot-unique work");
|
||||
during_dualsense_dispatch = [] {
|
||||
during_dualsense_dispatch = nullptr;
|
||||
bluepad32_input_backend_select_native_source(0, nullptr);
|
||||
};
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(bluepad32_input_backend_native_sample_result(0, ar) == -1 &&
|
||||
bluepad32_input_backend_native_sample_result(2, br) == 1 &&
|
||||
second.last_high == 96 && second.last_low == 160 && second.last_rumble_duration_ms == 60,
|
||||
"reselection during A's driver call must reject stale A completion without retiring B's next dispatch");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(first.last_rumble_duration_ms == 0 && second.last_high == 96,
|
||||
"a raced A submission must be stopped without canceling B's physical timer");
|
||||
platform_on_device_disconnected(&first);
|
||||
now_ms = 100;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(second.last_high == 0 && second.last_low == 160 && second.last_rumble_duration_ms == 900,
|
||||
"B's remaining left pulse must retain its original deadline after A disconnects");
|
||||
}
|
||||
|
||||
void explicit_precedence() {
|
||||
start_pairing_backend();
|
||||
auto first = dualsense(0);
|
||||
auto second = dualsense(1);
|
||||
bluepad32_input_backend_select_native_source(1, second.conn.btaddr);
|
||||
require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "explicit Pair B may arrive first");
|
||||
report_dualsense(second);
|
||||
require(!bridge_snapshot(0).controller.active && bridge_snapshot(1).controller.active,
|
||||
"automatic Pair A cannot borrow an explicitly reserved source");
|
||||
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS, "independent automatic source must connect");
|
||||
report_dualsense(first);
|
||||
const auto initial_a = bridge_snapshot(0);
|
||||
auto duplicate = dualsense(2);
|
||||
memcpy(duplicate.conn.btaddr, second.conn.btaddr, sizeof(second.conn.btaddr));
|
||||
duplicate.product_id = second.product_id;
|
||||
require(platform_on_device_ready(&duplicate) == UNI_ERROR_SUCCESS, "ambiguous-address fixture must connect");
|
||||
report_dualsense(duplicate);
|
||||
require(!bridge_snapshot(1).controller.active &&
|
||||
bridge_snapshot(0).controller.connection_generation == initial_a.controller.connection_generation,
|
||||
"an ambiguous explicit address must fail only its affected pair closed");
|
||||
platform_on_device_disconnected(&duplicate);
|
||||
report_dualsense(second);
|
||||
require(bridge_snapshot(1).controller.active, "the unique explicit match must resume after ambiguity clears");
|
||||
bluepad32_input_backend_select_native_source(0, second.conn.btaddr);
|
||||
report_dualsense(second);
|
||||
require(!bridge_snapshot(0).controller.active && !bridge_snapshot(1).controller.active,
|
||||
"two explicit selectors matching one logical pad must never broadcast it");
|
||||
bluepad32_input_backend_select_native_source(0, nullptr);
|
||||
report_dualsense(first);
|
||||
report_dualsense(second);
|
||||
require(controller_identity_equal(bridge_snapshot(0).controller.identity, identity_for_device(&first)) &&
|
||||
controller_identity_equal(bridge_snapshot(1).controller.identity, identity_for_device(&second)),
|
||||
"releasing an explicit conflict restores separate automatic and explicit sources");
|
||||
}
|
||||
|
||||
void paired_explicit_conflict() {
|
||||
start_pairing_backend();
|
||||
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
|
||||
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
|
||||
bluepad32_input_backend_select_native_source(0, left.conn.btaddr);
|
||||
bluepad32_input_backend_select_native_source(1, right.conn.btaddr);
|
||||
ready_switch2(left);
|
||||
uint64_t old;
|
||||
require(bluepad32_input_backend_native_sample_request(0, 1, &old), "solo explicit source cue must queue");
|
||||
ready_switch2(right);
|
||||
motion_fixture(left);
|
||||
motion_fixture(right);
|
||||
report_gamepad(right);
|
||||
uint64_t rejected;
|
||||
require(!bridge_snapshot(0).controller.active && !bridge_snapshot(1).controller.active &&
|
||||
bluepad32_input_backend_native_sample_result(0, old) == -1 &&
|
||||
!bluepad32_input_backend_native_sample_request(2, 1, &rejected),
|
||||
"paired physical halves matched by different explicit selectors must retire old work and fail both closed");
|
||||
bluepad32_input_backend_select_native_source(1, nullptr);
|
||||
++sensors[right.idx].metadata.report_sequence;
|
||||
report_gamepad(right);
|
||||
require(bridge_snapshot(0).controller.active && !bridge_snapshot(1).controller.active,
|
||||
"an explicit logical pair reserves both halves against automatic assignment");
|
||||
auto independent = dualsense(2);
|
||||
require(platform_on_device_ready(&independent) == UNI_ERROR_SUCCESS, "independent second source must connect");
|
||||
report_dualsense(independent);
|
||||
const auto before_b = bridge_snapshot(1);
|
||||
uint64_t rc, lc, bc;
|
||||
require(bluepad32_input_backend_native_sample_request(0, 6, &rc) &&
|
||||
bluepad32_input_backend_native_sample_request(1, 1, &lc) &&
|
||||
bluepad32_input_backend_native_sample_request(3, 7, &bc),
|
||||
"paired real halves and independent pad must accept separate feedback");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(right.last_high == 96 && left.last_low == 160 && independent.last_low == 220,
|
||||
"feedback must respect both logical pair and paired physical side");
|
||||
set_runtime_joycon_mode(JoyConMode::kIndividual);
|
||||
++sensors[left.idx].metadata.report_sequence;
|
||||
++sensors[right.idx].metadata.report_sequence;
|
||||
report_gamepad(left);
|
||||
report_gamepad(right);
|
||||
require(bridge_snapshot(0).controller.active &&
|
||||
controller_identity_equal(bridge_snapshot(0).controller.identity, identity_for_device(&left)) &&
|
||||
bridge_snapshot(1).controller.connection_generation == before_b.controller.connection_generation &&
|
||||
bluepad32_input_backend_native_sample_result(0, rc) == -1 &&
|
||||
bluepad32_input_backend_native_sample_result(1, lc) == -1 &&
|
||||
bluepad32_input_backend_native_sample_result(3, bc) == 1,
|
||||
"splitting a physical pair retires only its old cues and cannot duplicate its unselected member into Pair B");
|
||||
}
|
||||
|
||||
void topology_reservations() {
|
||||
start_pairing_backend();
|
||||
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
|
||||
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
|
||||
ready_switch2(left);
|
||||
ready_switch2(right);
|
||||
motion_fixture(left);
|
||||
motion_fixture(right);
|
||||
report_gamepad(right);
|
||||
auto independent = dualsense(2);
|
||||
require(platform_on_device_ready(&independent) == UNI_ERROR_SUCCESS, "independent automatic source must connect");
|
||||
report_dualsense(independent);
|
||||
const auto before_b = bridge_snapshot(1);
|
||||
const auto pair_identity = bridge_snapshot(0).controller.identity;
|
||||
set_runtime_joycon_mode(JoyConMode::kIndividual);
|
||||
++sensors[left.idx].metadata.report_sequence;
|
||||
++sensors[right.idx].metadata.report_sequence;
|
||||
report_gamepad(left);
|
||||
report_gamepad(right);
|
||||
require(!bridge_snapshot(0).controller.active &&
|
||||
bridge_snapshot(1).controller.connection_generation == before_b.controller.connection_generation,
|
||||
"a split remembered pair is ambiguous without moving the independent pair");
|
||||
set_runtime_joycon_mode(JoyConMode::kPaired);
|
||||
++sensors[right.idx].metadata.report_sequence;
|
||||
report_gamepad(right);
|
||||
require(bridge_snapshot(0).controller.active &&
|
||||
controller_identity_equal(bridge_snapshot(0).controller.identity, pair_identity) &&
|
||||
bridge_snapshot(1).controller.connection_generation == before_b.controller.connection_generation,
|
||||
"remerging the same remembered members must restore only their reserved pair");
|
||||
platform_on_device_disconnected(&independent);
|
||||
set_runtime_joycon_mode(JoyConMode::kIndividual);
|
||||
bluepad32_input_backend_select_native_source(0, left.conn.btaddr);
|
||||
bluepad32_input_backend_select_native_source(1, right.conn.btaddr);
|
||||
bluepad32_input_backend_select_native_source(0, nullptr);
|
||||
bluepad32_input_backend_select_native_source(1, nullptr);
|
||||
++sensors[left.idx].metadata.report_sequence;
|
||||
++sensors[right.idx].metadata.report_sequence;
|
||||
report_gamepad(left);
|
||||
report_gamepad(right);
|
||||
require(bridge_snapshot(0).controller.active && bridge_snapshot(1).controller.active,
|
||||
"individually reserved physical halves must first own separate logical streams");
|
||||
set_runtime_joycon_mode(JoyConMode::kPaired);
|
||||
++sensors[right.idx].metadata.report_sequence;
|
||||
report_gamepad(right);
|
||||
require(!bridge_snapshot(0).controller.active && !bridge_snapshot(1).controller.active,
|
||||
"merging two independently reserved sources must fail both closed rather than duplicate the merged pair");
|
||||
set_runtime_joycon_mode(JoyConMode::kIndividual);
|
||||
++sensors[left.idx].metadata.report_sequence;
|
||||
++sensors[right.idx].metadata.report_sequence;
|
||||
report_gamepad(left);
|
||||
report_gamepad(right);
|
||||
require(bridge_snapshot(0).controller.active && bridge_snapshot(1).controller.active &&
|
||||
bridge_snapshot(0).slot != bridge_snapshot(1).slot,
|
||||
"splitting conflicting members restores their previous independent reservations");
|
||||
}
|
||||
|
||||
void stable_ble_reservation() {
|
||||
start_pairing_backend();
|
||||
auto first = device(0, true, UNI_BT_CONN_PROTOCOL_BLE);
|
||||
auto independent = dualsense(1);
|
||||
bluepad32_input_backend_select_native_source(1, independent.conn.btaddr);
|
||||
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
|
||||
platform_on_device_ready(&independent) == UNI_ERROR_SUCCESS,
|
||||
"an unresolved BLE gamepad may connect beside an explicit stable source");
|
||||
report_gamepad(first);
|
||||
report_dualsense(independent);
|
||||
const auto initial_b = bridge_snapshot(1);
|
||||
require(!bridge_snapshot(0).controller.active && initial_b.controller.active,
|
||||
"automatic reservations must not promote an unresolved BLE connection address to a stable identity");
|
||||
const bd_addr_t identity = {0xc2, 0x10, 0x20, 0x30, 0x40, 0x50};
|
||||
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, first,
|
||||
BD_ADDR_TYPE_LE_RANDOM, identity);
|
||||
first.controller.gamepad.buttons = BUTTON_A;
|
||||
report_gamepad(first);
|
||||
const auto initial_a = bridge_snapshot(0);
|
||||
uint64_t old_a, live_b;
|
||||
require(initial_a.controller.active && initial_a.controller.state.button_south &&
|
||||
bluepad32_input_backend_native_sample_request(0, 1, &old_a) &&
|
||||
bluepad32_input_backend_native_sample_request(3, 1, &live_b),
|
||||
"resolved identity publication must activate its own stream and feedback without waiting for another connection");
|
||||
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_STARTED, first,
|
||||
BD_ADDR_TYPE_LE_RANDOM, identity);
|
||||
require(!bridge_snapshot(0).controller.active &&
|
||||
bridge_snapshot(1).controller.connection_generation == initial_b.controller.connection_generation &&
|
||||
bluepad32_input_backend_native_sample_result(0, old_a) == -1 &&
|
||||
bluepad32_input_backend_native_sample_result(3, live_b) == 0,
|
||||
"identity loss must retire only the uncertain source's input and pending work");
|
||||
platform_on_device_disconnected(&first);
|
||||
auto reconnect = device(2, true, UNI_BT_CONN_PROTOCOL_BLE);
|
||||
reconnect.vendor_id = first.vendor_id;
|
||||
reconnect.product_id = first.product_id;
|
||||
platform_on_device_connected(&reconnect);
|
||||
require(platform_on_device_ready(&reconnect) == UNI_ERROR_SUCCESS, "BLE controller must reconnect at a different transport index");
|
||||
report_gamepad(reconnect);
|
||||
require(!bridge_snapshot(0).controller.active, "a fresh unresolved BLE address must not steal the remembered stable source");
|
||||
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, reconnect,
|
||||
BD_ADDR_TYPE_LE_RANDOM, identity);
|
||||
reconnect.controller.gamepad.buttons = BUTTON_B;
|
||||
report_gamepad(reconnect);
|
||||
require(bridge_snapshot(0).controller.active && bridge_snapshot(0).controller.state.button_east &&
|
||||
!bridge_snapshot(0).controller.state.button_south &&
|
||||
controller_identity_equal(bridge_snapshot(0).controller.identity, initial_a.controller.identity) &&
|
||||
bridge_snapshot(1).controller.connection_generation == initial_b.controller.connection_generation,
|
||||
"resolving a new BLE connection address must recover the original pair reservation without reviving cached controls");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
require(argc == 2, "scenario required");
|
||||
const std::string scenario = argv[1];
|
||||
|
|
@ -636,6 +999,12 @@ int main(int argc, char** argv) {
|
|||
else if (scenario == "paired-source") paired_source();
|
||||
else if (scenario == "pair-cue-races") pair_cue_races();
|
||||
else if (scenario == "mono-rumble") mono_rumble();
|
||||
else if (scenario == "two-pair-sources") two_pair_sources();
|
||||
else if (scenario == "two-pair-cues") two_pair_cues();
|
||||
else if (scenario == "explicit-precedence") explicit_precedence();
|
||||
else if (scenario == "paired-explicit-conflict") paired_explicit_conflict();
|
||||
else if (scenario == "topology-reservations") topology_reservations();
|
||||
else if (scenario == "stable-ble-reservation") stable_ble_reservation();
|
||||
else require(false, "unknown native gamepad scenario");
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
104
tests/native_hub_log_test.c
Normal file
104
tests/native_hub_log_test.c
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
#include <assert.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "hardware_stub.h"
|
||||
#include "tusb_config.h"
|
||||
|
||||
static unsigned test_core, test_exception;
|
||||
#define get_core_num() test_core
|
||||
#define __get_current_exception() test_exception
|
||||
#define hard_assert(value) assert(value)
|
||||
#define HID_REPORT_TYPE_INPUT 1
|
||||
static uint64_t get_absolute_time(void) { return 0; }
|
||||
static uint32_t to_ms_since_boot(uint64_t value) { return (uint32_t)value; }
|
||||
static void sleep_us(uint32_t microseconds) { (void)microseconds; }
|
||||
static void panic(const char* text) { (void)text; abort(); }
|
||||
#define main unused_probe_firmware_main
|
||||
#include "../tools/switch2_usb_probe/main.c"
|
||||
#undef main
|
||||
|
||||
uint32_t native_test_interrupt_mask;
|
||||
static bool pending_completion, inject_completion;
|
||||
static unsigned completions, missed_tokens;
|
||||
static uint32_t phase;
|
||||
static char serial_bytes[2 * LOG_CAPACITY];
|
||||
static size_t serial_size;
|
||||
|
||||
void native_test_service_interrupt(void) {
|
||||
if (pending_completion && !native_test_interrupt_mask) {
|
||||
pending_completion = false;
|
||||
++completions;
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t native_hub_trace_phase(uint32_t next) {
|
||||
const uint32_t previous = phase;
|
||||
phase = next;
|
||||
if (inject_completion && next == NATIVE_HUB_TRACE_PHASE_LOG_COPY) {
|
||||
// A completed child packet must be serviced before the next owner's
|
||||
// token can be selected. This is the same blocking condition checked
|
||||
// by native_hub_select_device; the real logger runs between both.
|
||||
pending_completion = true;
|
||||
native_test_service_interrupt();
|
||||
if (pending_completion) ++missed_tokens;
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
bool uart_is_writable(void* uart) { (void)uart; return serial_size < sizeof(serial_bytes); }
|
||||
void uart_putc_raw(void* uart, char value) { (void)uart; serial_bytes[serial_size++] = value; }
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc == 2) {
|
||||
if (strcmp(argv[1], "core") == 0) test_core = 1;
|
||||
else if (strcmp(argv[1], "irq") == 0) test_exception = 16;
|
||||
else return 2;
|
||||
probe_debug_printf("unsafe caller\n");
|
||||
return 0;
|
||||
}
|
||||
// Exercise a wrapped, full-length diagnostic message, not just empty logs.
|
||||
char message[480];
|
||||
memset(message, 'x', sizeof(message) - 1);
|
||||
message[sizeof(message) - 1] = 0;
|
||||
log_read = log_written = LOG_CAPACITY - 13;
|
||||
inject_completion = true;
|
||||
assert(probe_debug_printf("%s", message) == (int)strlen(message));
|
||||
assert(missed_tokens == 0 && "logging blocked a USB completion and the next device's token");
|
||||
assert(completions == 1 && !pending_completion);
|
||||
drain_log();
|
||||
assert(serial_size == strlen(message));
|
||||
assert(memcmp(serial_bytes, message, serial_size) == 0);
|
||||
|
||||
// Full-ring overflow drops a complete message without corrupting queued data.
|
||||
inject_completion = false;
|
||||
serial_size = 0;
|
||||
log_read = 0; log_written = LOG_CAPACITY;
|
||||
memset(log_bytes, 'q', sizeof(log_bytes));
|
||||
const uint32_t drops_before = log_dropped;
|
||||
assert(probe_debug_printf("discard me") < 0);
|
||||
drain_log();
|
||||
assert(serial_size == LOG_CAPACITY);
|
||||
for (size_t i = 0; i < serial_size; ++i) assert(serial_bytes[i] == 'q');
|
||||
assert(log_dropped == drops_before + 10);
|
||||
serial_size = 0;
|
||||
assert(probe_debug_printf("discard me") == 10);
|
||||
drain_log();
|
||||
assert(serial_size == 10 && memcmp(serial_bytes, "discard me", 10) == 0);
|
||||
|
||||
// Respect a caller's existing critical section; logging cannot enable IRQs.
|
||||
serial_size = 0;
|
||||
inject_completion = true;
|
||||
native_test_interrupt_mask = 1;
|
||||
const unsigned completed_before = completions;
|
||||
probe_debug_printf("caller owns mask");
|
||||
assert(native_test_interrupt_mask == 1 && completions == completed_before);
|
||||
restore_interrupts(0);
|
||||
assert(completions == completed_before + 1);
|
||||
drain_log();
|
||||
assert(serial_size == strlen("caller owns mask"));
|
||||
assert(memcmp(serial_bytes, "caller owns mask", serial_size) == 0);
|
||||
puts("native logging preserved USB progress, message order and caller IRQ state");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
extern "C" {
|
||||
void native_test_initialize(void);
|
||||
void native_test_drain(void);
|
||||
bool native_test_startup(void);
|
||||
void native_test_advance(uint32_t);
|
||||
bool native_test_setup(uint8_t, const tusb_control_request_t*, bool);
|
||||
bool native_test_out(uint8_t, const uint8_t*, uint16_t, bool);
|
||||
bool native_test_in(uint8_t, uint8_t*, uint16_t*, bool);
|
||||
|
|
@ -20,6 +22,12 @@ void native_test_bus_reset(bool);
|
|||
void native_test_hold_abort(bool);
|
||||
bool native_test_select(uint8_t);
|
||||
bool native_test_private_in(uint8_t, uint8_t, uint8_t*, uint16_t*);
|
||||
bool native_test_private_out(uint8_t, uint8_t, const uint8_t*, uint16_t, bool);
|
||||
extern uint32_t native_test_hid_completions[PROBE_CONTROLLER_COUNT];
|
||||
extern uint32_t native_test_bulk_completions[PROBE_CONTROLLER_COUNT];
|
||||
extern uint32_t native_test_received_count[PROBE_CONTROLLER_COUNT][2];
|
||||
extern uint16_t native_test_received_length[PROBE_CONTROLLER_COUNT][2];
|
||||
extern uint8_t native_test_received_data[PROBE_CONTROLLER_COUNT][2][64];
|
||||
extern uint32_t native_test_interrupt_mask;
|
||||
}
|
||||
|
||||
|
|
@ -29,8 +37,10 @@ std::array<uint8_t, PROFILE_STORAGE_ARENA_COUNT * PROFILE_STORAGE_ARENA_SIZE> fl
|
|||
uint32_t programs = 0;
|
||||
uint32_t erases = 0;
|
||||
uint32_t bootsel_calls = 0;
|
||||
std::array<uint8_t, 64> child_identity[2];
|
||||
std::array<uint8_t, 64> child_identity[PROBE_CONTROLLER_COUNT];
|
||||
bool interleave_identity_ack = false;
|
||||
bool synthetic_root_management = false;
|
||||
uint32_t bootsel_time_ms = 0;
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) { std::cerr << message << '\n'; std::exit(1); }
|
||||
|
|
@ -155,6 +165,240 @@ void read_child(uint8_t slot) {
|
|||
"native child identity leaked root or sibling vendor bytes");
|
||||
}
|
||||
|
||||
void assign_address(uint8_t slot, uint8_t address) {
|
||||
tusb_control_request_t setup{};
|
||||
setup.bRequest = TUSB_REQ_SET_ADDRESS;
|
||||
setup.wValue = address;
|
||||
require(native_test_setup(slot, &setup, true), "SET_ADDRESS stalled");
|
||||
acknowledge(slot);
|
||||
}
|
||||
|
||||
void configure(uint8_t slot, uint8_t value = 1) {
|
||||
tusb_control_request_t setup{};
|
||||
setup.bRequest = TUSB_REQ_SET_CONFIGURATION;
|
||||
setup.wValue = value;
|
||||
require(native_test_setup(slot, &setup, true), "SET_CONFIGURATION stalled");
|
||||
acknowledge(slot);
|
||||
}
|
||||
|
||||
tusb_control_request_t port_feature(uint8_t port, uint16_t feature, bool set) {
|
||||
tusb_control_request_t setup{};
|
||||
setup.bmRequestType = 0x23;
|
||||
setup.bRequest = set ? TUSB_REQ_SET_FEATURE : TUSB_REQ_CLEAR_FEATURE;
|
||||
setup.wIndex = port;
|
||||
setup.wValue = feature;
|
||||
return setup;
|
||||
}
|
||||
|
||||
void change_port(uint8_t port, uint16_t feature, bool set) {
|
||||
const auto setup = port_feature(port, feature, set);
|
||||
require(native_test_setup(0, &setup, true), "port feature request stalled");
|
||||
acknowledge();
|
||||
}
|
||||
|
||||
std::vector<uint8_t> port_status(uint8_t port) {
|
||||
tusb_control_request_t setup{};
|
||||
setup.bmRequestType = 0xa3;
|
||||
setup.bRequest = TUSB_REQ_GET_STATUS;
|
||||
setup.wIndex = port;
|
||||
setup.wLength = 4;
|
||||
require(native_test_setup(0, &setup, true), "port status request stalled");
|
||||
return receive();
|
||||
}
|
||||
|
||||
void require_hub_change(uint8_t expected) {
|
||||
uint8_t packet[64]; uint16_t length = 0;
|
||||
require(native_test_private_in(0, 0x8f, packet, &length) && length == 1 && packet[0] == expected,
|
||||
"root interrupt endpoint omitted or mixed port change bits");
|
||||
native_test_drain();
|
||||
require(!native_test_private_in(0, 0x8f, packet, &length),
|
||||
"cleared hub port changes did not return to NAK");
|
||||
}
|
||||
|
||||
void test_port_enumeration_and_bounds() {
|
||||
require(native_test_startup(), "native hub startup failed");
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot)
|
||||
require(!native_test_select(slot), "startup assigned an address to an unreset child");
|
||||
assign_address(0, 9);
|
||||
configure(0);
|
||||
tusb_control_request_t descriptor{};
|
||||
descriptor.bmRequestType = 0xa0;
|
||||
descriptor.bRequest = TUSB_REQ_GET_DESCRIPTOR;
|
||||
descriptor.wValue = 0x2900;
|
||||
descriptor.wLength = 64;
|
||||
require(native_test_setup(0, &descriptor, true), "hub descriptor stalled");
|
||||
const auto bytes = receive();
|
||||
require(bytes.size() == 9 && bytes[0] == 9 && bytes[1] == 0x29 &&
|
||||
bytes[2] == PROBE_CONTROLLER_COUNT && bytes[7] == (1u << (PROBE_CONTROLLER_COUNT + 1u)) - 2u &&
|
||||
bytes[8] == 0xff, "hub descriptor has incorrect port or non-removable masks");
|
||||
for (uint8_t port = 1; port <= PROBE_CONTROLLER_COUNT; ++port) {
|
||||
require(u16(port_status(port), 0) == 0, "unpowered port is not disconnected");
|
||||
change_port(port, 8, true);
|
||||
auto status = port_status(port);
|
||||
require(u16(status, 0) == 0x101 && u16(status, 2) == 1, "port power did not signal connection");
|
||||
change_port(port, 16, false);
|
||||
require_hub_change(1u << port);
|
||||
change_port(port, 4, true);
|
||||
status = port_status(port);
|
||||
require(u16(status, 0) == 0x111 && u16(status, 2) == 0, "port reset completed before its deadline");
|
||||
native_test_advance(10000);
|
||||
status = port_status(port);
|
||||
require(u16(status, 0) == 0x103 && u16(status, 2) == 16, "port reset did not enable its child");
|
||||
assign_address(port, 17u * port);
|
||||
configure(port);
|
||||
read_child(port);
|
||||
change_port(port, 20, false);
|
||||
require_hub_change(1u << port);
|
||||
change_port(port, 2, true);
|
||||
require(native_hub_suspended(port - 1) && !native_hub_hid_ready(port - 1),
|
||||
"suspended port remained ready for input");
|
||||
change_port(port, 2, false);
|
||||
change_port(port, 18, false);
|
||||
require_hub_change(1u << port);
|
||||
}
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) read_child(slot);
|
||||
for (uint8_t port : {uint8_t{0}, uint8_t{PROBE_CONTROLLER_COUNT + 1}}) {
|
||||
auto setup = port_feature(port, 8, true);
|
||||
require(!native_test_setup(0, &setup, true), "out-of-range port feature was accepted");
|
||||
setup.bmRequestType = 0xa3; setup.bRequest = 0; setup.wValue = 0; setup.wLength = 4;
|
||||
require(!native_test_setup(0, &setup, true), "out-of-range port status was accepted");
|
||||
}
|
||||
const uint8_t data = 1;
|
||||
for (uint8_t instance : {uint8_t{PROBE_CONTROLLER_COUNT}, uint8_t{255}}) {
|
||||
require(!native_hub_mounted(instance) && native_hub_suspended(instance) &&
|
||||
!native_hub_hid_ready(instance) && !native_hub_hid_report(instance, 1, &data, 1) &&
|
||||
native_hub_vendor_write_available(instance) == 0 &&
|
||||
native_hub_vendor_write(instance, &data, 1) == 0 && native_hub_vendor_write_flush(instance) == 0,
|
||||
"out-of-range controller instance touched a bank");
|
||||
require(!native_hub_control_xfer(instance + (instance != 255), &descriptor, nullptr, 0, false) &&
|
||||
!native_hub_control_status(instance + (instance != 255), &descriptor),
|
||||
"out-of-range control slot was accepted");
|
||||
}
|
||||
configure(0, 0);
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot)
|
||||
require(!native_hub_mounted(slot - 1) && !native_test_select(slot),
|
||||
"root deconfiguration retained a child bank or address");
|
||||
native_test_initialize();
|
||||
}
|
||||
|
||||
void test_child_control_and_receive_isolation() {
|
||||
native_test_initialize();
|
||||
tusb_control_request_t identity{};
|
||||
identity.bmRequestType = 0xc0; identity.bRequest = 3; identity.wLength = 128;
|
||||
uint8_t packet[64]; uint16_t length;
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
configure(slot);
|
||||
const uint8_t payload = 0x70 + slot;
|
||||
require(native_hub_hid_report(slot - 1, 8, &payload, 1) &&
|
||||
native_test_private_in(slot, 0x81, packet, &length), "HID completion setup failed");
|
||||
require(native_test_setup(slot, &identity, false), "interleaved child control setup failed");
|
||||
}
|
||||
native_test_drain();
|
||||
for (uint8_t slot = PROBE_CONTROLLER_COUNT; slot; --slot) {
|
||||
const auto bytes = receive(slot);
|
||||
require(bytes == std::vector<uint8_t>(child_identity[slot - 1].begin(), child_identity[slot - 1].end()),
|
||||
"concurrent control transfers shared another child's EP0 data");
|
||||
require(native_test_hid_completions[slot - 1] == 1 && native_hub_hid_ready(slot - 1),
|
||||
"new SETUP invalidated an unrelated HID completion");
|
||||
}
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
for (uint8_t endpoint : {1, 2}) {
|
||||
const uint8_t payload[] = {slot, endpoint, uint8_t(slot ^ 0x5a)};
|
||||
require(native_test_private_out(slot, endpoint, payload, sizeof(payload), false),
|
||||
"private OUT packet was not accepted");
|
||||
require(!native_test_private_out(slot, endpoint, payload, sizeof(payload), false),
|
||||
"pending OUT buffer failed to NAK before foreground consumption");
|
||||
}
|
||||
}
|
||||
native_test_drain();
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
for (uint8_t endpoint : {1, 2}) {
|
||||
const uint8_t payload[] = {slot, endpoint, uint8_t(slot ^ 0x5a)};
|
||||
require(native_test_received_count[slot - 1][endpoint - 1] == 1 &&
|
||||
native_test_received_length[slot - 1][endpoint - 1] == sizeof(payload) &&
|
||||
std::memcmp(native_test_received_data[slot - 1][endpoint - 1], payload, sizeof(payload)) == 0,
|
||||
"OUT callback received another child's endpoint payload");
|
||||
}
|
||||
}
|
||||
native_test_initialize();
|
||||
}
|
||||
|
||||
void test_port_reset_revokes_only_its_child_events() {
|
||||
for (uint8_t target = 1; target <= PROBE_CONTROLLER_COUNT; ++target) {
|
||||
native_test_initialize();
|
||||
assign_address(0, 9);
|
||||
tusb_control_request_t identity{};
|
||||
identity.bmRequestType = 0xc0; identity.bRequest = 3; identity.wLength = 128;
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
change_port(slot, 8, true);
|
||||
configure(slot);
|
||||
const uint8_t data = slot;
|
||||
require(native_hub_hid_report(slot - 1, 8, &data, 1), "reset isolation HID setup failed");
|
||||
require(native_test_setup(slot, &identity, true), "reset isolation control setup failed");
|
||||
}
|
||||
const auto reset = port_feature(target, 4, true);
|
||||
require(native_test_setup(0, &reset, true), "port reset request failed");
|
||||
acknowledge(0, false);
|
||||
uint8_t packet[64]; uint16_t length;
|
||||
require(native_test_in(target, packet, &length, false) && length == 64,
|
||||
"could not queue the reset child's old control completion");
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
const uint8_t data = slot;
|
||||
require(native_test_private_in(slot, 0x81, packet, &length) &&
|
||||
native_test_private_out(slot, 2, &data, 1, false), "reset isolation completion setup failed");
|
||||
}
|
||||
native_test_drain();
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
const unsigned expected = slot == target ? 0 : 1;
|
||||
require(native_test_hid_completions[slot - 1] == expected &&
|
||||
native_test_received_count[slot - 1][1] == expected,
|
||||
"port reset revoked a sibling event or dispatched a stale child event");
|
||||
if (slot != target) {
|
||||
const auto bytes = receive(slot);
|
||||
require(bytes == std::vector<uint8_t>(child_identity[slot - 1].begin(), child_identity[slot - 1].end()),
|
||||
"port reset corrupted a sibling control transfer");
|
||||
}
|
||||
}
|
||||
const uint8_t other = target == PROBE_CONTROLLER_COUNT ? 1 : target + 1;
|
||||
const auto concurrent_reset = port_feature(other, 4, true);
|
||||
require(!native_test_setup(0, &concurrent_reset, true),
|
||||
"simultaneous port resets created competing address-zero owners");
|
||||
native_test_advance(10000);
|
||||
require(!native_test_in(target, packet, &length, true) && !native_hub_mounted(target - 1),
|
||||
"port reset retained a stale control packet or configuration");
|
||||
assign_address(target, 17u * target);
|
||||
configure(target);
|
||||
read_child(target);
|
||||
}
|
||||
native_test_initialize();
|
||||
}
|
||||
|
||||
void require_interleaved_profile(const std::vector<uint8_t>& expected) {
|
||||
const auto setup = request(Operation::kProfileRead, true, kMaximumResponseSize);
|
||||
require(native_test_setup(0, &setup, true), "interleaved profile read setup failed");
|
||||
std::vector<uint8_t> bytes;
|
||||
const size_t total = kResponseHeaderSize + expected.size();
|
||||
for (unsigned index = 0; bytes.size() < total; ++index) {
|
||||
// Every root IN follows another owner's tokens, including the first.
|
||||
read_child(1u + index % PROBE_CONTROLLER_COUNT);
|
||||
uint8_t packet[64]; uint16_t length = 0;
|
||||
require(native_test_in(0, packet, &length, false),
|
||||
"prepared profile packet required foreground work after selection");
|
||||
require(length == std::min<size_t>(64, total - bytes.size()),
|
||||
"address alternation changed the profile packet boundary");
|
||||
bytes.insert(bytes.end(), packet, packet + length);
|
||||
native_test_drain(); // Only the completed packet may prepare its successor.
|
||||
}
|
||||
read_child(PROBE_CONTROLLER_COUNT);
|
||||
require(native_test_out(0, nullptr, 0, true), "interleaved profile status OUT failed");
|
||||
require(std::memcmp(bytes.data(), "SPMG", 4) == 0 &&
|
||||
bytes[5] == static_cast<uint8_t>(Operation::kProfileRead) &&
|
||||
bytes[6] == static_cast<uint8_t>(Status::kOk) && u16(bytes, 8) == expected.size() &&
|
||||
u32(bytes, 16) == configuration_crc32(expected.data(), expected.size()) &&
|
||||
std::vector<uint8_t>(bytes.begin() + kResponseHeaderSize, bytes.end()) == expected,
|
||||
"alternating root and child reads mixed profile or identity bytes");
|
||||
}
|
||||
|
||||
void test_profile_transport() {
|
||||
const uint32_t programs_before = programs, erases_before = erases;
|
||||
const auto original = encoded_profile(0);
|
||||
|
|
@ -168,9 +412,9 @@ void test_profile_transport() {
|
|||
auto playtest = read_operation(Operation::kProfilePlaytest);
|
||||
require(playtest[kResponseHeaderSize] == 0 && playtest[kResponseHeaderSize + 1] == 0xff,
|
||||
"disconnected playtest fabricated controller input");
|
||||
require_profile(original);
|
||||
require_interleaved_profile(original);
|
||||
require(programs == programs_before && erases == erases_before, "editor reads wrote saved storage");
|
||||
for (uint8_t slot : {1, 2}) {
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
const auto management = request(Operation::kProfileList, true, kMaximumResponseSize);
|
||||
require(!native_test_setup(slot, &management, true), "native child accepted regular management");
|
||||
read_child(slot);
|
||||
|
|
@ -184,7 +428,7 @@ void test_profile_transport() {
|
|||
const auto chunk = envelope(Operation::kProfileChunk, chunk_payload(1, edited, 0));
|
||||
const auto setup = request(Operation::kProfileChunk, false, chunk.size());
|
||||
require(native_test_setup(0, &setup, true) && native_test_out(0, chunk.data(), 64, true), "first full OUT packet failed");
|
||||
read_child(1); read_child(2);
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) read_child(slot);
|
||||
const auto child_management = request(Operation::kInfo, true, kMaximumResponseSize);
|
||||
require(!native_test_setup(1, &child_management, true), "child INFO was accepted during a root write");
|
||||
require(native_test_out(0, chunk.data() + 64, chunk.size() - 64, true), "interleaved child requests corrupted root OUT tail");
|
||||
|
|
@ -296,13 +540,16 @@ void test_private_transmit_survives_round_robin_tokens() {
|
|||
tusb_control_request_t configuration{};
|
||||
configuration.bRequest = TUSB_REQ_SET_CONFIGURATION;
|
||||
configuration.wValue = 1;
|
||||
for (uint8_t slot : {1, 2}) {
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
require(native_test_setup(slot, &configuration, true), "child configuration failed");
|
||||
acknowledge(slot);
|
||||
}
|
||||
const uint8_t payloads[2][3] = {{0x11, 0x22, 0x33}, {0x44, 0x55, 0x66}};
|
||||
for (uint8_t instance : {0, 1}) {
|
||||
require(native_hub_hid_report(instance, instance ? 7 : 8, payloads[instance], 3),
|
||||
uint8_t payloads[PROBE_CONTROLLER_COUNT][3];
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
payloads[instance][0] = 0x11u + instance;
|
||||
payloads[instance][1] = 0x42u + instance;
|
||||
payloads[instance][2] = 0x83u + instance;
|
||||
require(native_hub_hid_report(instance, 8u - instance, payloads[instance], 3),
|
||||
"could not queue HID packet");
|
||||
require(native_hub_vendor_write(instance, payloads[instance], 3) == 3 &&
|
||||
native_hub_vendor_write_flush(instance) == 3, "could not queue bulk packet");
|
||||
|
|
@ -310,26 +557,30 @@ void test_private_transmit_survives_round_robin_tokens() {
|
|||
uint8_t packet[64];
|
||||
uint16_t length = 0;
|
||||
for (uint8_t endpoint : {0x81, 0x82}) {
|
||||
for (uint8_t slot : {1, 2}) {
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
require(native_test_private_in(slot, endpoint, packet, &length),
|
||||
"queued private IN packet required foreground work after bank selection");
|
||||
const unsigned prefix = endpoint == 0x81 ? 1 : 0;
|
||||
require(length == 3 + prefix &&
|
||||
(!prefix || packet[0] == (slot == 1 ? 8 : 7)) &&
|
||||
(!prefix || packet[0] == 9u - slot) &&
|
||||
std::memcmp(packet + prefix, payloads[slot - 1], 3) == 0,
|
||||
"round-robin IN token received another endpoint's payload");
|
||||
require(!native_test_private_in(slot, endpoint, packet, &length),
|
||||
"unarmed endpoint reused another child's IN packet instead of NAK");
|
||||
}
|
||||
}
|
||||
native_test_drain();
|
||||
require(native_hub_hid_ready(0) && native_hub_hid_ready(1),
|
||||
"acknowledged HID packets did not release their queues");
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance)
|
||||
require(native_hub_hid_ready(instance) && native_test_hid_completions[instance] == 1 &&
|
||||
native_test_bulk_completions[instance] == 1,
|
||||
"acknowledged packets did not release exactly one completion per endpoint");
|
||||
require(!native_test_private_in(1, 0x81, packet, &length),
|
||||
"acknowledged HID packet was retransmitted");
|
||||
// The idle poll selected R without restoring its shared EP0 image.
|
||||
// An idle EP0 bank must not block newly queued private endpoint traffic.
|
||||
require(native_hub_hid_report(0, 8, payloads[0], 3), "could not queue the next HID packet");
|
||||
require(native_test_private_in(1, 0x81, packet, &length) && length == 4 &&
|
||||
std::memcmp(packet + 1, payloads[0], 3) == 0,
|
||||
"pending shared EP0 restoration blocked a newly queued private IN packet");
|
||||
"idle shared EP0 blocked a newly queued private IN packet");
|
||||
native_test_drain();
|
||||
native_test_initialize();
|
||||
}
|
||||
|
|
@ -338,62 +589,196 @@ void test_masked_irq_completion_handoff() {
|
|||
tusb_control_request_t configuration{};
|
||||
configuration.bRequest = TUSB_REQ_SET_CONFIGURATION;
|
||||
configuration.wValue = 1;
|
||||
for (uint8_t slot : {1, 2}) {
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
require(native_test_setup(slot, &configuration, true), "child configuration failed");
|
||||
acknowledge(slot);
|
||||
}
|
||||
const uint8_t payloads[2][3] = {{0x12, 0x34, 0x56}, {0x78, 0x9a, 0xbc}};
|
||||
for (uint8_t instance : {0, 1})
|
||||
uint8_t payloads[PROBE_CONTROLLER_COUNT][3];
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
payloads[instance][0] = 0x12u + instance;
|
||||
payloads[instance][1] = 0x34u + instance;
|
||||
payloads[instance][2] = 0x56u + instance;
|
||||
require(native_hub_hid_report(instance, 8, payloads[instance], 3),
|
||||
"could not queue masked-window HID packet");
|
||||
}
|
||||
uint8_t packet[64];
|
||||
uint16_t length = 0;
|
||||
native_test_interrupt_mask = 1;
|
||||
require(native_test_private_in(1, 0x81, packet, &length),
|
||||
"first controller did not complete during masked window");
|
||||
require(!native_test_select(2),
|
||||
"pending completion must prevent overwriting the active bank");
|
||||
native_hub_service_pending_usb();
|
||||
require(native_test_interrupt_mask == 1,
|
||||
"SRAM service must preserve the caller's interrupt mask");
|
||||
require(native_test_private_in(2, 0x81, packet, &length) && length == 4 &&
|
||||
packet[0] == 8 && std::memcmp(packet + 1, payloads[1], 3) == 0,
|
||||
"SRAM service did not permit the other controller's real packet");
|
||||
native_hub_service_pending_usb();
|
||||
require(!native_hub_hid_ready(0) && !native_hub_hid_ready(1),
|
||||
"SRAM service must defer protocol callbacks to foreground dispatch");
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
require(native_test_private_in(slot, 0x81, packet, &length) && length == 4 &&
|
||||
packet[0] == 8 && std::memcmp(packet + 1, payloads[slot - 1], 3) == 0,
|
||||
"controller did not retain its packet during the masked window");
|
||||
const uint8_t next = slot == PROBE_CONTROLLER_COUNT ? 1 : slot + 1;
|
||||
require(!native_test_select(next),
|
||||
"pending completion must prevent overwriting the active bank");
|
||||
native_hub_service_pending_usb();
|
||||
require(native_test_interrupt_mask == 1,
|
||||
"SRAM service must preserve the caller's interrupt mask");
|
||||
require(!native_hub_hid_ready(slot - 1) && native_test_hid_completions[slot - 1] == 0,
|
||||
"SRAM service must defer protocol callbacks to foreground dispatch");
|
||||
}
|
||||
native_test_interrupt_mask = 0;
|
||||
native_test_drain();
|
||||
require(native_hub_hid_ready(0) && native_hub_hid_ready(1),
|
||||
"deferred completions did not release both controller queues");
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance)
|
||||
require(native_hub_hid_ready(instance) && native_test_hid_completions[instance] == 1,
|
||||
"deferred completions did not release every controller queue exactly once");
|
||||
require(!native_test_private_in(1, 0x81, packet, &length),
|
||||
"later IRQ dispatch duplicated a serviced completion");
|
||||
native_test_initialize();
|
||||
}
|
||||
|
||||
|
||||
void test_private_bootsel() {
|
||||
void require_no_bootsel() {
|
||||
bootsel_time_ms += 100;
|
||||
probe_bootsel_task(bootsel_time_ms);
|
||||
probe_bootsel_task(bootsel_time_ms + 50);
|
||||
require(bootsel_calls == 0, "unauthorized or unacknowledged BOOTSEL rebooted the device");
|
||||
}
|
||||
|
||||
void test_neutral_management_surface() {
|
||||
require(!synthetic_root_management, "neutral surface must use the production BOOTSEL-only callback");
|
||||
const uint32_t programs_before = programs, erases_before = erases;
|
||||
struct WriteRequest { Operation operation; uint16_t payload_size; };
|
||||
const WriteRequest writes[] = {
|
||||
{Operation::kModeSet, 5}, {Operation::kReboot, 4},
|
||||
{Operation::kConfigurationBegin, 12}, {Operation::kConfigurationChunk, 9},
|
||||
{Operation::kConfigurationCommit, 4}, {Operation::kConfigurationReset, 4},
|
||||
{Operation::kProfileSelect, 15}, {Operation::kProfileBegin, 28},
|
||||
{Operation::kProfileChunk, 9}, {Operation::kProfileCommit, 4},
|
||||
{Operation::kProfileReset, 19}, {Operation::kProfileActivate, 19},
|
||||
{Operation::kProfileMetadataSet, 20}, {Operation::kProfileIdentify, 14},
|
||||
{Operation::kWiiOrientation, 19}, {Operation::kPairingRefresh, 0},
|
||||
{Operation::kPairingClear, 0},
|
||||
};
|
||||
for (uint8_t slot = 0; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
for (Operation op : {Operation::kInfo, Operation::kConfigurationRead,
|
||||
Operation::kTransactionStatus, Operation::kPairingRead,
|
||||
Operation::kRuntimeDiagnostics, Operation::kProfileList,
|
||||
Operation::kProfileRead, Operation::kProfilePlaytest,
|
||||
Operation::kProfileTransactionStatus, Operation::kProfileMetadataRead}) {
|
||||
const auto setup = request(op, true, kMaximumResponseSize);
|
||||
require(!native_test_setup(slot, &setup, true), "neutral device exposed full management reads");
|
||||
}
|
||||
for (const auto& item : writes) {
|
||||
const auto setup = request(item.operation, false, kRequestHeaderSize + item.payload_size);
|
||||
require(!native_test_setup(slot, &setup, true), "neutral device exposed a management mutation");
|
||||
}
|
||||
if (slot) read_child(slot);
|
||||
}
|
||||
profile_service_task_on_storage_core(5000);
|
||||
require(programs == programs_before && erases == erases_before,
|
||||
"neutral management rejection changed saved profiles");
|
||||
require_no_bootsel();
|
||||
}
|
||||
|
||||
void test_private_bootsel(uint8_t reboot_slot) {
|
||||
require(!synthetic_root_management, "BOOTSEL must use the production transport callback");
|
||||
const uint32_t programs_before = programs, erases_before = erases;
|
||||
const auto bytes = envelope(Operation::kBootselReboot, {});
|
||||
const auto setup = request(Operation::kBootselReboot, false, bytes.size());
|
||||
for (uint8_t slot : {0, 1, 2}) {
|
||||
require(native_test_setup(slot, &setup, true), "private BOOTSEL setup stalled");
|
||||
require(!native_test_out(slot, bytes.data(), bytes.size() - 1, true), "short BOOTSEL was accepted");
|
||||
probe_bootsel_task(100); probe_bootsel_task(200);
|
||||
require(bootsel_calls == 0, "short BOOTSEL rebooted the device");
|
||||
require(native_test_setup(slot, &setup, true) && native_test_out(slot, bytes.data(), bytes.size(), true),
|
||||
"valid private BOOTSEL envelope failed");
|
||||
// An unrelated identity/INFO SETUP cancels an unacknowledged BOOTSEL.
|
||||
if (slot) read_child(slot); else read_operation(Operation::kInfo);
|
||||
probe_bootsel_task(300); probe_bootsel_task(400);
|
||||
require(bootsel_calls == 0, "unacknowledged BOOTSEL rebooted the device");
|
||||
tusb_control_request_t replacement{};
|
||||
replacement.bmRequestType = 0x80;
|
||||
replacement.bRequest = TUSB_REQ_GET_STATUS;
|
||||
replacement.wLength = 2;
|
||||
uint8_t packet[64]; uint16_t length;
|
||||
for (uint8_t slot = 0; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
|
||||
for (uint16_t size : {uint16_t{0}, uint16_t{kRequestHeaderSize - 1}}) {
|
||||
require(native_test_setup(slot, &setup, true), "private BOOTSEL setup stalled");
|
||||
require(!native_test_in(slot, packet, &length, true), "BOOTSEL armed status before receiving its envelope");
|
||||
require(!native_test_out(slot, bytes.data(), size, true), "short BOOTSEL was accepted");
|
||||
require(!native_test_in(slot, packet, &length, true), "short BOOTSEL armed a status ACK");
|
||||
require_no_bootsel();
|
||||
}
|
||||
// Every reserved field and CRC byte must be checked by the shared decoder.
|
||||
for (size_t offset : {0, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}) {
|
||||
auto malformed = bytes;
|
||||
malformed[offset] ^= 1;
|
||||
require(native_test_setup(slot, &setup, true) &&
|
||||
native_test_out(slot, bytes.data(), bytes.size(), true), "superseded BOOTSEL setup failed");
|
||||
require(native_test_setup(slot, &setup, true), "malformed BOOTSEL setup stalled");
|
||||
require(!native_test_out(slot, malformed.data(), malformed.size(), true), "malformed BOOTSEL was accepted");
|
||||
require(!native_test_in(slot, packet, &length, true), "malformed BOOTSEL reused an earlier authorization");
|
||||
require_no_bootsel();
|
||||
}
|
||||
std::array<tusb_control_request_t, 8> wrong_setup;
|
||||
wrong_setup.fill(setup);
|
||||
wrong_setup[0].bmRequestType = 0x41; // Interface recipient.
|
||||
wrong_setup[1].bmRequestType = 0x42; // Endpoint recipient.
|
||||
wrong_setup[2].bmRequestType = 0xc0; // Wrong direction.
|
||||
wrong_setup[3].wValue ^= 1;
|
||||
wrong_setup[4].wIndex ^= 1;
|
||||
wrong_setup[5].wLength = 0;
|
||||
wrong_setup[6].wLength = kRequestHeaderSize - 1;
|
||||
wrong_setup[7].wLength = kRequestHeaderSize + 1;
|
||||
for (const auto& invalid : wrong_setup) {
|
||||
require(native_test_setup(slot, &setup, true) &&
|
||||
native_test_out(slot, bytes.data(), bytes.size(), true), "interrupted BOOTSEL setup failed");
|
||||
require(!native_test_setup(slot, &invalid, true), "wrong BOOTSEL setup was accepted");
|
||||
require(!native_test_in(slot, packet, &length, true) &&
|
||||
!native_test_out(slot, bytes.data(), bytes.size(), true), "rejected SETUP retained an old BOOTSEL transfer");
|
||||
require_no_bootsel();
|
||||
}
|
||||
// Standard requests do not call the vendor handler: transport ownership
|
||||
// must still revoke both incomplete DATA and unacknowledged status.
|
||||
for (bool send_data : {false, true}) {
|
||||
require(native_test_setup(slot, &setup, true), "interruptible BOOTSEL setup stalled");
|
||||
if (send_data)
|
||||
require(native_test_out(slot, bytes.data(), bytes.size(), true), "interruptible BOOTSEL DATA failed");
|
||||
require(native_test_setup(slot, &replacement, true), "replacement standard request stalled");
|
||||
require(receive(slot).size() == 2, "replacement standard transfer did not complete");
|
||||
require(!native_test_in(slot, packet, &length, true) &&
|
||||
!native_test_out(slot, bytes.data(), bytes.size(), true), "superseded BOOTSEL retained a transfer");
|
||||
require_no_bootsel();
|
||||
}
|
||||
// Reset revokes queued DATA, validated DATA, and even a captured status
|
||||
// ACK that has not reached the foreground callback yet.
|
||||
for (unsigned phase : {0, 1, 2}) {
|
||||
require(native_test_setup(slot, &setup, true) &&
|
||||
native_test_out(slot, bytes.data(), bytes.size(), phase != 0), "resettable BOOTSEL setup failed");
|
||||
if (phase == 2) acknowledge(slot, false);
|
||||
native_test_bus_reset(true);
|
||||
require(!native_test_in(slot, packet, &length, true), "bus reset retained BOOTSEL status");
|
||||
require_no_bootsel();
|
||||
}
|
||||
}
|
||||
require(native_test_setup(2, &setup, true) && native_test_out(2, bytes.data(), bytes.size(), true),
|
||||
"validated child BOOTSEL failed");
|
||||
acknowledge(2);
|
||||
probe_bootsel_task(500); probe_bootsel_task(549);
|
||||
require(bootsel_calls == 0, "BOOTSEL did not retain the post-ACK delay");
|
||||
probe_bootsel_task(550);
|
||||
require(bootsel_calls == 1, "validated child BOOTSEL did not reach ROM after the delay");
|
||||
// Concurrent children must not share the valid envelope or authorization.
|
||||
for (uint8_t slot : {uint8_t{1}, uint8_t{PROBE_CONTROLLER_COUNT}})
|
||||
require(native_test_setup(slot, &setup, true), "concurrent BOOTSEL setup failed");
|
||||
require(native_test_out(1, bytes.data(), bytes.size(), true), "first child's BOOTSEL DATA failed");
|
||||
auto corrupt = bytes;
|
||||
corrupt[12] ^= 1;
|
||||
require(!native_test_out(PROBE_CONTROLLER_COUNT, corrupt.data(), corrupt.size(), true),
|
||||
"last child inherited its sibling's BOOTSEL authorization");
|
||||
native_test_bus_reset(true);
|
||||
require_no_bootsel();
|
||||
|
||||
if (reboot_slot == 0) {
|
||||
require(native_test_startup(), "root-only BOOTSEL startup failed");
|
||||
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot)
|
||||
require(!native_test_select(slot), "root-only recovery unexpectedly requires an enumerated child");
|
||||
} else {
|
||||
native_test_initialize();
|
||||
}
|
||||
require(native_test_setup(reboot_slot, &setup, true), "valid BOOTSEL setup failed");
|
||||
require_no_bootsel();
|
||||
require(native_test_out(reboot_slot, bytes.data(), bytes.size(), true), "valid BOOTSEL DATA failed");
|
||||
require_no_bootsel();
|
||||
acknowledge(reboot_slot, false);
|
||||
require_no_bootsel();
|
||||
// Unlike reset, the next SETUP preserves a genuine, already-captured ACK.
|
||||
require(native_test_setup(reboot_slot, &replacement, false), "post-ACK SETUP failed");
|
||||
native_test_drain();
|
||||
require(receive(reboot_slot).size() == 2, "post-ACK standard transfer failed");
|
||||
const uint32_t now = bootsel_time_ms + 100;
|
||||
probe_bootsel_task(now); probe_bootsel_task(now + 49);
|
||||
require(bootsel_calls == 0, "BOOTSEL did not retain the post-ACK 50ms delay");
|
||||
probe_bootsel_task(now + 50);
|
||||
require(bootsel_calls == 1, "validated BOOTSEL did not reach ROM after the delay");
|
||||
probe_bootsel_task(now + 100);
|
||||
require(bootsel_calls == 1, "BOOTSEL dispatched more than once");
|
||||
profile_service_task_on_storage_core(now + 100);
|
||||
require(programs == programs_before && erases == erases_before,
|
||||
"private BOOTSEL changed saved profiles");
|
||||
}
|
||||
|
||||
bool flash_read(void*, uint8_t arena, size_t offset, uint8_t* data, size_t size) {
|
||||
|
|
@ -445,7 +830,11 @@ bool bluepad32_input_backend_capture_page(uint32_t, uint16_t, Bluepad32CaptureSn
|
|||
extern "C" void reset_usb_boot(uint32_t, uint32_t) { ++bootsel_calls; }
|
||||
extern "C" bool tud_vendor_control_xfer_cb(uint8_t slot, uint8_t stage, const tusb_control_request_t* setup) {
|
||||
if (probe_management_vendor_control(slot, stage, setup)) return true;
|
||||
if (slot < 1 || slot > 2 || setup->bmRequestType != 0xc0 ||
|
||||
// Exercise the full root service over a synthetic four-child transport
|
||||
// without claiming that the neutral firmware exposes that service.
|
||||
if (synthetic_root_management && slot == 0 &&
|
||||
usb_configuration_management_vendor_control(slot, stage, setup)) return true;
|
||||
if (slot < 1 || slot > PROBE_CONTROLLER_COUNT || setup->bmRequestType != 0xc0 ||
|
||||
setup->bRequest != 3 || setup->wValue || setup->wIndex) return false;
|
||||
if (stage == CONTROL_STAGE_ACK && slot == 1 && interleave_identity_ack) {
|
||||
interleave_identity_ack = false;
|
||||
|
|
@ -456,20 +845,31 @@ extern "C" bool tud_vendor_control_xfer_cb(uint8_t slot, uint8_t stage, const tu
|
|||
require(native_test_select(0), "read ACK callback blocked servicing the next USB SETUP");
|
||||
}
|
||||
return stage != CONTROL_STAGE_SETUP || native_hub_control_xfer(slot, setup,
|
||||
child_identity[slot - 1].data(), child_identity[slot - 1].size());
|
||||
child_identity[slot - 1].data(), child_identity[slot - 1].size(), true);
|
||||
}
|
||||
|
||||
int main() {
|
||||
int main(int argc, char** argv) {
|
||||
static_assert(sizeof(tusb_control_request_t) == 8);
|
||||
flash.fill(0xff); child_identity[0].fill(0x31); child_identity[1].fill(0x72);
|
||||
require(argc == 2 && (std::strcmp(argv[1], "root") == 0 || std::strcmp(argv[1], "child") == 0),
|
||||
"select the root or last-child BOOTSEL completion scenario");
|
||||
const uint8_t reboot_slot = std::strcmp(argv[1], "root") == 0 ? 0 : PROBE_CONTROLLER_COUNT;
|
||||
flash.fill(0xff);
|
||||
for (unsigned instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance)
|
||||
child_identity[instance].fill(0x31u + instance * 0x21u);
|
||||
profile_service_prepare(); profile_service_initialize_on_storage_core();
|
||||
native_test_initialize();
|
||||
synthetic_root_management = SWITCH2_PROBE_NEUTRAL_INPUT;
|
||||
test_profile_transport();
|
||||
test_interrupted_transactions();
|
||||
test_pending_control_buffer_ownership();
|
||||
synthetic_root_management = false;
|
||||
test_read_ack_allows_usb_progress();
|
||||
test_private_transmit_survives_round_robin_tokens();
|
||||
test_masked_irq_completion_handoff();
|
||||
test_private_bootsel();
|
||||
std::cout << "native root management packet and persistence regressions passed\n";
|
||||
test_port_enumeration_and_bounds();
|
||||
test_child_control_and_receive_isolation();
|
||||
test_port_reset_revokes_only_its_child_events();
|
||||
if (SWITCH2_PROBE_NEUTRAL_INPUT) test_neutral_management_surface();
|
||||
test_private_bootsel(reboot_slot);
|
||||
std::cout << "native transport, synthetic root management and private BOOTSEL regressions passed\n";
|
||||
}
|
||||
|
|
|
|||
160
tests/native_hub_router_test.c
Normal file
160
tests/native_hub_router_test.c
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
#include "hardware_stub.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
|
||||
// The real router tables and token-header decision run on the host. Only the
|
||||
// clock/pad registers and the SIE bank-selection receiver are modeled here;
|
||||
// the timing loop is compiled but never run against a simulated USB wire.
|
||||
#define PICO_RP2350 1
|
||||
#undef SIO_GPIO_HI_IN_USB_DP_BITS
|
||||
#undef SIO_GPIO_HI_IN_USB_DM_BITS
|
||||
#define SIO_GPIO_HI_IN_USB_DP_BITS (1u << 24)
|
||||
#define SIO_GPIO_HI_IN_USB_DM_BITS (1u << 25)
|
||||
#define SIO_MTIME_CTRL_EN_BITS 1u
|
||||
#define SIO_MTIME_CTRL_FULLSPEED_BITS 2u
|
||||
#define __wfe() ((void)0)
|
||||
#define __dsb() ((void)0)
|
||||
#define __isb() ((void)0)
|
||||
static struct {
|
||||
volatile uint32_t mtime, mtimeh, mtimecmp, mtimecmph, mtime_ctrl, gpio_hi_in;
|
||||
} router_test_sio;
|
||||
#undef sio_hw
|
||||
#define sio_hw (&router_test_sio)
|
||||
#include "router.c"
|
||||
|
||||
usb_hw_t native_test_usb;
|
||||
uint32_t native_test_interrupt_mask;
|
||||
static unsigned selections;
|
||||
static uint8_t selected_address, selected_owner;
|
||||
static bool accept_selection = true;
|
||||
|
||||
void native_test_service_interrupt(void) {}
|
||||
bool native_hub_select_device(uint8_t address, uint8_t owner, uint32_t cutoff) {
|
||||
(void)cutoff;
|
||||
++selections;
|
||||
selected_address = address;
|
||||
selected_owner = owner;
|
||||
return accept_selection;
|
||||
}
|
||||
|
||||
static const routing_table* current_table(void) {
|
||||
uint32_t generation;
|
||||
return acquire_table(&generation);
|
||||
}
|
||||
|
||||
static void expect_route(const routing_table* table, unsigned address, uint8_t owner) {
|
||||
selections = 0;
|
||||
raw_packet packet = {0};
|
||||
route_header(table,address,TOKEN_SETUP_SIGNATURE,127,100,&packet);
|
||||
uint32_t sequence;
|
||||
assert(probe_router_setup_slot(&sequence) == owner);
|
||||
probe_router_stats snapshot;
|
||||
probe_router_snapshot(&snapshot);
|
||||
assert(snapshot.last_setup_slot == owner && snapshot.last_setup_sequence == sequence);
|
||||
if (owner == PROBE_ROUTER_UNASSIGNED) {
|
||||
assert(selections == 0 && packet.retargets == 0);
|
||||
} else {
|
||||
assert(selections == 1 && selected_address == address && selected_owner == owner);
|
||||
assert(packet.retargets == (address != 127));
|
||||
}
|
||||
}
|
||||
|
||||
static uint8_t address_wire(unsigned address, unsigned kind) {
|
||||
// Independent LSB-first NRZI encoder, starting after the token PID's K.
|
||||
unsigned wire = 0, line = 0, ones = kind ? 3u : 0u, bit_index = 0;
|
||||
for (unsigned symbol = 0; symbol < 8; ++symbol) {
|
||||
unsigned bit;
|
||||
if (ones == 6) {
|
||||
bit = 0;
|
||||
} else {
|
||||
bit = bit_index < 7 ? (address >> bit_index) & 1u : 0u;
|
||||
++bit_index;
|
||||
}
|
||||
if (!bit) line ^= 1u;
|
||||
wire |= line << symbol;
|
||||
ones = bit ? ones + 1u : 0u;
|
||||
}
|
||||
return wire;
|
||||
}
|
||||
|
||||
static unsigned raw_prefix(uint8_t wire) {
|
||||
unsigned prefix = 0;
|
||||
for (unsigned bit = 0; bit < 4; ++bit)
|
||||
prefix |= ((wire >> bit) & 1u ? LINE_J : LINE_K) << (2u * bit);
|
||||
return prefix;
|
||||
}
|
||||
|
||||
static void expect_prefixes(const routing_table* table, const uint8_t* addresses) {
|
||||
for (unsigned kind = 0; kind < 2; ++kind) {
|
||||
for (uint8_t slot = 0; slot < PROBE_ROUTER_SLOTS; ++slot) {
|
||||
const uint8_t wire = address_wire(addresses[slot],kind);
|
||||
const unsigned prefix = raw_prefix(wire);
|
||||
unsigned matches = 0;
|
||||
for (uint8_t other = 0; other < PROBE_ROUTER_SLOTS; ++other)
|
||||
matches += raw_prefix(address_wire(addresses[other],kind)) == prefix;
|
||||
assert(table->early_address[kind][prefix] ==
|
||||
(matches == 1 ? addresses[slot] : PROBE_ROUTER_UNASSIGNED));
|
||||
expect_route(table,address_decoder[kind][wire],slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
probe_router_init(FS_CLOCK_HZ);
|
||||
// Simulate observer readiness, not USB timing; this enables the actual
|
||||
// routing decision without starting the hardware-bound sampling loop.
|
||||
counters.ready = 1;
|
||||
probe_router_enable(true);
|
||||
const routing_table* table = current_table();
|
||||
expect_route(table,0,0);
|
||||
for (unsigned address = 1; address < 128; ++address)
|
||||
expect_route(table,address,PROBE_ROUTER_UNASSIGNED);
|
||||
|
||||
uint8_t addresses[PROBE_ROUTER_SLOTS];
|
||||
addresses[0] = 9;
|
||||
for (uint8_t slot = 1; slot < PROBE_ROUTER_SLOTS; ++slot) addresses[slot] = 17u * slot;
|
||||
probe_router_publish(addresses,PROBE_ROUTER_UNASSIGNED);
|
||||
table = current_table();
|
||||
expect_prefixes(table,addresses);
|
||||
expect_route(table,0,PROBE_ROUTER_UNASSIGNED);
|
||||
expect_route(table,128,PROBE_ROUTER_UNASSIGNED);
|
||||
expect_route(table,255,PROBE_ROUTER_UNASSIGNED);
|
||||
|
||||
// Every child's address shares the first four symbols. No early owner may
|
||||
// be guessed, even though the full decoded addresses still route uniquely.
|
||||
for (uint8_t slot = 1; slot < PROBE_ROUTER_SLOTS; ++slot) addresses[slot] = 1u + 16u * slot;
|
||||
probe_router_publish(addresses,PROBE_ROUTER_UNASSIGNED);
|
||||
table = current_table();
|
||||
expect_prefixes(table,addresses);
|
||||
|
||||
// Slot 4 must not collide with the invalid sentinel or sequence carry.
|
||||
setup_publication = SETUP_SEQUENCE_MASK - 1u;
|
||||
expect_route(table,addresses[PROBE_ROUTER_SLOTS - 1],PROBE_ROUTER_SLOTS - 1);
|
||||
uint32_t sequence;
|
||||
assert(probe_router_setup_slot(&sequence) == PROBE_ROUTER_SLOTS - 1 && sequence == SETUP_SEQUENCE_MASK);
|
||||
expect_route(table,addresses[PROBE_ROUTER_SLOTS - 1],PROBE_ROUTER_SLOTS - 1);
|
||||
assert(probe_router_setup_slot(&sequence) == PROBE_ROUTER_SLOTS - 1 && sequence == 0);
|
||||
expect_route(table,127,PROBE_ROUTER_UNASSIGNED);
|
||||
assert(probe_router_setup_slot(&sequence) == PROBE_ROUTER_UNASSIGNED && sequence == 0);
|
||||
|
||||
accept_selection = false;
|
||||
selections = 0;
|
||||
raw_packet packet = {0};
|
||||
route_header(table,addresses[1],TOKEN_SETUP_SIGNATURE,127,100,&packet);
|
||||
assert(selections == 1 && packet.retargets == 0 &&
|
||||
probe_router_setup_slot(&sequence) == PROBE_ROUTER_UNASSIGNED);
|
||||
accept_selection = true;
|
||||
|
||||
addresses[1] = addresses[2];
|
||||
probe_router_publish(addresses,PROBE_ROUTER_SLOTS - 1);
|
||||
table = current_table();
|
||||
expect_route(table,addresses[1],PROBE_ROUTER_UNASSIGNED);
|
||||
expect_route(table,0,PROBE_ROUTER_SLOTS - 1);
|
||||
for (unsigned kind = 0; kind < 2; ++kind)
|
||||
for (unsigned prefix = 0; prefix < 256; ++prefix)
|
||||
assert(table->early_address[kind][prefix] != addresses[1]);
|
||||
probe_router_publish(addresses,PROBE_ROUTER_UNASSIGNED);
|
||||
expect_route(current_table(),0,PROBE_ROUTER_UNASSIGNED);
|
||||
printf("native router ownership regressions passed for %u slots\n",PROBE_ROUTER_SLOTS);
|
||||
return 0;
|
||||
}
|
||||
6
tests/native_hub_stubs/hardware/uart.h
Normal file
6
tests/native_hub_stubs/hardware/uart.h
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#define uart0 ((void*)0)
|
||||
bool uart_is_writable(void* uart);
|
||||
void uart_putc_raw(void* uart, char value);
|
||||
1
tests/native_hub_stubs/pico.h
Normal file
1
tests/native_hub_stubs/pico.h
Normal file
|
|
@ -0,0 +1 @@
|
|||
#include "hardware_stub.h"
|
||||
2230
tests/native_hub_trace_test.c
Normal file
2230
tests/native_hub_trace_test.c
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,7 @@
|
|||
#include "hardware_stub.h"
|
||||
#include <stdlib.h>
|
||||
static uint32_t native_test_time_us = 1000000u;
|
||||
#define time_us_32() native_test_time_us
|
||||
#include "usb/native_hub/native_hub.c"
|
||||
|
||||
usb_hw_t native_test_usb;
|
||||
|
|
@ -6,6 +9,10 @@ usb_device_dpram_t native_test_dpram;
|
|||
sio_hw_t native_test_sio;
|
||||
bool native_test_abort_stuck;
|
||||
uint32_t native_test_interrupt_mask;
|
||||
uint32_t native_test_hid_completions[CHILDREN], native_test_bulk_completions[CHILDREN];
|
||||
uint32_t native_test_received_count[CHILDREN][2];
|
||||
uint16_t native_test_received_length[CHILDREN][2];
|
||||
uint8_t native_test_received_data[CHILDREN][2][PACKET];
|
||||
static bool servicing_interrupt;
|
||||
|
||||
void native_test_service_interrupt(void) {
|
||||
|
|
@ -22,7 +29,9 @@ void probe_router_publish(const uint8_t values[PROBE_ROUTER_SLOTS], uint8_t slot
|
|||
void probe_router_enable(bool enabled) { (void)enabled; }
|
||||
bool probe_router_set_phase(uint32_t phase) { (void)phase; return true; }
|
||||
void probe_router_snapshot(probe_router_stats* snapshot) { memset(snapshot,0,sizeof(*snapshot)); snapshot->ready = 1; }
|
||||
#ifndef NATIVE_TEST_EXTERNAL_LOG
|
||||
int probe_debug_printf(const char* format, ...) { (void)format; return 0; }
|
||||
#endif
|
||||
|
||||
const uint8_t* native_joycon_device_descriptor(uint8_t instance) { (void)instance; return hub_device; }
|
||||
const uint8_t* native_joycon_configuration_descriptor(uint8_t instance) { (void)instance; return hub_configuration; }
|
||||
|
|
@ -35,32 +44,66 @@ uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t id, hid_report_type_t t
|
|||
(void)instance; (void)id; (void)type; (void)data; (void)length; return 0;
|
||||
}
|
||||
void tud_hid_set_report_cb(uint8_t instance, uint8_t id, hid_report_type_t type, const uint8_t* data, uint16_t length) {
|
||||
(void)instance; (void)id; (void)type; (void)data; (void)length;
|
||||
(void)id; (void)type;
|
||||
if (instance >= CHILDREN || length > PACKET) abort();
|
||||
++native_test_received_count[instance][0];
|
||||
native_test_received_length[instance][0] = length;
|
||||
if (length) memcpy(native_test_received_data[instance][0],data,length);
|
||||
}
|
||||
void tud_hid_report_complete_cb(uint8_t instance, const uint8_t* data, uint16_t length) {
|
||||
(void)data; (void)length;
|
||||
if (instance >= CHILDREN) abort();
|
||||
++native_test_hid_completions[instance];
|
||||
}
|
||||
void tud_vendor_rx_cb(uint8_t instance, const uint8_t* data, uint16_t length) {
|
||||
if (instance >= CHILDREN || length > PACKET) abort();
|
||||
++native_test_received_count[instance][1];
|
||||
native_test_received_length[instance][1] = length;
|
||||
if (length) memcpy(native_test_received_data[instance][1],data,length);
|
||||
}
|
||||
void tud_vendor_tx_cb(uint8_t instance, uint32_t length) {
|
||||
(void)length;
|
||||
if (instance >= CHILDREN) abort();
|
||||
++native_test_bulk_completions[instance];
|
||||
}
|
||||
void tud_hid_report_complete_cb(uint8_t instance, const uint8_t* data, uint16_t length) { (void)instance; (void)data; (void)length; }
|
||||
void tud_vendor_rx_cb(uint8_t instance, const uint8_t* data, uint16_t length) { (void)instance; (void)data; (void)length; }
|
||||
void tud_vendor_tx_cb(uint8_t instance, uint32_t length) { (void)instance; (void)length; }
|
||||
|
||||
void native_test_initialize(void) {
|
||||
memset(devices,0,sizeof(devices));
|
||||
memset(ports,0,sizeof(ports));
|
||||
memset(usb_hw,0,sizeof(*usb_hw));
|
||||
memset(usb_dpram,0,sizeof(*usb_dpram));
|
||||
memset(native_test_hid_completions,0,sizeof(native_test_hid_completions));
|
||||
memset(native_test_bulk_completions,0,sizeof(native_test_bulk_completions));
|
||||
memset(native_test_received_count,0,sizeof(native_test_received_count));
|
||||
memset(native_test_received_length,0,sizeof(native_test_received_length));
|
||||
memset(native_test_received_data,0,sizeof(native_test_received_data));
|
||||
native_test_time_us = 1000000u;
|
||||
event_head = event_tail = 0;
|
||||
native_test_abort_stuck = false;
|
||||
native_test_interrupt_mask = 0;
|
||||
servicing_interrupt = false;
|
||||
failed = bus_suspended = bank_restore_pending = false;
|
||||
failed = bus_suspended = false;
|
||||
bank_lock = spin_lock_instance(0);
|
||||
active_device = default_device = 0;
|
||||
addresses[0] = 0; addresses[1] = 1; addresses[2] = 2;
|
||||
addresses[0] = 0;
|
||||
for (unsigned slot = 1; slot < DEVICES; ++slot) addresses[slot] = slot * 17u;
|
||||
started = root_configured_once = true;
|
||||
}
|
||||
|
||||
bool native_test_startup(void) {
|
||||
native_test_initialize();
|
||||
started = root_configured_once = false;
|
||||
return native_hub_init();
|
||||
}
|
||||
|
||||
void native_test_advance(uint32_t microseconds) {
|
||||
native_test_time_us += microseconds;
|
||||
native_hub_task();
|
||||
}
|
||||
|
||||
static bool select_slot(uint8_t slot) {
|
||||
if (!native_hub_select_device(addresses[slot],slot,UINT32_MAX / 2)) return false;
|
||||
restore_selected_bank();
|
||||
return true;
|
||||
if (slot >= DEVICES || addresses[slot] == NONE) return false;
|
||||
return native_hub_select_device(addresses[slot],slot,UINT32_MAX / 2);
|
||||
}
|
||||
|
||||
bool native_test_select(uint8_t slot) { return select_slot(slot); }
|
||||
|
|
@ -111,22 +154,42 @@ bool native_test_in(uint8_t slot, uint8_t* data, uint16_t* length, bool drain) {
|
|||
}
|
||||
|
||||
bool native_test_private_in(uint8_t slot, uint8_t endpoint, uint8_t* data, uint16_t* length) {
|
||||
if (slot < 1 || slot > 2 || (endpoint != 0x81 && endpoint != 0x82)) return false;
|
||||
if (slot >= DEVICES || addresses[slot] == NONE || (slot == 0 ? endpoint != 0x8f :
|
||||
(endpoint != 0x81 && endpoint != 0x82))) return false;
|
||||
// A host token selects the bank, but cannot wait for a foreground task.
|
||||
if (!native_hub_select_device(addresses[slot],slot,UINT32_MAX / 2)) return false;
|
||||
unsigned channel = (endpoint & 15u) * 2u;
|
||||
uint32_t control = endpoint_regs()[channel - 2u];
|
||||
uint32_t value = buffer_regs()[channel];
|
||||
unsigned channel = logical_channel(slot,endpoint);
|
||||
unsigned physical = physical_channel(slot,channel);
|
||||
uint32_t control = slot == 0 ? usb_dpram->ep_ctrl[14].in : endpoint_regs()[channel - 2u];
|
||||
uint32_t value = buffer_regs()[physical];
|
||||
if (!(control & EP_CTRL_ENABLE_BITS) || !(value & USB_BUF_CTRL_AVAIL) ||
|
||||
!(value & USB_BUF_CTRL_FULL) || (value & USB_BUF_CTRL_STALL)) return false;
|
||||
*length = value & USB_BUF_CTRL_LEN_MASK;
|
||||
if (*length > PACKET) return false;
|
||||
if (*length) copy_from_usb(data,
|
||||
(const volatile uint8_t*)USBCTRL_DPRAM_BASE + (control & 0xffffu), *length);
|
||||
buffer_regs()[channel] = value & ~USB_BUF_CTRL_AVAIL;
|
||||
buffer_regs()[physical] = value & ~USB_BUF_CTRL_AVAIL;
|
||||
usb_hw->buf_status |= 1u << physical;
|
||||
usb_hw->ints |= USB_INTS_BUFF_STATUS_BITS;
|
||||
native_test_service_interrupt();
|
||||
return !failed;
|
||||
}
|
||||
|
||||
bool native_test_private_out(uint8_t slot, uint8_t endpoint, const uint8_t* data, uint16_t length, bool drain) {
|
||||
if (slot < 1 || slot >= DEVICES || addresses[slot] == NONE ||
|
||||
(endpoint != 0x01 && endpoint != 0x02) || length > PACKET) return false;
|
||||
if (!native_hub_select_device(addresses[slot],slot,UINT32_MAX / 2)) return false;
|
||||
unsigned channel = logical_channel(slot,endpoint);
|
||||
uint32_t control = endpoint_regs()[channel - 2u];
|
||||
uint32_t value = buffer_regs()[channel];
|
||||
if (!(control & EP_CTRL_ENABLE_BITS) || !(value & USB_BUF_CTRL_AVAIL) ||
|
||||
(value & USB_BUF_CTRL_STALL)) return false;
|
||||
if (length) copy_to_usb((volatile uint8_t*)USBCTRL_DPRAM_BASE + (control & 0xffffu),data,length);
|
||||
buffer_regs()[channel] = (value & ~(USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_LEN_MASK)) | length;
|
||||
usb_hw->buf_status |= 1u << channel;
|
||||
usb_hw->ints |= USB_INTS_BUFF_STATUS_BITS;
|
||||
native_test_service_interrupt();
|
||||
if (drain) native_hub_task();
|
||||
return !failed;
|
||||
}
|
||||
|
||||
|
|
@ -136,5 +199,6 @@ void native_test_bus_reset(bool drain) {
|
|||
native_test_service_interrupt();
|
||||
if (drain) native_hub_task();
|
||||
// Assign fixture addresses after reset, independently of EP0 state.
|
||||
addresses[0] = 0; addresses[1] = 1; addresses[2] = 2;
|
||||
addresses[0] = 0;
|
||||
for (unsigned slot = 1; slot < DEVICES; ++slot) addresses[slot] = slot * 17u;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#include "input/bluepad32_input_backend.h"
|
||||
#include "input/switch2_mouse_capture.h"
|
||||
#include "platform/pico/bootsel_pairing_button.h"
|
||||
#include "platform/pico/system_clock.h"
|
||||
#include "parser/uni_hid_parser_switch2.h"
|
||||
#include "pico/stdlib.h"
|
||||
#include <array>
|
||||
|
|
|
|||
|
|
@ -8,20 +8,42 @@
|
|||
#include "model.h"
|
||||
#include "pico/stdlib.h"
|
||||
#include "platform/pico/bootsel_pairing_button.h"
|
||||
#include "platform/pico/system_clock.h"
|
||||
#include "profile/controller_profile_runtime.h"
|
||||
#include "profile/profile_service.h"
|
||||
|
||||
namespace {
|
||||
uint64_t now_us = 1000000;
|
||||
uint32_t stage;
|
||||
Bluepad32NativeGamepadSnapshot source;
|
||||
ControllerProfile profile;
|
||||
Bluepad32NativeGamepadSnapshot sources[BLUEPAD32_NATIVE_PAIR_COUNT];
|
||||
ControllerProfile profiles[BLUEPAD32_NATIVE_PAIR_COUNT];
|
||||
// Existing single-pair scenarios exercise PairA in both executable configurations.
|
||||
Bluepad32NativeGamepadSnapshot& source = sources[0];
|
||||
ControllerProfile& profile = profiles[0];
|
||||
bool selected[BLUEPAD32_NATIVE_PAIR_COUNT];
|
||||
uint64_t cue_tokens[PROBE_CONTROLLER_COUNT];
|
||||
uint64_t next_cue_token;
|
||||
uint32_t profile_generation = 1;
|
||||
bool alternating_shortcut;
|
||||
bool shortcut_phase;
|
||||
probe_controller_input controls[2];
|
||||
uint8_t reports[2][63];
|
||||
bool alternating_shortcuts[BLUEPAD32_NATIVE_PAIR_COUNT];
|
||||
bool shortcut_phases[BLUEPAD32_NATIVE_PAIR_COUNT];
|
||||
bool& alternating_shortcut = alternating_shortcuts[0];
|
||||
bool latching_shortcuts[BLUEPAD32_NATIVE_PAIR_COUNT];
|
||||
struct SlotShortcut {
|
||||
bool active = false;
|
||||
bool latched = false;
|
||||
uint32_t connection_generation = 0;
|
||||
};
|
||||
SlotShortcut slot_shortcuts[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT];
|
||||
probe_controller_input controls[PROBE_CONTROLLER_COUNT];
|
||||
uint8_t reports[PROBE_CONTROLLER_COUNT][63];
|
||||
|
||||
uint8_t source_pair(uint8_t slot) {
|
||||
for (uint8_t pair_index = 0; pair_index < BLUEPAD32_NATIVE_PAIR_COUNT; ++pair_index)
|
||||
if (sources[pair_index].controller.active && sources[pair_index].slot == slot) return pair_index;
|
||||
assert(false);
|
||||
return 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
uint32_t time_us_32() { return static_cast<uint32_t>(now_us); }
|
||||
absolute_time_t get_absolute_time() { return now_us; }
|
||||
|
|
@ -34,25 +56,58 @@ void bluepad32_input_backend_start() { stage = 2; }
|
|||
void bluepad32_input_backend_poll() {}
|
||||
void bluepad32_input_backend_diagnostics(Bluepad32BackendDiagnostics* out) { *out = {}; out->initialization_stage = stage; }
|
||||
void bluepad32_input_backend_open_pairing_window() {}
|
||||
void bluepad32_input_backend_select_native_source(const uint8_t*) {}
|
||||
void bluepad32_input_backend_native_snapshot(Bluepad32NativeGamepadSnapshot* out) { *out = source; }
|
||||
bool bluepad32_input_backend_native_sample_request(uint8_t, uint8_t, uint64_t*) { return false; }
|
||||
int bluepad32_input_backend_native_sample_result(uint8_t, uint64_t) { return -1; }
|
||||
void bluepad32_input_backend_native_sample_cancel(uint8_t) {}
|
||||
void bluepad32_input_backend_select_native_source(uint8_t pair_index, const uint8_t*) {
|
||||
assert(pair_index < BLUEPAD32_NATIVE_PAIR_COUNT);
|
||||
selected[pair_index] = true;
|
||||
}
|
||||
void bluepad32_input_backend_native_snapshot(uint8_t pair_index, Bluepad32NativeGamepadSnapshot* out) {
|
||||
assert(pair_index < BLUEPAD32_NATIVE_PAIR_COUNT);
|
||||
*out = selected[pair_index] ? sources[pair_index] : Bluepad32NativeGamepadSnapshot{};
|
||||
}
|
||||
bool bluepad32_input_backend_native_sample_request(uint8_t instance, uint8_t, uint64_t* token) {
|
||||
if (instance >= PROBE_CONTROLLER_COUNT || !sources[instance / 2].controller.active || !token) return false;
|
||||
*token = cue_tokens[instance] = ++next_cue_token;
|
||||
return true;
|
||||
}
|
||||
int bluepad32_input_backend_native_sample_result(uint8_t instance, uint64_t token) {
|
||||
return instance < PROBE_CONTROLLER_COUNT && token && cue_tokens[instance] == token ? 1 : -1;
|
||||
}
|
||||
void bluepad32_input_backend_native_sample_cancel(uint8_t instance) {
|
||||
assert(instance < PROBE_CONTROLLER_COUNT);
|
||||
cue_tokens[instance] = 0;
|
||||
}
|
||||
void bluepad32_input_backend_queue_profile_feedback(uint8_t, uint32_t, uint8_t, ControllerProfileConfirmationPolicy) {}
|
||||
void controller_profile_runtime_reset() { profile = controller_profile_default(controller_identity_global(), 0); }
|
||||
void controller_profile_runtime_reset() {
|
||||
for (ControllerProfile& value : profiles)
|
||||
value = controller_profile_default(controller_identity_global(), 0);
|
||||
}
|
||||
uint32_t profile_service_database_generation() { return profile_generation; }
|
||||
bool controller_profile_runtime_take_initial_profile_indication(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; }
|
||||
bool controller_profile_runtime_take_profile_change(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; }
|
||||
ControllerProfileTransformResult controller_profile_runtime_transform(
|
||||
uint8_t, const Bluepad32SlotSnapshot& input, uint32_t, AdapterUsbMode) {
|
||||
if (!input.active) return {};
|
||||
auto result = controller_profile_transform(input.state, profile);
|
||||
if (alternating_shortcut) {
|
||||
uint8_t slot, const Bluepad32SlotSnapshot& input, uint32_t, AdapterUsbMode) {
|
||||
if (!input.active) {
|
||||
slot_shortcuts[slot] = {};
|
||||
return {};
|
||||
}
|
||||
const uint8_t pair_index = source_pair(slot);
|
||||
auto result = controller_profile_transform(input.state, profiles[pair_index]);
|
||||
if (alternating_shortcuts[pair_index]) {
|
||||
// Model a runtime synthetic transition spanning the two halves. Two
|
||||
// evaluations for one paired report would expose contradictory states.
|
||||
shortcut_phase = !shortcut_phase;
|
||||
result.state.button_system = result.state.button_capture = shortcut_phase;
|
||||
shortcut_phases[pair_index] = !shortcut_phases[pair_index];
|
||||
result.state.button_system = result.state.button_capture = shortcut_phases[pair_index];
|
||||
}
|
||||
if (latching_shortcuts[pair_index]) {
|
||||
// Model a macro/Shift latch owned by a runtime SLOT, not a USB pair.
|
||||
auto& shortcut = slot_shortcuts[slot];
|
||||
if (!shortcut.active || shortcut.connection_generation != input.connection_generation) {
|
||||
shortcut = {};
|
||||
shortcut.active = true;
|
||||
shortcut.connection_generation = input.connection_generation;
|
||||
}
|
||||
if (input.state.button_select) shortcut.latched = true;
|
||||
result.state.button_system = result.state.button_capture = shortcut.latched;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -92,16 +147,21 @@ void quaternion(uint8_t instance, double out[4]) {
|
|||
out[largest] = 1 / sqrt(norm);
|
||||
for (unsigned i = 0; i < 3; ++i) out[(largest + i + 1) & 3] = ratios[i] * out[largest];
|
||||
}
|
||||
void publish(bool motion = true) {
|
||||
now_us += 4000;
|
||||
source.received_us = time_us_32();
|
||||
++source.state_generation;
|
||||
void publish_at_current_time(uint8_t pair_index, bool motion) {
|
||||
Bluepad32NativeGamepadSnapshot& snapshot = sources[pair_index];
|
||||
snapshot.received_us = time_us_32();
|
||||
++snapshot.state_generation;
|
||||
if (motion) {
|
||||
source.accel_received_us = source.gyro_received_us = time_us_32();
|
||||
++source.accel_sequence;
|
||||
++source.gyro_sequence;
|
||||
snapshot.accel_received_us = snapshot.gyro_received_us = time_us_32();
|
||||
++snapshot.accel_sequence;
|
||||
++snapshot.gyro_sequence;
|
||||
}
|
||||
}
|
||||
|
||||
void publish(bool motion = true, uint8_t pair_index = 0) {
|
||||
now_us += 4000;
|
||||
publish_at_current_time(pair_index, motion);
|
||||
}
|
||||
uint32_t peek(uint8_t instance) {
|
||||
probe_controller_input_poll(instance, now_ms(), &controls[instance]);
|
||||
return probe_controller_input_peek_native_report(instance, now_ms(), reports[instance]);
|
||||
|
|
@ -110,7 +170,7 @@ void consume(uint8_t instance) {
|
|||
const uint32_t token = peek(instance);
|
||||
assert(token && probe_controller_input_commit_native_report(instance, token));
|
||||
}
|
||||
void pair() { consume(0); consume(1); }
|
||||
void pair(uint8_t pair_index = 0) { consume(pair_index * 2); consume(pair_index * 2 + 1); }
|
||||
void no_mouse_or_rails() {
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
assert((reports[i][3] & 0xc0) == 0);
|
||||
|
|
@ -673,15 +733,301 @@ void solo_motion_rotates_coherently_and_resets_frame() {
|
|||
}
|
||||
}
|
||||
|
||||
#if PROBE_CONTROLLER_COUNT == 4
|
||||
void publish_both(bool motion = true) {
|
||||
now_us += 4000;
|
||||
publish_at_current_time(0, motion);
|
||||
publish_at_current_time(1, motion);
|
||||
}
|
||||
|
||||
void prepare_two_sources(bool motion) {
|
||||
for (uint8_t pair_index = 0; pair_index < BLUEPAD32_NATIVE_PAIR_COUNT; ++pair_index) {
|
||||
auto& snapshot = sources[pair_index];
|
||||
const uint32_t connection_generation = snapshot.controller.connection_generation + 1;
|
||||
snapshot = {};
|
||||
snapshot.slot = pair_index;
|
||||
snapshot.controller.active = true;
|
||||
snapshot.controller.connection_generation = connection_generation;
|
||||
snapshot.controller.identity = controller_identity_global();
|
||||
snapshot.accel_valid = snapshot.gyro_valid = motion;
|
||||
snapshot.accel_q13[1] = 8192;
|
||||
profiles[pair_index] = controller_profile_default(controller_identity_global(), 0);
|
||||
alternating_shortcuts[pair_index] = false;
|
||||
}
|
||||
++profile_generation;
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
probe_controller_input_set_native_stream(instance, true);
|
||||
calibrate(instance, 2048, 2048, 1000, 1000, 1000, 1000);
|
||||
}
|
||||
publish_both(motion);
|
||||
pair(0);
|
||||
pair(1);
|
||||
}
|
||||
|
||||
void two_pair_controls_and_profile_coherence() {
|
||||
prepare_two_sources(false);
|
||||
auto& a = sources[0].controller.state;
|
||||
auto& b = sources[1].controller.state;
|
||||
a.button_south = a.dpad_up = true;
|
||||
a.button_left_shoulder = a.button_right_shoulder = true;
|
||||
b.button_east = b.dpad_down = true;
|
||||
sources[0].battery = 255;
|
||||
sources[1].battery = 0;
|
||||
publish_both(false); pair(0); pair(1);
|
||||
assert(reports[0][2] == 0x11 && reports[1][2] == 0x18);
|
||||
assert(reports[2][2] == 0x02 && reports[3][2] == 0x01);
|
||||
assert(reports[0][1] == 0x25 && reports[1][1] == 0x25);
|
||||
assert(reports[2][1] == 0x01 && reports[3][1] == 0x01);
|
||||
a.button_left_shoulder = a.button_right_shoulder = false;
|
||||
b.button_left_shoulder = b.button_right_shoulder = true;
|
||||
publish_both(false); pair(1); pair(0);
|
||||
assert(reports[0][2] == 0x01 && reports[1][2] == 0x08);
|
||||
assert(reports[2][2] == 0x12 && reports[3][2] == 0x11); // Real L+R only on PairB.
|
||||
|
||||
// Digital mapped-left movement after swapping feeds only A's solo frame.
|
||||
// B independently inverts its physical left stick, then swaps it to right.
|
||||
a = {}; b = {};
|
||||
a.dpad_up = a.button_south = true;
|
||||
a.left_stick_x = INT16_MAX;
|
||||
profiles[0].button_map[12] = CONTROLLER_PROFILE_LEFT_STICK_UP_OUTPUT;
|
||||
profiles[0].native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
|
||||
profiles[0].swap_sticks = true;
|
||||
b.dpad_down = true;
|
||||
b.left_stick_y = INT16_MAX;
|
||||
profiles[1].sticks[0].invert_y = true;
|
||||
profiles[1].swap_sticks = true;
|
||||
++profile_generation;
|
||||
publish_both(false);
|
||||
consume(0); pair(1); inactive_child(1);
|
||||
assert(reports[0][2] == 0x02 && stick_x(0) == 1048 && stick_y(0) == 2048);
|
||||
assert(reports[2][2] == 0 && stick_x(2) == 2048 && stick_y(2) == 3048);
|
||||
assert(reports[3][2] == 0x01 && stick_x(3) == 2048 && stick_y(3) == 2048);
|
||||
profiles[0].native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
|
||||
++profile_generation;
|
||||
pair(1); consume(1); inactive_child(0);
|
||||
assert(reports[1][2] == 0x04 && stick_x(1) == 3048 && stick_y(1) == 2048);
|
||||
assert(reports[2][2] == 0 && stick_y(2) == 3048 && reports[3][2] == 0x01);
|
||||
// A and B may select different solo sides without neutralizing each other.
|
||||
profiles[1].native_joycon_layout = ControllerProfileNativeJoyconLayout::kRightSolo;
|
||||
b.right_stick_x = INT16_MAX;
|
||||
++profile_generation;
|
||||
publish_both(false);
|
||||
consume(2); consume(1); inactive_child(0); inactive_child(3);
|
||||
assert(stick_x(1) == 3048 && stick_y(1) == 2048);
|
||||
assert(stick_x(2) == 2048 && stick_y(2) == 3048);
|
||||
|
||||
a = {}; b = {};
|
||||
profiles[0] = profiles[1] = controller_profile_default(controller_identity_global(), 0);
|
||||
++profile_generation;
|
||||
alternating_shortcuts[0] = alternating_shortcuts[1] = true;
|
||||
shortcut_phases[0] = false;
|
||||
shortcut_phases[1] = true;
|
||||
for (unsigned round = 0; round < 4; ++round) {
|
||||
publish_both(false);
|
||||
consume(0); consume(2); consume(1); consume(3);
|
||||
assert(reports[0][3] == reports[1][3] && reports[2][3] == reports[3][3]);
|
||||
assert(reports[0][3] != reports[2][3]);
|
||||
}
|
||||
const uint8_t a_before = reports[0][3], b_before = reports[2][3];
|
||||
// A's same-millisecond publication must re-evaluate A, not B; alternating
|
||||
// slot-local transitions make both duplicate and missing evaluations visible.
|
||||
publish_at_current_time(0, false);
|
||||
consume(0); consume(2); consume(1); consume(3);
|
||||
assert(reports[0][3] != a_before && reports[0][3] == reports[1][3]);
|
||||
assert(reports[2][3] == b_before && reports[2][3] == reports[3][3]);
|
||||
alternating_shortcuts[0] = alternating_shortcuts[1] = false;
|
||||
}
|
||||
|
||||
void two_pair_transport_and_disconnect_isolation() {
|
||||
prepare_two_sources(true);
|
||||
sources[0].controller.state.button_south = true;
|
||||
sources[1].controller.state.button_north = true;
|
||||
publish_both();
|
||||
uint32_t pending[PROBE_CONTROLLER_COUNT];
|
||||
uint64_t cues[PROBE_CONTROLLER_COUNT];
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
pending[instance] = peek(instance);
|
||||
assert(pending[instance]);
|
||||
assert(bluepad32_input_backend_native_sample_request(instance, 1, &cues[instance]));
|
||||
}
|
||||
assert(!probe_controller_input_commit_native_report(0, pending[2]));
|
||||
uint8_t saved_b[2][63];
|
||||
memcpy(saved_b, reports + 2, sizeof(saved_b));
|
||||
sources[0].controller.active = false;
|
||||
// Recheck the actual owning source, even before any poll sees its loss.
|
||||
assert(!probe_controller_input_commit_native_report(0, pending[0]));
|
||||
assert(!probe_controller_input_commit_native_report(1, pending[1]));
|
||||
inactive_child(0); inactive_child(1);
|
||||
for (uint8_t instance = 0; instance < 2; ++instance)
|
||||
assert(bluepad32_input_backend_native_sample_result(instance, cues[instance]) == -1);
|
||||
for (uint8_t instance = 2; instance < 4; ++instance) {
|
||||
assert(bluepad32_input_backend_native_sample_result(instance, cues[instance]) == 1);
|
||||
assert(peek(instance) == pending[instance]);
|
||||
assert(memcmp(saved_b[instance - 2], reports[instance], 63) == 0);
|
||||
assert(probe_controller_input_commit_native_report(instance, pending[instance]));
|
||||
}
|
||||
const uint32_t b_pending = peek(2);
|
||||
sources[0].controller.active = true;
|
||||
++sources[0].controller.connection_generation;
|
||||
publish(true, 0); pair(0);
|
||||
assert(reports[0][2] == 0x01 && reports[2][2] == 0x08);
|
||||
assert(!probe_controller_input_commit_native_report(0, pending[0]));
|
||||
assert(probe_controller_input_commit_native_report(2, b_pending));
|
||||
|
||||
// Repeated updates on three endpoints must neither consume a blocked
|
||||
// endpoint's counter nor starve the other source's two endpoints.
|
||||
publish_both();
|
||||
const uint32_t blocked_left = peek(1);
|
||||
const uint8_t left_counter = reports[1][0];
|
||||
const uint32_t blocked_b = peek(2);
|
||||
const uint8_t b_counter = reports[2][0];
|
||||
for (unsigned update = 0; update < 40; ++update) {
|
||||
sources[1].controller.state.button_east = (update & 1u) != 0;
|
||||
publish_both(); consume(0); pair(1);
|
||||
}
|
||||
assert(!probe_controller_input_commit_native_report(1, blocked_left));
|
||||
assert(!probe_controller_input_commit_native_report(2, blocked_b));
|
||||
consume(1);
|
||||
assert(reports[1][0] == left_counter);
|
||||
assert(reports[2][0] == static_cast<uint8_t>(b_counter + 39));
|
||||
assert(reports[2][2] == 0x0a);
|
||||
probe_controller_input_set_native_stream(0, false);
|
||||
assert(!peek(0));
|
||||
publish_both();
|
||||
const uint32_t left_pending = peek(1);
|
||||
pair(1);
|
||||
assert(probe_controller_input_commit_native_report(1, left_pending));
|
||||
probe_controller_input_set_native_stream(0, true);
|
||||
consume(0);
|
||||
assert(reports[0][2] == 0x01);
|
||||
|
||||
// USB suspension also remains child-local on PairB.
|
||||
publish_both();
|
||||
const uint32_t a_pending = peek(0), b_left_pending = peek(3);
|
||||
probe_controller_input_set_native_stream(2, false);
|
||||
assert(!peek(2));
|
||||
assert(probe_controller_input_commit_native_report(0, a_pending));
|
||||
assert(probe_controller_input_commit_native_report(3, b_left_pending));
|
||||
assert(bluepad32_input_backend_native_sample_result(2, cues[2]) == -1);
|
||||
assert(bluepad32_input_backend_native_sample_result(3, cues[3]) == 1);
|
||||
probe_controller_input_set_native_stream(2, true);
|
||||
|
||||
const uint32_t expires = peek(0);
|
||||
// B stays live while A's queued report expires, then A's source times out.
|
||||
for (unsigned update = 0; update < 26; ++update) { publish(true, 1); pair(1); }
|
||||
assert(!probe_controller_input_commit_native_report(0, expires));
|
||||
for (unsigned update = 0; update < 100; ++update) { publish(true, 1); pair(1); }
|
||||
const uint32_t surviving_b = peek(2);
|
||||
inactive_child(0); inactive_child(1);
|
||||
assert(probe_controller_input_commit_native_report(2, surviving_b));
|
||||
assert(controls[2].active && controls[3].active && reports[2][2] == 0x0a);
|
||||
}
|
||||
|
||||
void two_pair_motion_provenance_and_resets() {
|
||||
prepare_two_sources(true);
|
||||
const uint8_t side = (SWITCH2_BRIDGE_IMU_TARGET_MASK & 1) ? 0 : 1;
|
||||
const uint8_t a_imu = side, b_imu = 2 + side;
|
||||
sources[0].gyro_q10[1] = 90 * 1024;
|
||||
sources[1].gyro_q10[1] = -45 * 1024;
|
||||
for (unsigned sample = 0; sample < 250; ++sample) {
|
||||
publish_both();
|
||||
consume(0); consume(2); consume(1); consume(3);
|
||||
}
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
const bool enabled = (SWITCH2_BRIDGE_IMU_TARGET_MASK & (1u << (instance & 1u))) != 0;
|
||||
assert(imu_length(instance) == (enabled ? 30 : 0));
|
||||
}
|
||||
double a[4], b[4];
|
||||
quaternion(a_imu, a); quaternion(b_imu, b);
|
||||
assert(fabs(fabs(a[0]) - sqrt(.5)) < .015);
|
||||
assert(fabs(fabs(b[0]) - cos(3.141592653589793 / 8)) < .015);
|
||||
assert(a[3] * b[3] < 0); // Opposite physical yaw cannot share one integrator.
|
||||
if (SWITCH2_BRIDGE_IMU_TARGET_MASK == 3) {
|
||||
assert(memcmp(reports[0] + probe_model_imu_data_offset(0),
|
||||
reports[1] + probe_model_imu_data_offset(1), 30) == 0);
|
||||
assert(memcmp(reports[2] + probe_model_imu_data_offset(2),
|
||||
reports[3] + probe_model_imu_data_offset(3), 30) == 0);
|
||||
}
|
||||
sources[0].gyro_q10[1] = sources[1].gyro_q10[1] = 0;
|
||||
publish_both(); pair(0); pair(1);
|
||||
quaternion(b_imu, b);
|
||||
const uint8_t* b_block = reports[b_imu] + probe_model_imu_data_offset(b_imu);
|
||||
const uint32_t b_ticks = bits(b_block, 0, 12);
|
||||
publish(false, 1); pair(1);
|
||||
publish(true, 0); pair(0);
|
||||
pair(1);
|
||||
assert(imu_length(2) == 0 && imu_length(3) == 0); // A cannot manufacture a B sample.
|
||||
|
||||
const uint32_t pending_a = peek(a_imu);
|
||||
sources[0].controller.active = false;
|
||||
inactive_child(0); inactive_child(1);
|
||||
sources[0].controller.active = true;
|
||||
++sources[0].controller.connection_generation;
|
||||
publish(true, 0); pair(0);
|
||||
assert(!probe_controller_input_commit_native_report(a_imu, pending_a));
|
||||
quaternion(a_imu, a);
|
||||
assert(fabs(fabs(a[0]) - 1) < 1e-6); // Only A reconnects at identity heading.
|
||||
publish(true, 1); pair(1);
|
||||
double after[4]; quaternion(b_imu, after);
|
||||
for (unsigned axis = 0; axis < 4; ++axis) assert(fabs(after[axis] - b[axis]) < 1e-6);
|
||||
b_block = reports[b_imu] + probe_model_imu_data_offset(b_imu);
|
||||
assert(bits(b_block, 12, 12) == ((bits(b_block, 0, 12) - b_ticks) & 0xfffu));
|
||||
|
||||
// Reframing A to solo must not reset B's heading or in-flight motion.
|
||||
profiles[0].native_joycon_layout = ControllerProfileNativeJoyconLayout::kLeftSolo;
|
||||
++profile_generation;
|
||||
publish_both();
|
||||
const uint32_t b_pending = peek(b_imu);
|
||||
uint8_t saved[63]; memcpy(saved, reports[b_imu], sizeof(saved));
|
||||
consume(1); inactive_child(0);
|
||||
assert(peek(b_imu) == b_pending && memcmp(saved, reports[b_imu], sizeof(saved)) == 0);
|
||||
assert(probe_controller_input_commit_native_report(b_imu, b_pending));
|
||||
quaternion(b_imu, after);
|
||||
for (unsigned axis = 0; axis < 4; ++axis) assert(fabs(after[axis] - b[axis]) < 1e-6);
|
||||
}
|
||||
|
||||
void recycled_slot_preserves_the_new_pairs_runtime() {
|
||||
prepare_two_sources(false);
|
||||
const uint32_t old_a = peek(0);
|
||||
// A disconnects without another poll. B reconnects into A's recycled
|
||||
// physical slot and starts a held synthetic action before A sees its loss.
|
||||
sources[0].controller.active = false;
|
||||
sources[1].slot = sources[0].slot;
|
||||
++sources[1].controller.connection_generation;
|
||||
sources[1].controller.state.button_select = true;
|
||||
latching_shortcuts[1] = true;
|
||||
publish(false, 1); pair(1);
|
||||
assert(reports[2][3] == 1 && reports[3][3] == 1);
|
||||
sources[1].controller.state.button_select = false;
|
||||
publish(false, 1); pair(1);
|
||||
const uint32_t pending_b = peek(2);
|
||||
inactive_child(0); inactive_child(1);
|
||||
assert(!probe_controller_input_commit_native_report(0, old_a));
|
||||
assert(probe_controller_input_commit_native_report(2, pending_b));
|
||||
// The next evaluation exposes accidental inactive-transform retirement;
|
||||
// checking only the already-cached report would miss that runtime reset.
|
||||
publish(false, 1); pair(1);
|
||||
assert(reports[2][3] == 1 && reports[3][3] == 1);
|
||||
sources[0].slot = 1;
|
||||
sources[0].controller.active = true;
|
||||
++sources[0].controller.connection_generation;
|
||||
publish(false, 0); pair(0);
|
||||
publish(false, 1); pair(1);
|
||||
assert(reports[2][3] == 1 && reports[3][3] == 1);
|
||||
latching_shortcuts[1] = false;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
assert(!probe_controller_input_peek_native_report(0, now_ms(), reports[0]));
|
||||
probe_controller_input_init();
|
||||
assert(probe_controller_input_start());
|
||||
probe_controller_input_set_native_stream(0, true);
|
||||
probe_controller_input_set_native_stream(1, true);
|
||||
assert(!peek(0) && !peek(1));
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
probe_controller_input_set_native_stream(instance, true);
|
||||
assert(!peek(instance));
|
||||
}
|
||||
mapped_halves_and_calibration();
|
||||
independent_backpressure_and_resets();
|
||||
if (SWITCH2_BRIDGE_IMU_TARGET_MASK == 3) real_motion_admission_and_loss();
|
||||
|
|
@ -692,5 +1038,11 @@ int main() {
|
|||
profile_changes_retire_tokens_without_source_publication();
|
||||
digital_dpad_reaches_the_mapped_left_stick();
|
||||
solo_motion_rotates_coherently_and_resets_frame();
|
||||
#if PROBE_CONTROLLER_COUNT == 4
|
||||
two_pair_controls_and_profile_coherence();
|
||||
two_pair_transport_and_disconnect_isolation();
|
||||
two_pair_motion_provenance_and_resets();
|
||||
recycled_slot_preserves_the_new_pairs_runtime();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,12 @@ static void test_descriptors(void) {
|
|||
((uint16_t)probe_device_descriptor[11] << 8);
|
||||
assert(product_id == (SWITCH2_PROBE_JOYCON_LEFT ? 0x2067 : 0x2066));
|
||||
assert(probe_configuration_descriptor[2] == sizeof(probe_configuration_descriptor));
|
||||
assert(probe_configuration_descriptor[4] == 2 * PROBE_CONTROLLER_COUNT);
|
||||
const unsigned functions = SWITCH2_PROBE_COMPOSITE ? 2 : 1;
|
||||
assert(probe_configuration_descriptor[4] == 2 * functions);
|
||||
#if SWITCH2_PROBE_HUB
|
||||
assert((probe_left_device_descriptor[10] |
|
||||
((uint16_t)probe_left_device_descriptor[11] << 8)) == 0x2067);
|
||||
#endif
|
||||
unsigned interface_count = 0, endpoint_count = 0;
|
||||
unsigned interface = 0, seen_endpoints = 0;
|
||||
for (size_t offset = 9; offset < sizeof(probe_configuration_descriptor);) {
|
||||
|
|
@ -67,8 +72,8 @@ static void test_descriptors(void) {
|
|||
}
|
||||
offset += descriptor[0];
|
||||
}
|
||||
assert(interface_count == 2 * PROBE_CONTROLLER_COUNT);
|
||||
assert(endpoint_count == 4 * PROBE_CONTROLLER_COUNT);
|
||||
assert(interface_count == 2 * functions);
|
||||
assert(endpoint_count == 4 * functions);
|
||||
// Read HID short items as a host would: each function advertises only its
|
||||
// own native report plus common 05, with sizes matching report generation.
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
|
|
@ -91,7 +96,11 @@ static void test_descriptors(void) {
|
|||
case 0x90: output_bits[report_id] += report_size * report_count; break;
|
||||
}
|
||||
}
|
||||
const bool is_left = SWITCH2_PROBE_COMPOSITE ? instance == 1 : SWITCH2_PROBE_JOYCON_LEFT;
|
||||
const bool is_left = (SWITCH2_PROBE_COMPOSITE || SWITCH2_PROBE_HUB) ?
|
||||
(instance & 1u) != 0 : SWITCH2_PROBE_JOYCON_LEFT;
|
||||
assert(probe_model_is_left(instance) == is_left);
|
||||
assert(probe_model_pid(instance) == (is_left ? 0x2067 : 0x2066));
|
||||
assert(probe_model_report_id(instance) == (is_left ? 7 : 8));
|
||||
probe_protocol_state state;
|
||||
probe_protocol_reset(&state, is_left);
|
||||
initialize(&state);
|
||||
|
|
@ -435,43 +444,50 @@ static void test_interleaved_reports_and_features(void) {
|
|||
}
|
||||
|
||||
static void test_interleaved_callbacks_and_pairing(void) {
|
||||
const uint8_t addresses[2][6] = {
|
||||
enum { count = PROBE_CONTROLLER_COUNT > 2 ? PROBE_CONTROLLER_COUNT : 2 };
|
||||
const uint8_t addresses[4][6] = {
|
||||
{0x64, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
|
||||
{0x65, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
|
||||
{0x66, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
|
||||
{0x67, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
|
||||
};
|
||||
controller_context controllers[2] = {
|
||||
{.expected_sample = 3, .source_available = true,
|
||||
.source_token = UINT64_C(0x100000001), .storage_available = true},
|
||||
{.expected_sample = 3, .source_available = false,
|
||||
.source_token = UINT64_C(0x200000001), .storage_available = false},
|
||||
};
|
||||
probe_protocol_state states[2];
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
probe_protocol_reset(&states[side], side != 0);
|
||||
states[side].context = &controllers[side];
|
||||
states[side].play_sample = play_sample;
|
||||
states[side].save_pairing = save_pairing;
|
||||
memcpy(states[side].controller_address, addresses[side], 6);
|
||||
controller_context controllers[count];
|
||||
memset(controllers, 0, sizeof(controllers));
|
||||
probe_protocol_state states[count];
|
||||
for (unsigned instance = 0; instance < count; ++instance) {
|
||||
controllers[instance].expected_sample = 3;
|
||||
controllers[instance].source_available = instance != 1;
|
||||
controllers[instance].storage_available = instance != 1;
|
||||
controllers[instance].source_token = ((uint64_t)(instance + 1) << 32) | 1;
|
||||
probe_protocol_reset(&states[instance], (instance & 1u) != 0);
|
||||
states[instance].context = &controllers[instance];
|
||||
states[instance].play_sample = play_sample;
|
||||
states[instance].save_pairing = save_pairing;
|
||||
memcpy(states[instance].controller_address, addresses[instance], 6);
|
||||
}
|
||||
uint8_t reply[PROBE_REPLY_MAX_SIZE];
|
||||
uint8_t cue_replies[2][8];
|
||||
uint64_t tokens[2] = {0, UINT64_MAX};
|
||||
assert(probe_protocol_command(&states[0], sample_command, sizeof(sample_command),
|
||||
cue_replies[0], 8, &tokens[0]) == 8);
|
||||
assert(probe_protocol_command(&states[1], sample_command, sizeof(sample_command),
|
||||
cue_replies[1], 8, &tokens[1]) == 0);
|
||||
assert(tokens[0] == UINT64_C(0x100000001) && tokens[1] == 0);
|
||||
controllers[1].source_available = true;
|
||||
assert(probe_protocol_command(&states[1], sample_command, sizeof(sample_command),
|
||||
cue_replies[1], 8, &tokens[1]) == 8);
|
||||
assert(tokens[0] == UINT64_C(0x100000001) && tokens[1] == UINT64_C(0x200000001));
|
||||
uint64_t tokens[count];
|
||||
const uint8_t cue_ack[] = {0x0a, 1, 0, 2, 0, 0xf8, 0, 0};
|
||||
assert(memcmp(cue_replies[0], cue_ack, 8) == 0);
|
||||
assert(memcmp(cue_replies[1], cue_ack, 8) == 0);
|
||||
for (unsigned instance = 0; instance < count; ++instance) {
|
||||
tokens[instance] = UINT64_MAX;
|
||||
if (instance == 1) {
|
||||
assert(probe_protocol_command(&states[instance], sample_command, sizeof(sample_command),
|
||||
reply, sizeof(reply), &tokens[instance]) == 0);
|
||||
assert(tokens[instance] == 0);
|
||||
controllers[instance].source_available = true;
|
||||
}
|
||||
assert(probe_protocol_command(&states[instance], sample_command, sizeof(sample_command),
|
||||
reply, sizeof(reply), &tokens[instance]) == sizeof(cue_ack));
|
||||
assert(memcmp(reply, cue_ack, sizeof(cue_ack)) == 0);
|
||||
for (unsigned previous = 0; previous <= instance; ++previous)
|
||||
assert(tokens[previous] == (((uint64_t)(previous + 1) << 32) | 1));
|
||||
}
|
||||
|
||||
const uint8_t hosts[2][16] = {
|
||||
const uint8_t hosts[4][16] = {
|
||||
{0x15, 0x91, 0, 1, 0, 8, 0, 0, 0, 1, 1, 2, 3, 4, 5, 6},
|
||||
{0x15, 0x91, 0, 1, 0, 8, 0, 0, 0, 1, 7, 8, 9, 10, 11, 12},
|
||||
{0x15, 0x91, 0, 1, 0, 8, 0, 0, 0, 1, 13, 14, 15, 16, 17, 18},
|
||||
{0x15, 0x91, 0, 1, 0, 8, 0, 0, 0, 1, 19, 20, 21, 22, 23, 24},
|
||||
};
|
||||
const uint8_t device_component[] = {
|
||||
0x5c, 0xf6, 0xee, 0x79, 0x2c, 0xdf, 0x05, 0xe1,
|
||||
|
|
@ -483,69 +499,69 @@ static void test_interleaved_callbacks_and_pairing(void) {
|
|||
{0x66, 0xe9, 0x4b, 0xd4, 0xef, 0x8a, 0x2c, 0x3b,
|
||||
0x88, 0x4c, 0xfa, 0x59, 0xca, 0x34, 0x2b, 0x2e},
|
||||
};
|
||||
uint8_t challenges[2][25] = {
|
||||
{0x15, 0x91, 0, 2, 0, 17, 0, 0, 0},
|
||||
{0x15, 0x91, 0, 2, 0, 17, 0, 0, 0},
|
||||
};
|
||||
uint8_t challenges[count][25];
|
||||
const uint8_t finalize[] = {0x15, 0x91, 0, 3, 0, 1, 0, 0, 0};
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
assert(probe_protocol_command(&states[side], hosts[side], sizeof(hosts[side]),
|
||||
for (unsigned instance = 0; instance < count; ++instance) {
|
||||
assert(probe_protocol_command(&states[instance], hosts[instance], sizeof(hosts[instance]),
|
||||
reply, sizeof(reply), NULL) == 17);
|
||||
assert(memcmp(reply + 11, addresses[side], 6) == 0);
|
||||
}
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
assert(memcmp(reply + 11, addresses[instance], 6) == 0);
|
||||
uint8_t key[] = {0x15, 0x91, 0, 4, 0, 17, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
|
||||
memcpy(challenges[instance], key, sizeof(key));
|
||||
challenges[instance][3] = 2;
|
||||
for (unsigned i = 0; i < 16; ++i) {
|
||||
// R uses AES's 000102...0f / 001122...ff vector; L uses all zeros.
|
||||
key[9 + i] = device_component[i] ^ (side ? 0 : 15u - i);
|
||||
challenges[side][9 + i] = side ? 0 : (uint8_t)((15u - i) * 0x11u);
|
||||
key[9 + i] = device_component[i] ^ ((instance & 1u) ? 0 : 15u - i);
|
||||
challenges[instance][9 + i] = (instance & 1u) ? 0 : (uint8_t)((15u - i) * 0x11u);
|
||||
}
|
||||
assert(probe_protocol_command(&states[side], key, sizeof(key),
|
||||
assert(probe_protocol_command(&states[instance], key, sizeof(key),
|
||||
reply, sizeof(reply), NULL) == 25);
|
||||
}
|
||||
assert(probe_protocol_command(&states[0], challenges[0], sizeof(challenges[0]),
|
||||
reply, sizeof(reply), NULL) == 25);
|
||||
assert(memcmp(reply + 9, ciphertexts[0], 16) == 0);
|
||||
// Right confirmation cannot authorize the left's finalize.
|
||||
assert(probe_protocol_command(&states[1], finalize, sizeof(finalize),
|
||||
reply, sizeof(reply), NULL) == 0);
|
||||
assert(controllers[0].saves == 0 && controllers[1].saves == 0);
|
||||
assert(probe_protocol_command(&states[1], challenges[1], sizeof(challenges[1]),
|
||||
reply, sizeof(reply), NULL) == 25);
|
||||
assert(memcmp(reply + 9, ciphertexts[1], 16) == 0);
|
||||
assert(probe_protocol_command(&states[0], finalize, sizeof(finalize),
|
||||
reply, sizeof(reply), NULL) == 9);
|
||||
assert(reply[8] == 1);
|
||||
assert(probe_protocol_command(&states[1], finalize, sizeof(finalize),
|
||||
reply, sizeof(reply), NULL) == 0);
|
||||
assert(controllers[0].saves == 1 && controllers[1].saves == 0);
|
||||
controllers[1].storage_available = true;
|
||||
assert(probe_protocol_command(&states[1], finalize, sizeof(finalize),
|
||||
reply, sizeof(reply), NULL) == 9);
|
||||
assert(reply[8] == 1);
|
||||
assert(controllers[0].saves == 1 && controllers[1].saves == 1);
|
||||
for (unsigned instance = 0; instance < count; ++instance) {
|
||||
assert(probe_protocol_command(&states[instance], challenges[instance], sizeof(challenges[instance]),
|
||||
reply, sizeof(reply), NULL) == 25);
|
||||
assert(memcmp(reply + 9, ciphertexts[instance & 1u], 16) == 0);
|
||||
// A confirmation cannot authorize any sibling, including the same-side
|
||||
// child in the other pair. A failed durable save cannot be acknowledged.
|
||||
for (unsigned pending = instance + 1; pending < count; ++pending)
|
||||
assert(probe_protocol_command(&states[pending], finalize, sizeof(finalize),
|
||||
reply, sizeof(reply), NULL) == 0);
|
||||
if (instance == 1) {
|
||||
assert(probe_protocol_command(&states[instance], finalize, sizeof(finalize),
|
||||
reply, sizeof(reply), NULL) == 0);
|
||||
assert(controllers[instance].saves == 0);
|
||||
controllers[instance].storage_available = true;
|
||||
}
|
||||
assert(probe_protocol_command(&states[instance], finalize, sizeof(finalize),
|
||||
reply, sizeof(reply), NULL) == 9);
|
||||
assert(reply[8] == 1);
|
||||
for (unsigned sibling = 0; sibling < count; ++sibling)
|
||||
assert(controllers[sibling].saves == (unsigned)(sibling <= instance));
|
||||
}
|
||||
|
||||
// After independent resets, each durable record must resume only its own
|
||||
// challenge association; swapping the two contexts' records is rejected.
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
probe_protocol_reset(&states[side], side != 0);
|
||||
memcpy(states[side].controller_address, addresses[side], 6);
|
||||
assert(!probe_protocol_restore_pairing(&states[side], controllers[1 - side].pairing_blob,
|
||||
PROBE_PAIRING_BLOB_SIZE));
|
||||
assert(probe_protocol_restore_pairing(&states[side], controllers[side].pairing_blob,
|
||||
// Each durable record resumes only its own identity and host association.
|
||||
for (unsigned instance = 0; instance < count; ++instance) {
|
||||
probe_protocol_reset(&states[instance], (instance & 1u) != 0);
|
||||
memcpy(states[instance].controller_address, addresses[instance], 6);
|
||||
for (unsigned sibling = 0; sibling < count; ++sibling) {
|
||||
if (sibling == instance) continue;
|
||||
assert(!probe_protocol_restore_pairing(&states[instance], controllers[sibling].pairing_blob,
|
||||
PROBE_PAIRING_BLOB_SIZE));
|
||||
}
|
||||
assert(probe_protocol_restore_pairing(&states[instance], controllers[instance].pairing_blob,
|
||||
PROBE_PAIRING_BLOB_SIZE));
|
||||
}
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
assert(probe_protocol_command(&states[side], hosts[1 - side], sizeof(hosts[0]),
|
||||
for (unsigned instance = 0; instance < count; ++instance) {
|
||||
const unsigned sibling = (instance + (count == 4 ? 2 : 1)) % count;
|
||||
assert(probe_protocol_command(&states[instance], hosts[sibling], sizeof(hosts[sibling]),
|
||||
reply, sizeof(reply), NULL) == 17);
|
||||
assert(probe_protocol_command(&states[side], challenges[side], sizeof(challenges[side]),
|
||||
assert(probe_protocol_command(&states[instance], challenges[instance], sizeof(challenges[instance]),
|
||||
reply, sizeof(reply), NULL) == 0);
|
||||
assert(probe_protocol_command(&states[side], hosts[side], sizeof(hosts[side]),
|
||||
assert(probe_protocol_command(&states[instance], hosts[instance], sizeof(hosts[instance]),
|
||||
reply, sizeof(reply), NULL) == 17);
|
||||
assert(probe_protocol_command(&states[side], challenges[side], sizeof(challenges[side]),
|
||||
assert(probe_protocol_command(&states[instance], challenges[instance], sizeof(challenges[instance]),
|
||||
reply, sizeof(reply), NULL) == 25);
|
||||
assert(memcmp(reply + 9, ciphertexts[side], 16) == 0);
|
||||
assert(memcmp(reply + 9, ciphertexts[instance & 1u], 16) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -556,39 +572,104 @@ static bool read_memory(void* context, uint32_t address, uint8_t* output, size_t
|
|||
static void test_indexed_memory(void) {
|
||||
probe_protocol_state states[PROBE_CONTROLLER_COUNT];
|
||||
uint8_t instances[PROBE_CONTROLLER_COUNT];
|
||||
const uint8_t addresses[4][6] = {
|
||||
{0x64, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
|
||||
{0x65, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
|
||||
{0x66, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
|
||||
{0x67, 0xf9, 0xd8, 0x93, 0x05, 0xa2},
|
||||
};
|
||||
const uint8_t versions[4][12] = {
|
||||
{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},
|
||||
{13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24},
|
||||
{25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36},
|
||||
{37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48},
|
||||
};
|
||||
uint8_t reports[PROBE_CONTROLLER_COUNT][PROBE_INPUT_SIZE];
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
instances[instance] = instance;
|
||||
probe_protocol_reset(&states[instance], probe_model_is_left(instance));
|
||||
states[instance].context = &instances[instance];
|
||||
states[instance].read_memory = read_memory;
|
||||
memcpy(states[instance].controller_address, addresses[instance], 6);
|
||||
states[instance].firmware_version = versions[instance];
|
||||
uint8_t calibration[9];
|
||||
assert(probe_memory_stick_calibration(instance, calibration));
|
||||
memcpy(states[instance].stick_center, calibration, 3);
|
||||
initialize(&states[instance]);
|
||||
set_features(&states[instance], 2, 0x17);
|
||||
set_features(&states[instance], 4, 0x17);
|
||||
states[instance].report_counter = 0x21 + instance;
|
||||
}
|
||||
const uint8_t calibrations[2][9] = {
|
||||
{0x10, 0x08, 0x81, 0, 3, 0x30, 0, 4, 0x40}, // Valid user override.
|
||||
{0, 0x09, 0x90, 0, 3, 0x30, 0, 4, 0x40}, // Invalid user, factory fallback.
|
||||
};
|
||||
const uint8_t firmware_query[] = {0x10, 0x91, 0, 1, 0, 0, 0, 0};
|
||||
const uint8_t address_query[] = {0x15, 0x91, 0, 1, 0, 0, 0, 0};
|
||||
const uint8_t command[] = {
|
||||
0x02, 0x91, 0, 4, 0, 8, 0, 0, 9, 0x7e, 0, 0, 0xa8, 0x30, 1, 0,
|
||||
};
|
||||
for (unsigned remaining = PROBE_CONTROLLER_COUNT; remaining; --remaining) {
|
||||
const uint8_t instance = (uint8_t)(remaining - 1);
|
||||
const bool is_left = SWITCH2_PROBE_COMPOSITE ? instance == 1 : SWITCH2_PROBE_JOYCON_LEFT;
|
||||
const bool is_left = (SWITCH2_PROBE_COMPOSITE || SWITCH2_PROBE_HUB) ?
|
||||
(instance & 1u) != 0 : SWITCH2_PROBE_JOYCON_LEFT;
|
||||
const uint8_t pair = instance / 2;
|
||||
uint8_t reply[PROBE_REPLY_MAX_SIZE], calibration[9];
|
||||
assert(probe_memory_stick_calibration(instance, calibration));
|
||||
assert(memcmp(calibration, calibrations[is_left], sizeof(calibration)) == 0);
|
||||
const uint8_t expected_calibration[] = {
|
||||
(uint8_t)((is_left ? 0 : 0x10) + pair * 0x20),
|
||||
is_left ? 9 : 8, is_left ? 0x90 : 0x81, 0, 3, 0x30, 0, 4, 0x40,
|
||||
};
|
||||
assert(memcmp(calibration, expected_calibration, sizeof(calibration)) == 0);
|
||||
assert(probe_protocol_command(&states[instance], command, sizeof(command),
|
||||
reply, sizeof(reply), NULL) == 25);
|
||||
const uint8_t factory[] = {0, is_left ? 9 : 8, is_left ? 0x90 : 0x80, 0, 3, 0x30, 0, 4, 0x40};
|
||||
const uint8_t factory[] = {
|
||||
(uint8_t)(pair * 0x20), is_left ? 9 : 8, is_left ? 0x90 : 0x80,
|
||||
0, 3, 0x30, 0, 4, 0x40,
|
||||
};
|
||||
assert(memcmp(reply + 16, factory, sizeof(factory)) == 0);
|
||||
assert(probe_protocol_command(&states[instance], firmware_query, sizeof(firmware_query),
|
||||
reply, sizeof(reply), NULL) == 20);
|
||||
assert(memcmp(reply + 8, versions[instance], 12) == 0);
|
||||
assert(probe_protocol_command(&states[instance], address_query, sizeof(address_query),
|
||||
reply, sizeof(reply), NULL) == 17);
|
||||
assert(memcmp(reply + 11, addresses[instance], 6) == 0);
|
||||
// No source is present: enabling features must not invent input or cue ACKs.
|
||||
uint64_t token = UINT64_MAX;
|
||||
assert(probe_protocol_command(&states[instance], sample_command, sizeof(sample_command),
|
||||
reply, sizeof(reply), &token) == 0);
|
||||
assert(token == 0);
|
||||
uint8_t expected[PROBE_INPUT_SIZE] = {0};
|
||||
expected[0] = (uint8_t)(0x21 + instance);
|
||||
expected[1] = 0x25;
|
||||
expected[4] = 7;
|
||||
memcpy(expected + 5, expected_calibration, 3);
|
||||
assert(probe_protocol_report(&states[instance], is_left ? 7 : 8,
|
||||
reports[instance], PROBE_INPUT_SIZE) == PROBE_INPUT_SIZE);
|
||||
assert(memcmp(reports[instance], expected, sizeof(expected)) == 0);
|
||||
const uint32_t ends[] = {0x14fff, 0x1fcfff};
|
||||
for (unsigned region = 0; region < 2; ++region) {
|
||||
uint8_t output[2] = {0xa5, 0xa5};
|
||||
assert(!probe_memory_read(instance, ends[region], output, sizeof(output)));
|
||||
assert(output[0] == 0xa5 && output[1] == 0xa5);
|
||||
assert(probe_memory_read(instance, ends[region], output, 1));
|
||||
assert(output[0] == (uint8_t)((region ? 0xf1 : 0xe1) + is_left));
|
||||
assert(output[0] == (uint8_t)((region ? 0xf1 : 0xe1) + is_left + pair * 2));
|
||||
assert(output[1] == 0xa5);
|
||||
}
|
||||
}
|
||||
// Reset each child in turn: the remaining children's complete wire snapshots
|
||||
// and captured identity queries must remain unchanged, including same-side peers.
|
||||
for (uint8_t reset = 0; reset < PROBE_CONTROLLER_COUNT; ++reset) {
|
||||
probe_protocol_reset(&states[reset], probe_model_is_left(reset));
|
||||
uint8_t output[PROBE_REPLY_MAX_SIZE];
|
||||
assert(probe_protocol_report(&states[reset], probe_model_report_id(reset),
|
||||
output, sizeof(output)) == 0);
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
if (instance <= reset) continue;
|
||||
assert(probe_protocol_report(&states[instance], probe_model_report_id(instance),
|
||||
output, sizeof(output)) == PROBE_INPUT_SIZE);
|
||||
assert(memcmp(output, reports[instance], PROBE_INPUT_SIZE) == 0);
|
||||
assert(probe_protocol_command(&states[instance], address_query, sizeof(address_query),
|
||||
output, sizeof(output), NULL) == 17);
|
||||
assert(memcmp(output + 11, addresses[instance], 6) == 0);
|
||||
}
|
||||
}
|
||||
uint8_t output[9];
|
||||
memset(output, 0xa5, sizeof(output));
|
||||
const uint8_t invalid[] = {PROBE_CONTROLLER_COUNT, UINT8_MAX};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#define FLASH_SECTOR_SIZE 4096u
|
||||
#define FLASH_PAGE_SIZE 256u
|
||||
#define PICO_FLASH_SIZE_BYTES (2u * 1024u * 1024u)
|
||||
|
||||
void flash_range_erase(uint32_t offset, size_t count);
|
||||
void flash_range_program(uint32_t offset, const uint8_t* data, size_t count);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
#include "hardware/flash.h"
|
||||
|
||||
#define PICO_FLASH_BANK_TOTAL_SIZE (2u * FLASH_SECTOR_SIZE)
|
||||
#define PICO_FLASH_BANK_STORAGE_OFFSET (PICO_FLASH_SIZE_BYTES - PICO_FLASH_BANK_TOTAL_SIZE)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
constexpr int PICO_OK = 0;
|
||||
int flash_safe_execute(void (*function)(void*), void* parameter,
|
||||
uint32_t enter_exit_timeout_ms);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
#pragma once
|
||||
|
||||
#include "hardware/flash.h"
|
||||
|
||||
extern "C" {
|
||||
extern uint8_t probe_test_flash[PICO_FLASH_SIZE_BYTES];
|
||||
}
|
||||
#define XIP_BASE (reinterpret_cast<uintptr_t>(probe_test_flash))
|
||||
296
tests/switch2_usb_probe_storage_test.cpp
Normal file
296
tests/switch2_usb_probe_storage_test.cpp
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
#include "storage.h"
|
||||
#include "protocol.h"
|
||||
#include "configuration/configuration_storage.h"
|
||||
#include "profile/profile_storage.h"
|
||||
#include "hardware/flash.h"
|
||||
#include "pico/btstack_flash_bank.h"
|
||||
#include "pico/flash.h"
|
||||
#include "pico/platform.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
extern "C" {
|
||||
alignas(FLASH_SECTOR_SIZE) uint8_t probe_test_flash[PICO_FLASH_SIZE_BYTES];
|
||||
}
|
||||
|
||||
namespace {
|
||||
constexpr size_t kBankSize = 2 * FLASH_SECTOR_SIZE;
|
||||
constexpr uint32_t kProfileOffset = PICO_FLASH_BANK_STORAGE_OFFSET -
|
||||
CONFIGURATION_STORAGE_COPY_COUNT * FLASH_SECTOR_SIZE - PROFILE_STORAGE_TOTAL_SIZE;
|
||||
constexpr uint32_t kReservedOffset = kProfileOffset -
|
||||
(PROBE_CONTROLLER_COUNT > 2 ? PROBE_CONTROLLER_COUNT : 2) * kBankSize;
|
||||
using Blob = std::array<uint8_t, PROBE_PAIRING_BLOB_SIZE>;
|
||||
using Blobs = std::array<Blob, PROBE_CONTROLLER_COUNT>;
|
||||
using Image = std::vector<uint8_t>;
|
||||
|
||||
struct Mutation {
|
||||
uint32_t offset;
|
||||
size_t size;
|
||||
bool erase;
|
||||
};
|
||||
std::vector<Mutation> mutations;
|
||||
int safe_calls;
|
||||
int fail_at = -1;
|
||||
size_t torn_bytes;
|
||||
size_t mutation_limit = std::numeric_limits<size_t>::max();
|
||||
bool inside_safe;
|
||||
bool fault_hit;
|
||||
|
||||
void reset_fault() {
|
||||
mutations.clear();
|
||||
safe_calls = 0;
|
||||
fail_at = -1;
|
||||
fault_hit = false;
|
||||
}
|
||||
|
||||
Image image() {
|
||||
return Image(probe_test_flash, probe_test_flash + sizeof(probe_test_flash));
|
||||
}
|
||||
|
||||
void restore(const Image& saved) {
|
||||
std::memcpy(probe_test_flash, saved.data(), saved.size());
|
||||
reset_fault();
|
||||
}
|
||||
|
||||
Blob blob(uint8_t instance, unsigned generation) {
|
||||
Blob result;
|
||||
for (size_t i = 0; i < result.size(); ++i)
|
||||
result[i] = static_cast<uint8_t>(instance * 31 + generation * 83 + i * 7);
|
||||
return result;
|
||||
}
|
||||
|
||||
void expect_blob(uint8_t instance, const Blob& expected) {
|
||||
Blob result;
|
||||
result.fill(0xa5);
|
||||
assert(probe_storage_load(instance, result.data(), result.size()));
|
||||
assert(result == expected);
|
||||
}
|
||||
|
||||
void expect_siblings(uint8_t target, const Blobs& expected) {
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance)
|
||||
if (instance != target) expect_blob(instance, expected[instance]);
|
||||
}
|
||||
|
||||
void expect_outside_unchanged(uint8_t target, const Image& before) {
|
||||
const uint32_t offset = probe_storage_offset(target);
|
||||
assert(std::memcmp(probe_test_flash, before.data(), offset) == 0);
|
||||
assert(std::memcmp(probe_test_flash + offset + kBankSize,
|
||||
before.data() + offset + kBankSize,
|
||||
sizeof(probe_test_flash) - offset - kBankSize) == 0);
|
||||
}
|
||||
|
||||
void erase_fixture() {
|
||||
reset_fault();
|
||||
// Non-erased sentinels protect firmware, profiles, configuration and BTstack.
|
||||
std::memset(probe_test_flash, 0xa5, sizeof(probe_test_flash));
|
||||
std::memset(probe_test_flash + kReservedOffset, 0xff, kProfileOffset - kReservedOffset);
|
||||
}
|
||||
|
||||
Blobs seed() {
|
||||
erase_fixture();
|
||||
Blobs expected;
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
expected[instance] = blob(instance, 1);
|
||||
assert(probe_storage_save(instance, expected[instance].data(), expected[instance].size()));
|
||||
}
|
||||
reset_fault();
|
||||
return expected;
|
||||
}
|
||||
|
||||
void test_offsets_and_isolation() {
|
||||
// These are the pre-experiment R/L offsets for the 2 MiB stub geometry.
|
||||
const uint32_t original_offsets[] = {0x1ba000, 0x1b8000, 0x1b6000, 0x1b4000};
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
const unsigned bank = PROBE_CONTROLLER_COUNT == 1 ? SWITCH2_PROBE_JOYCON_LEFT : instance;
|
||||
assert(probe_storage_offset(instance) == original_offsets[bank]);
|
||||
}
|
||||
assert(probe_storage_offset(PROBE_CONTROLLER_COUNT) == UINT32_MAX);
|
||||
assert(probe_storage_offset(UINT8_MAX) == UINT32_MAX);
|
||||
|
||||
auto expected = seed();
|
||||
for (unsigned generation = 2; generation <= 3; ++generation) {
|
||||
for (unsigned remaining = PROBE_CONTROLLER_COUNT; remaining; --remaining) {
|
||||
const uint8_t instance = static_cast<uint8_t>(remaining - 1);
|
||||
const auto before = image();
|
||||
expected[instance] = blob(instance, generation);
|
||||
assert(probe_storage_save(instance, expected[instance].data(), expected[instance].size()));
|
||||
expect_blob(instance, expected[instance]);
|
||||
expect_siblings(instance, expected);
|
||||
expect_outside_unchanged(instance, before);
|
||||
reset_fault();
|
||||
assert(probe_storage_save(instance, expected[instance].data(), expected[instance].size()));
|
||||
assert(mutations.empty()); // Identical saves must not wear flash.
|
||||
}
|
||||
}
|
||||
Blob output;
|
||||
output.fill(0xa5);
|
||||
const Blob untouched = output;
|
||||
const auto before = image();
|
||||
assert(!probe_storage_load(PROBE_CONTROLLER_COUNT, output.data(), output.size()));
|
||||
assert(!probe_storage_save(PROBE_CONTROLLER_COUNT, output.data(), output.size()));
|
||||
assert(!probe_storage_load(UINT8_MAX, output.data(), output.size()));
|
||||
assert(!probe_storage_save(UINT8_MAX, output.data(), output.size()));
|
||||
assert(!probe_storage_load(0, output.data(), output.size() - 1));
|
||||
assert(output == untouched);
|
||||
assert(image() == before);
|
||||
assert(mutations.empty());
|
||||
}
|
||||
|
||||
void test_interrupted_updates() {
|
||||
for (uint8_t target = 0; target < PROBE_CONTROLLER_COUNT; ++target) {
|
||||
// An erased inactive slot needs only programming. A reused inactive slot
|
||||
// must first erase its old owned record; cover both atomic transitions.
|
||||
for (bool reuse : {false, true}) {
|
||||
auto expected = seed();
|
||||
if (reuse) {
|
||||
expected[target] = blob(target, 2);
|
||||
assert(probe_storage_save(target, expected[target].data(), expected[target].size()));
|
||||
}
|
||||
const auto before = image();
|
||||
const Blob replacement = blob(target, 3);
|
||||
reset_fault();
|
||||
assert(probe_storage_save(target, replacement.data(), replacement.size()));
|
||||
const auto successful_mutations = mutations;
|
||||
assert(!successful_mutations.empty());
|
||||
for (size_t cut = 0; cut < successful_mutations.size(); ++cut) {
|
||||
const Mutation interrupted = successful_mutations[cut];
|
||||
const size_t partials[] = {0, 1, FLASH_PAGE_SIZE / 2, interrupted.size};
|
||||
for (size_t partial : partials) {
|
||||
restore(before);
|
||||
fail_at = static_cast<int>(cut);
|
||||
torn_bytes = partial;
|
||||
assert(!probe_storage_save(target, replacement.data(), replacement.size()));
|
||||
assert(fault_hit);
|
||||
expect_outside_unchanged(target, before);
|
||||
expect_siblings(target, expected);
|
||||
Blob recovered;
|
||||
assert(probe_storage_load(target, recovered.data(), recovered.size()));
|
||||
// A fully programmed commit may survive despite an ambiguous
|
||||
// flash-safe return. Only the complete old OR new blob is legal.
|
||||
assert(recovered == expected[target] || recovered == replacement);
|
||||
|
||||
// A torn owner/erase cannot prove ownership and must refuse
|
||||
// further writes. Complete ownership allows body/commit recovery.
|
||||
const bool unknown = interrupted.erase ?
|
||||
partial != 0 && partial < interrupted.size :
|
||||
interrupted.offset % FLASH_SECTOR_SIZE == 0 && partial != 0 && partial < 40;
|
||||
const auto after_failure = image();
|
||||
reset_fault();
|
||||
const bool saved = probe_storage_save(target, replacement.data(), replacement.size());
|
||||
assert(saved != unknown);
|
||||
if (unknown) {
|
||||
assert(mutations.empty());
|
||||
assert(image() == after_failure);
|
||||
} else {
|
||||
expect_blob(target, replacement);
|
||||
}
|
||||
expect_siblings(target, expected);
|
||||
expect_outside_unchanged(target, before);
|
||||
if (unknown && PROBE_CONTROLLER_COUNT > 1) {
|
||||
const uint8_t sibling = (target + 1) % PROBE_CONTROLLER_COUNT;
|
||||
const auto before_sibling = image();
|
||||
const Blob sibling_replacement = blob(sibling, 4);
|
||||
assert(probe_storage_save(sibling, sibling_replacement.data(), sibling_replacement.size()));
|
||||
expect_blob(sibling, sibling_replacement);
|
||||
expect_outside_unchanged(sibling, before_sibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void test_unknown_sectors() {
|
||||
for (uint8_t target = 0; target < PROBE_CONTROLLER_COUNT; ++target) {
|
||||
for (unsigned slot = 0; slot < 2; ++slot) {
|
||||
const auto expected = seed();
|
||||
// Neither an arbitrary sector nor a record copied from a different
|
||||
// absolute bank may be claimed just because another child owns it.
|
||||
const uint32_t offset = probe_storage_offset(target) + slot * FLASH_SECTOR_SIZE;
|
||||
if (slot == 1 && PROBE_CONTROLLER_COUNT > 1) {
|
||||
const uint8_t sibling = (target + 1) % PROBE_CONTROLLER_COUNT;
|
||||
std::memcpy(probe_test_flash + offset,
|
||||
probe_test_flash + probe_storage_offset(sibling), FLASH_SECTOR_SIZE);
|
||||
} else {
|
||||
probe_test_flash[offset] ^= 0x55;
|
||||
}
|
||||
const auto before = image();
|
||||
const Blob replacement = blob(target, 2);
|
||||
assert(!probe_storage_save(target, replacement.data(), replacement.size()));
|
||||
assert(mutations.empty());
|
||||
assert(image() == before);
|
||||
Blob output;
|
||||
output.fill(0xa5);
|
||||
const Blob untouched = output;
|
||||
if (slot == 0) {
|
||||
assert(!probe_storage_load(target, output.data(), output.size()));
|
||||
assert(output == untouched);
|
||||
} else {
|
||||
expect_blob(target, expected[target]);
|
||||
}
|
||||
expect_siblings(target, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void test_reserved_range_overlap() {
|
||||
erase_fixture();
|
||||
const auto before = image();
|
||||
for (uint8_t instance = 0; instance < PROBE_CONTROLLER_COUNT; ++instance) {
|
||||
Blob output;
|
||||
output.fill(0xa5);
|
||||
const Blob untouched = output;
|
||||
assert(!probe_storage_load(instance, output.data(), output.size()));
|
||||
assert(output == untouched);
|
||||
assert(!probe_storage_save(instance, output.data(), output.size()));
|
||||
}
|
||||
assert(mutations.empty());
|
||||
assert(image() == before);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void flash_range_erase(uint32_t offset, size_t count) {
|
||||
assert(inside_safe);
|
||||
assert(offset % FLASH_SECTOR_SIZE == 0 && count == FLASH_SECTOR_SIZE);
|
||||
assert(offset <= PICO_FLASH_SIZE_BYTES && count <= PICO_FLASH_SIZE_BYTES - offset);
|
||||
mutations.push_back({offset, count, true});
|
||||
std::memset(probe_test_flash + offset, 0xff, std::min(count, mutation_limit));
|
||||
}
|
||||
|
||||
void flash_range_program(uint32_t offset, const uint8_t* data, size_t count) {
|
||||
assert(inside_safe);
|
||||
assert(offset % FLASH_PAGE_SIZE == 0 && count == FLASH_PAGE_SIZE);
|
||||
assert(offset <= PICO_FLASH_SIZE_BYTES && count <= PICO_FLASH_SIZE_BYTES - offset);
|
||||
mutations.push_back({offset, count, false});
|
||||
for (size_t i = 0; i < std::min(count, mutation_limit); ++i)
|
||||
probe_test_flash[offset + i] &= data[i];
|
||||
}
|
||||
|
||||
int flash_safe_execute(void (*function)(void*), void* parameter, uint32_t timeout_ms) {
|
||||
assert(timeout_ms != 0 && !inside_safe);
|
||||
const bool fail = safe_calls++ == fail_at;
|
||||
mutation_limit = fail ? torn_bytes : std::numeric_limits<size_t>::max();
|
||||
fault_hit |= fail;
|
||||
inside_safe = true;
|
||||
function(parameter);
|
||||
inside_safe = false;
|
||||
return fail ? -1 : PICO_OK;
|
||||
}
|
||||
|
||||
int main() {
|
||||
if (PROBE_TEST_STORAGE_OVERLAP) {
|
||||
test_reserved_range_overlap();
|
||||
} else {
|
||||
test_offsets_and_isolation();
|
||||
test_interrupted_updates();
|
||||
test_unknown_sectors();
|
||||
}
|
||||
std::puts("switch2 probe pairing storage tests passed");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -8,7 +8,10 @@ import pytest
|
|||
|
||||
|
||||
@pytest.mark.parametrize("source", ("GAMEPAD", "DUALSENSE"))
|
||||
def test_native_gamepad_backend_native(tmp_path: Path, source: str) -> None:
|
||||
@pytest.mark.parametrize("controller_count", (2, 4))
|
||||
def test_native_gamepad_backend_native(
|
||||
tmp_path: Path, source: str, controller_count: int
|
||||
) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
|
|
@ -39,6 +42,7 @@ def test_native_gamepad_backend_native(tmp_path: Path, source: str) -> None:
|
|||
"-DSWITCH_PICO_ENABLE_CLASSIC=1",
|
||||
"-DSWITCH2_BRIDGE_FULL_INPUT=1",
|
||||
f"-DSWITCH2_BRIDGE_{source}_INPUT=1",
|
||||
f"-DPROBE_CONTROLLER_COUNT={controller_count}",
|
||||
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
|
||||
f"-I{firmware}",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
|
|
@ -49,19 +53,22 @@ def test_native_gamepad_backend_native(tmp_path: Path, source: str) -> None:
|
|||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
for scenario in (
|
||||
"stable-logical-slot",
|
||||
"source-isolation",
|
||||
"cue-lifetime",
|
||||
"cue-races",
|
||||
):
|
||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||
scenarios = ["stable-logical-slot", "cue-lifetime", "cue-races"]
|
||||
if controller_count == 2:
|
||||
scenarios.append("source-isolation")
|
||||
else:
|
||||
scenarios.extend(("two-pair-sources", "two-pair-cues", "explicit-precedence"))
|
||||
if source == "GAMEPAD":
|
||||
for scenario in (
|
||||
"sensorless-admission",
|
||||
"independent-motion",
|
||||
"paired-source",
|
||||
"pair-cue-races",
|
||||
"mono-rumble",
|
||||
):
|
||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||
scenarios.extend(("paired-source", "pair-cue-races", "mono-rumble"))
|
||||
if controller_count == 2:
|
||||
scenarios.extend(("sensorless-admission", "independent-motion"))
|
||||
else:
|
||||
scenarios.extend(
|
||||
(
|
||||
"paired-explicit-conflict",
|
||||
"topology-reservations",
|
||||
"stable-ble-reservation",
|
||||
)
|
||||
)
|
||||
for scenario in scenarios:
|
||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||
|
|
|
|||
53
tests/test_native_hub_log_native.py
Normal file
53
tests/test_native_hub_log_native.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_native_logger_keeps_usb_interrupt_progress(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("cc") or shutil.which("gcc")
|
||||
assert compiler is not None, "a host C compiler is required"
|
||||
(tmp_path / "probe_version.h").write_text(
|
||||
"static const uint8_t probe_version_replies[PROBE_CONTROLLER_COUNT][16] = {{0}};\n"
|
||||
"static const uint8_t probe_firmware_versions[PROBE_CONTROLLER_COUNT][12] = {{0}};\n"
|
||||
)
|
||||
executable = tmp_path / "native_hub_log_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c11",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-ffunction-sections",
|
||||
"-fdata-sections",
|
||||
"-DSWITCH2_PROBE_HUB=1",
|
||||
"-DPROBE_CONTROLLER_COUNT=4",
|
||||
"-DSWITCH2_PROBE_NEUTRAL_INPUT=1",
|
||||
"-DSWITCH2_PROBE_TRACE_NATIVE_INPUT=1",
|
||||
"-DSWITCH2_PROBE_USB_INIT=1",
|
||||
"-DSWITCH2_PROBE_MEMORY=1",
|
||||
"-DSWITCH2_PROBE_VERSION_REPLY=1",
|
||||
f"-I{root / 'tests' / 'native_hub_stubs'}",
|
||||
f"-I{root / 'src' / 'firmware'}",
|
||||
f"-I{root / 'tools' / 'switch2_usb_probe'}",
|
||||
f"-I{tmp_path}",
|
||||
str(root / "tests" / "native_hub_log_test.c"),
|
||||
"-Wl,--gc-sections",
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
for caller in ("core", "irq"):
|
||||
rejected = subprocess.run(
|
||||
[str(executable), caller], capture_output=True, check=False, cwd=root
|
||||
)
|
||||
assert rejected.returncode == -signal.SIGABRT, (
|
||||
"unsafe concurrent log producer was accepted"
|
||||
)
|
||||
|
|
@ -2,8 +2,17 @@ import shutil
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
def test_native_hub_management_native(tmp_path: Path) -> None:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("controller_count", "neutral_input"),
|
||||
[(2, False), (2, True), (4, True)],
|
||||
ids=["native-management", "neutral-one-pair", "neutral-two-pair"],
|
||||
)
|
||||
def test_native_hub_management_native(
|
||||
tmp_path: Path, controller_count: int, neutral_input: bool
|
||||
) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
cc = shutil.which("cc") or shutil.which("gcc")
|
||||
cxx = shutil.which("c++") or shutil.which("g++")
|
||||
|
|
@ -15,7 +24,17 @@ def test_native_hub_management_native(tmp_path: Path) -> None:
|
|||
f"-I{root / 'tools' / 'pico_usb_address_probe'}",
|
||||
f"-I{root / 'tools' / 'switch2_usb_probe'}",
|
||||
]
|
||||
flags = ["-Wall", "-Wextra", "-Werror", "-pedantic", "-DSWITCH2_PROBE_HUB=1"]
|
||||
flags = [
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
"-ffunction-sections",
|
||||
"-fdata-sections",
|
||||
"-DSWITCH2_PROBE_HUB=1",
|
||||
f"-DPROBE_CONTROLLER_COUNT={controller_count}",
|
||||
]
|
||||
flags.append(f"-DSWITCH2_PROBE_NEUTRAL_INPUT={int(neutral_input)}")
|
||||
transport = tmp_path / "native_hub_transport.o"
|
||||
executable = tmp_path / "native_hub_management_test"
|
||||
subprocess.run(
|
||||
|
|
@ -48,6 +67,7 @@ def test_native_hub_management_native(tmp_path: Path) -> None:
|
|||
"-std=c++17",
|
||||
*flags,
|
||||
*includes,
|
||||
"-Wl,--gc-sections",
|
||||
*(str(root / path) for path in sources),
|
||||
str(transport),
|
||||
"-o",
|
||||
|
|
@ -56,4 +76,20 @@ def test_native_hub_management_native(tmp_path: Path) -> None:
|
|||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
for reboot_slot in ("root", "child"):
|
||||
subprocess.run([str(executable), reboot_slot], check=True, cwd=root)
|
||||
router_executable = tmp_path / "native_hub_router_test"
|
||||
subprocess.run(
|
||||
[
|
||||
cc,
|
||||
"-std=c11",
|
||||
*flags,
|
||||
*includes,
|
||||
str(root / "tests" / "native_hub_router_test.c"),
|
||||
"-o",
|
||||
str(router_executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(router_executable)], check=True, cwd=root)
|
||||
|
|
|
|||
95
tests/test_native_hub_trace_native.py
Normal file
95
tests/test_native_hub_trace_native.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("controller_count", [2, 4], ids=["one-pair", "two-pair"])
|
||||
def test_native_hub_trace_lifecycle(tmp_path: Path, controller_count: int) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("cc") or shutil.which("gcc")
|
||||
assert compiler is not None, "a host C compiler is required"
|
||||
executable = tmp_path / "native_hub_trace_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c11",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
"-ffunction-sections",
|
||||
"-fdata-sections",
|
||||
"-DSWITCH2_PROBE_HUB=1",
|
||||
"-DSWITCH2_PROBE_TRACE_NATIVE_INPUT=1",
|
||||
f"-DPROBE_CONTROLLER_COUNT={controller_count}",
|
||||
f"-I{root / 'tests' / 'native_hub_stubs'}",
|
||||
f"-I{root / 'src' / 'firmware'}",
|
||||
f"-I{root / 'tools' / 'pico_usb_address_probe'}",
|
||||
f"-I{root / 'tools' / 'switch2_usb_probe'}",
|
||||
str(root / "tests" / "native_hub_trace_test.c"),
|
||||
"-Wl,--gc-sections",
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
for scenario in (
|
||||
"live-wrap",
|
||||
"root-idle",
|
||||
"queue-pressure",
|
||||
"pending",
|
||||
"superseded",
|
||||
"immediate-supersession",
|
||||
"poll-retention",
|
||||
"selection-history",
|
||||
"frozen-selection-history",
|
||||
"delayed-publication",
|
||||
"publication-isolation",
|
||||
"publication-wrap-supersession",
|
||||
"ep0-handover",
|
||||
"coherent-publication",
|
||||
"bulk-commit-pids",
|
||||
"approved-status-handoff",
|
||||
"approved-status-superseded",
|
||||
"approved-status-reset",
|
||||
"approved-status-reset-during-completion",
|
||||
"approved-status-port-reset-ready",
|
||||
"approved-status-port-reset-queued",
|
||||
"approved-status-invalid-length",
|
||||
"approved-status-watch",
|
||||
"status-out-rejected-data",
|
||||
"status-out",
|
||||
"status-out-superseded",
|
||||
"status-out-stale",
|
||||
"status-out-reset",
|
||||
"status-out-reset-watch",
|
||||
"status-out-port-reset-watch",
|
||||
"marker-zero",
|
||||
"marker-active",
|
||||
"marker-rejected",
|
||||
):
|
||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||
|
||||
for mode in ("waiting", "partial"):
|
||||
subprocess.run([str(executable), "marker-priority", mode], check=True, cwd=root)
|
||||
|
||||
# Identical snapshots must reach the consumer in identical order even when
|
||||
# every individual header, record, and END is rejected twice by the logger.
|
||||
outputs = []
|
||||
for mode in ("open", "full"):
|
||||
result = subprocess.run(
|
||||
[str(executable), "backpressure", mode],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
outputs.append(result.stdout)
|
||||
assert outputs[0] == outputs[1], (
|
||||
"logger backpressure skipped, reordered, or changed dump lines"
|
||||
)
|
||||
245
tests/test_native_joycon_hub_live.py
Normal file
245
tests/test_native_joycon_hub_live.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_rig(monkeypatch, tmp_path):
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
import native_joycon_hub_check as check
|
||||
from switch2_native_imu import encode_mode0
|
||||
|
||||
rig = SimpleNamespace(check=check, clock=0.0, activity="independent")
|
||||
monkeypatch.setattr(check, "time", SimpleNamespace(monotonic=lambda: rig.clock))
|
||||
|
||||
def configure(pairs=2, mode="GAMEPAD", target="BOTH", neutral=False):
|
||||
cache = [
|
||||
f"SWITCH2_PROBE_PAIR_COUNT:STRING={pairs}",
|
||||
"SWITCH2_PROBE_HUB:BOOL=ON",
|
||||
"SWITCH2_PROBE_USB_INIT:BOOL=ON",
|
||||
f"SWITCH2_PROBE_NEUTRAL_INPUT:BOOL={'ON' if neutral else 'OFF'}",
|
||||
f"SWITCH_PICO_SWITCH2_USB_BRIDGE:BOOL={'OFF' if neutral else 'ON'}",
|
||||
f"SWITCH2_BRIDGE_INPUT:STRING={mode}",
|
||||
f"SWITCH2_BRIDGE_IMU_TARGET:STRING={target}",
|
||||
]
|
||||
for index, (child, model) in enumerate(check.child_models(pairs).items()):
|
||||
identity = bytearray(64)
|
||||
identity[0] = index + 1
|
||||
struct.pack_into("<HH", identity, 18, check.VID, model["pid"])
|
||||
version = bytearray(12)
|
||||
version[3] = int(model["side"] == "R")
|
||||
factory = identity + bytearray(8192 - len(identity))
|
||||
for field, contents in (
|
||||
("IDENTITY_FILE", identity),
|
||||
("VERSION_FILE", version),
|
||||
("FACTORY_FILE", factory),
|
||||
):
|
||||
path = tmp_path / f"{child}-{field}.bin"
|
||||
path.write_bytes(contents)
|
||||
cache.append(f"{model['capture_prefix']}_{field}:FILEPATH={path}")
|
||||
cache.append(
|
||||
f"{model['capture_prefix']}_CONTROLLER_ADDRESS:STRING=02:00:00:00:00:{index + 1:02x}"
|
||||
)
|
||||
(tmp_path / "CMakeCache.txt").write_text("\n".join(cache))
|
||||
return tmp_path
|
||||
|
||||
class InputPipe:
|
||||
def __init__(self, scenario, child):
|
||||
self.scenario = scenario
|
||||
self.model = scenario.models[child]
|
||||
self.frame = 0
|
||||
|
||||
def read(self, endpoint, length, *, timeout):
|
||||
rig.clock += 0.02
|
||||
self.frame += 1
|
||||
pair = int(self.model["pair"] == "B")
|
||||
cycle = self.frame % 4
|
||||
missing = pair and (
|
||||
rig.activity == "missing"
|
||||
or (
|
||||
rig.activity == "disconnect"
|
||||
and self.scenario.current_stage == "active_input_and_read_isolation"
|
||||
)
|
||||
)
|
||||
payload = bytearray(63)
|
||||
payload[0] = self.frame % 256
|
||||
payload[5:8] = b"\x00\x08\x80"
|
||||
if not missing:
|
||||
controls_pair = 0 if rig.activity == "mirrored" else pair
|
||||
payload[2] = (1 << (cycle + controls_pair * 4)) if cycle else 0
|
||||
payload[5] = controls_pair * 32 + cycle
|
||||
if not self.scenario.args.input_only:
|
||||
motion_pair = 0 if rig.activity == "mirrored" else pair
|
||||
motion_cycle = 0 if rig.activity == "static" else cycle
|
||||
block = encode_mode0(
|
||||
self.frame % 4096,
|
||||
4,
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[motion_pair + motion_cycle / 8, 0.0, 1.0],
|
||||
25,
|
||||
)
|
||||
offset = 14 if self.model["side"] == "L" else 15
|
||||
payload[offset] = len(block)
|
||||
payload[offset + 1 : offset + 1 + len(block)] = block
|
||||
return bytes((self.model["report"],)) + payload
|
||||
|
||||
def discover(scenario):
|
||||
scenario.devices = {
|
||||
child: InputPipe(scenario, child) for child in scenario.children
|
||||
}
|
||||
|
||||
monkeypatch.setattr(check.Check, "discover", discover)
|
||||
# Only the physical USB boundary is replaced; reference parsing, packet
|
||||
# decoding, readiness, active evidence, failure handling and JSON all run.
|
||||
for method in (
|
||||
"permissions",
|
||||
"claim",
|
||||
"descriptors",
|
||||
"identities",
|
||||
"initialize",
|
||||
"queries",
|
||||
"cleanup",
|
||||
):
|
||||
monkeypatch.setattr(check.Check, method, lambda *args, **kwargs: None)
|
||||
|
||||
def run(*, pairs=2, mode="GAMEPAD", input_only=False, activity="independent"):
|
||||
rig.clock = 0.0
|
||||
rig.activity = activity
|
||||
capture = tmp_path / "qualification.json"
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"native_joycon_hub_check.py",
|
||||
"--build-dir",
|
||||
str(configure(pairs, mode)),
|
||||
"--output",
|
||||
str(capture),
|
||||
"--duration",
|
||||
"2",
|
||||
*(["--pairs", str(pairs)] if pairs != 1 else []),
|
||||
*(["--input-only"] if input_only else []),
|
||||
],
|
||||
)
|
||||
status = check.main()
|
||||
return status, json.loads(capture.read_text())
|
||||
|
||||
rig.configure = configure
|
||||
rig.run = run
|
||||
return rig
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "input_only"), [("GAMEPAD", True), ("DUALSENSE", False)]
|
||||
)
|
||||
def test_two_live_pairs_qualify_distinct_activity_on_all_children(
|
||||
live_rig, mode, input_only
|
||||
):
|
||||
status, audit = live_rig.run(mode=mode, input_only=input_only)
|
||||
|
||||
assert status == 0, audit.get("failure", audit["errors"])
|
||||
children = ("A_R", "A_L", "B_R", "B_L")
|
||||
assert tuple(audit["child_results"]) == children
|
||||
assert not audit["gameplay_proven"]
|
||||
assert not audit["physical_latency_proven"]
|
||||
assert not audit["physical_source_isolation_proven"]
|
||||
for child in children:
|
||||
result = audit["child_results"][child]
|
||||
assert result["qualified"] and result["live_input_proven"]
|
||||
assert result["live_imu_proven"] is not input_only
|
||||
assert result["last_sample"]["packet_hex"].startswith(
|
||||
"08" if child.endswith("R") else "07"
|
||||
)
|
||||
assert f"{child}=" in audit["summary"]
|
||||
if not input_only:
|
||||
for pair in ("A", "B"):
|
||||
evidence = audit["imu_isolation"]["pairs"][pair]
|
||||
assert evidence["policy"] == "shared_physical_source"
|
||||
assert evidence["identical_blocks_seen_on_both_sides"] >= 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_only", "activity"), [(True, "missing"), (False, "disconnect")]
|
||||
)
|
||||
def test_unassigned_or_disconnected_second_pair_cannot_qualify(
|
||||
live_rig, input_only, activity
|
||||
):
|
||||
status, audit = live_rig.run(input_only=input_only, activity=activity)
|
||||
|
||||
assert status == 2
|
||||
assert not audit["success"]
|
||||
assert all(
|
||||
not child["live_input_proven"] for child in audit["child_results"].values()
|
||||
)
|
||||
if activity == "missing":
|
||||
assert audit["streams"]["B_R"]["buttons_nonzero"] == 0
|
||||
assert audit["streams"]["B_L"]["control_changes"] == 0
|
||||
else:
|
||||
assert {error["side"] for error in audit["errors"]} >= {"B_R", "B_L"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_only", "activity"),
|
||||
[(True, "mirrored"), (False, "mirrored"), (False, "static")],
|
||||
)
|
||||
def test_equal_or_static_pair_evidence_is_not_independent_activity(
|
||||
live_rig, input_only, activity
|
||||
):
|
||||
status, audit = live_rig.run(input_only=input_only, activity=activity)
|
||||
|
||||
assert status == 2
|
||||
assert not audit["physical_source_isolation_proven"]
|
||||
assert {error["side"] for error in audit["errors"]} >= {"A_R", "A_L", "B_R", "B_L"}
|
||||
|
||||
|
||||
def test_default_one_pair_keeps_right_left_capture_ids(live_rig):
|
||||
status, audit = live_rig.run(pairs=1, input_only=True)
|
||||
|
||||
assert status == 0, audit["errors"]
|
||||
assert tuple(audit["child_results"]) == ("R", "L")
|
||||
assert [child["port"] for child in audit["child_results"].values()] == [1, 2]
|
||||
|
||||
|
||||
def test_two_pair_input_only_accepts_side_target_but_imu_requires_both(live_rig):
|
||||
build = live_rig.configure(target="LEFT")
|
||||
assert tuple(
|
||||
live_rig.check.model_references(build, pairs=2, require_imu=False)
|
||||
) == ("A_R", "A_L", "B_R", "B_L")
|
||||
with pytest.raises(ValueError):
|
||||
live_rig.check.model_references(build, pairs=2, require_imu=True)
|
||||
|
||||
|
||||
def test_two_pair_live_rejects_donor_only_and_neutral_builds(live_rig):
|
||||
for settings in ({"mode": "JOYCON2"}, {"neutral": True}):
|
||||
build = live_rig.configure(**settings)
|
||||
with pytest.raises(ValueError):
|
||||
live_rig.check.model_references(build, pairs=2, require_imu=False)
|
||||
|
||||
|
||||
def test_neutral_and_input_only_are_exclusive_before_capture(
|
||||
live_rig, monkeypatch, tmp_path
|
||||
):
|
||||
capture = tmp_path / "incompatible.json"
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"native_joycon_hub_check.py",
|
||||
"--pairs",
|
||||
"2",
|
||||
"--neutral",
|
||||
"--input-only",
|
||||
"--output",
|
||||
str(capture),
|
||||
],
|
||||
)
|
||||
with pytest.raises(SystemExit) as error:
|
||||
live_rig.check.main()
|
||||
assert error.value.code == 2
|
||||
assert not capture.exists()
|
||||
315
tests/test_native_joycon_hub_recovery.py
Normal file
315
tests/test_native_joycon_hub_recovery.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import usb.core
|
||||
import usb.util
|
||||
|
||||
|
||||
class Root:
|
||||
idVendor = 0x057E
|
||||
idProduct = 0x2068
|
||||
bus = 2
|
||||
|
||||
def __init__(self, address=7, ports=(3, 4), serial="switch-pico-test"):
|
||||
self.address = address
|
||||
self.port_numbers = ports
|
||||
self.serial = self.cached_serial = serial
|
||||
self.transfers = []
|
||||
self.bootsel_requested = False
|
||||
self.write_result = 16
|
||||
|
||||
def ctrl_transfer(self, request_type, request, value, index, data, *, timeout):
|
||||
self.transfers.append((request_type, request, value, index, data))
|
||||
assert timeout > 0
|
||||
if request_type == 0x80:
|
||||
assert request == 6, "recovery must only read root identity descriptors"
|
||||
if value == 0x0100:
|
||||
assert index == 0 and data == 18
|
||||
descriptor = bytearray(18)
|
||||
descriptor[:2] = b"\x12\x01"
|
||||
descriptor[4] = 9
|
||||
descriptor[16] = 3
|
||||
struct.pack_into("<HH", descriptor, 8, self.idVendor, self.idProduct)
|
||||
return descriptor
|
||||
if value == 0x0300:
|
||||
assert index == 0 and data == 255
|
||||
return b"\x04\x03\x09\x04"
|
||||
assert value == 0x0303 and index == 0x0409 and data == 255
|
||||
serial = self.serial.encode("utf-16-le")
|
||||
return bytes((len(serial) + 2, 3)) + serial
|
||||
assert (request_type, request, value, index) == (0x40, 4, 0x5350, 1)
|
||||
assert data == b"SPMG\x01\x04\x00\x00" + bytes(8)
|
||||
assert not self.bootsel_requested, "recovery must never retry a reboot request"
|
||||
self.bootsel_requested = True
|
||||
if isinstance(self.write_result, Exception):
|
||||
raise self.write_result
|
||||
return self.write_result
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise AssertionError(f"forbidden USB operation during recovery: {name}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recovery_rig(monkeypatch, tmp_path):
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
import native_joycon_hub_check as check
|
||||
|
||||
root = Root()
|
||||
rom = SimpleNamespace(
|
||||
bus=root.bus,
|
||||
address=8,
|
||||
port_numbers=root.port_numbers,
|
||||
idVendor=0x2E8A,
|
||||
idProduct=0x000F,
|
||||
)
|
||||
rig = SimpleNamespace(
|
||||
check=check,
|
||||
root=root,
|
||||
rom=rom,
|
||||
devices=[root],
|
||||
after_reboot=[rom],
|
||||
acl=[],
|
||||
disposed=[],
|
||||
clock=0.0,
|
||||
capture=tmp_path / "recovery.json",
|
||||
)
|
||||
|
||||
def sleep(seconds):
|
||||
rig.clock += seconds
|
||||
|
||||
monkeypatch.setattr(
|
||||
check, "time", SimpleNamespace(monotonic=lambda: rig.clock, sleep=sleep)
|
||||
)
|
||||
|
||||
def find(*, find_all):
|
||||
assert find_all
|
||||
assert rig.capture.exists(), "audit capture must precede USB discovery"
|
||||
return rig.after_reboot if root.bootsel_requested else rig.devices
|
||||
|
||||
monkeypatch.setattr(usb.core, "find", find)
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def read_text(path, *args, **kwargs):
|
||||
if str(path).startswith("/sys/bus/usb/devices/"):
|
||||
for device in rig.devices:
|
||||
name = f"{device.bus}-" + ".".join(map(str, device.port_numbers))
|
||||
if path == Path("/sys/bus/usb/devices") / name / "serial":
|
||||
return device.cached_serial
|
||||
raise FileNotFoundError(str(path))
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", read_text)
|
||||
|
||||
def permissions(command, **kwargs):
|
||||
assert command == [
|
||||
"sudo",
|
||||
"-n",
|
||||
"setfacl",
|
||||
"-m",
|
||||
f"u:{os.getuid()}:rw",
|
||||
f"/dev/bus/usb/{root.bus:03d}/{root.address:03d}",
|
||||
], "recovery may grant access only to the verified root"
|
||||
rig.acl.append(command[-1])
|
||||
|
||||
monkeypatch.setattr(check.subprocess, "run", permissions)
|
||||
|
||||
def forbidden(*args, **kwargs):
|
||||
raise AssertionError("recovery attempted qualification or interface management")
|
||||
|
||||
monkeypatch.setattr(check, "model_references", forbidden)
|
||||
monkeypatch.setattr(check, "child_models", forbidden)
|
||||
monkeypatch.setattr(usb.util, "claim_interface", forbidden)
|
||||
monkeypatch.setattr(usb.util, "release_interface", forbidden)
|
||||
|
||||
def dispose(device):
|
||||
assert device is root, "unselected devices must receive no resource operations"
|
||||
rig.disposed.append(device)
|
||||
|
||||
monkeypatch.setattr(usb.util, "dispose_resources", dispose)
|
||||
|
||||
def run(*options, recovery=True):
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"native_joycon_hub_check.py",
|
||||
"--output",
|
||||
str(rig.capture),
|
||||
"--timeout",
|
||||
"0.4" if recovery else "20",
|
||||
*(["--reboot-bootsel"] if recovery else []),
|
||||
*options,
|
||||
],
|
||||
)
|
||||
status = check.main()
|
||||
return status, json.loads(rig.capture.read_text())
|
||||
|
||||
rig.run = run
|
||||
return rig
|
||||
|
||||
|
||||
@pytest.mark.parametrize("options", [(), ("--pairs", "2")])
|
||||
def test_root_only_recovery_confirms_rom_without_qualification(
|
||||
recovery_rig, tmp_path, options
|
||||
):
|
||||
rig = recovery_rig
|
||||
# A real Nintendo hub shares VID/PID but must receive no ACL or transfers.
|
||||
foreign = Root(address=11, ports=(3, 5), serial="Nintendo")
|
||||
rig.devices.append(foreign)
|
||||
status, audit = rig.run("--build-dir", str(tmp_path / "not-configured"), *options)
|
||||
|
||||
assert status == 0 and audit["recovery_success"]
|
||||
assert audit["devices"]["root"]["address"] == rig.root.address
|
||||
assert audit["devices"]["bootsel"]["ports"] == list(rig.root.port_numbers)
|
||||
assert audit["devices"]["bootsel"]["bus"] == rig.root.bus
|
||||
assert audit["devices"]["bootsel"]["address"] == rig.rom.address
|
||||
assert audit["recovery"] == {
|
||||
"request_attempted": True,
|
||||
"request_acknowledged": True,
|
||||
"root_disappeared": True,
|
||||
"rom_confirmed": True,
|
||||
}
|
||||
assert audit["operation"] == "bootsel_recovery"
|
||||
assert not audit["qualification_success"]
|
||||
assert not audit["live_input_proven"]
|
||||
assert not audit["live_imu_proven"]
|
||||
assert not audit["gameplay_proven"]
|
||||
assert foreign.transfers == []
|
||||
assert rig.root.bootsel_requested
|
||||
|
||||
|
||||
def test_ambiguous_picos_are_refused_before_permissions(recovery_rig):
|
||||
rig = recovery_rig
|
||||
other = Root(address=11, ports=(3, 5), serial="switch-pico-other")
|
||||
rig.devices.append(other)
|
||||
status, audit = rig.run()
|
||||
|
||||
assert status == 2 and not audit["recovery_success"]
|
||||
assert not audit["recovery"]["request_attempted"]
|
||||
assert rig.acl == []
|
||||
assert rig.root.transfers == other.transfers == []
|
||||
|
||||
|
||||
def test_foreign_root_is_refused_without_usb_access(recovery_rig):
|
||||
rig = recovery_rig
|
||||
rig.root.serial = rig.root.cached_serial = "Nintendo"
|
||||
status, audit = rig.run()
|
||||
|
||||
assert status == 2 and not audit["recovery_success"]
|
||||
assert not audit["recovery"]["request_attempted"]
|
||||
assert rig.acl == []
|
||||
assert rig.root.transfers == []
|
||||
|
||||
|
||||
def test_changed_usb_serial_cannot_receive_reboot(recovery_rig):
|
||||
rig = recovery_rig
|
||||
rig.root.serial = "Nintendo"
|
||||
status, audit = rig.run()
|
||||
|
||||
assert status == 2 and not audit["recovery_success"]
|
||||
assert not audit["recovery"]["request_attempted"]
|
||||
assert not rig.root.bootsel_requested
|
||||
assert all(transfer[0] == 0x80 for transfer in rig.root.transfers)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("write_result", [15, usb.core.USBError("disconnected")])
|
||||
def test_incomplete_control_write_is_not_acknowledged(recovery_rig, write_result):
|
||||
rig = recovery_rig
|
||||
rig.root.write_result = write_result
|
||||
status, audit = rig.run()
|
||||
|
||||
assert status == 2 and not audit["recovery_success"]
|
||||
assert audit["recovery"]["request_attempted"]
|
||||
assert not audit["recovery"]["request_acknowledged"]
|
||||
assert not audit["recovery"]["rom_confirmed"]
|
||||
|
||||
|
||||
def test_ack_without_rom_enumeration_is_incomplete(recovery_rig):
|
||||
rig = recovery_rig
|
||||
rig.after_reboot = []
|
||||
status, audit = rig.run()
|
||||
|
||||
assert status == 2 and not audit["recovery_success"]
|
||||
assert audit["recovery"]["request_acknowledged"]
|
||||
assert audit["recovery"]["root_disappeared"]
|
||||
assert not audit["recovery"]["rom_confirmed"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("bus", "ports"), [(2, (3, 5)), (3, (3, 4))])
|
||||
def test_rom_on_another_physical_path_does_not_confirm_recovery(
|
||||
recovery_rig, bus, ports
|
||||
):
|
||||
rig = recovery_rig
|
||||
rig.rom.bus = bus
|
||||
rig.rom.port_numbers = ports
|
||||
status, audit = rig.run()
|
||||
|
||||
assert status == 2 and not audit["recovery_success"]
|
||||
assert audit["recovery"]["request_acknowledged"]
|
||||
assert not audit["recovery"]["rom_confirmed"]
|
||||
|
||||
|
||||
def test_rom_cannot_confirm_while_root_still_enumerates(recovery_rig):
|
||||
rig = recovery_rig
|
||||
rig.after_reboot = [rig.root, rig.rom]
|
||||
status, audit = rig.run()
|
||||
|
||||
assert status == 2 and not audit["recovery_success"]
|
||||
assert audit["recovery"]["request_acknowledged"]
|
||||
assert not audit["recovery"]["root_disappeared"]
|
||||
assert not audit["recovery"]["rom_confirmed"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"options",
|
||||
[
|
||||
("--input-only",),
|
||||
("--neutral",),
|
||||
("--rumble-sample", "0"),
|
||||
("--capture-trace-on-error",),
|
||||
],
|
||||
)
|
||||
def test_recovery_rejects_qualification_and_motor_options(recovery_rig, options):
|
||||
rig = recovery_rig
|
||||
with pytest.raises(SystemExit) as error:
|
||||
rig.run(*options)
|
||||
|
||||
assert error.value.code == 2
|
||||
assert not rig.capture.exists()
|
||||
assert rig.acl == []
|
||||
assert rig.root.transfers == []
|
||||
|
||||
|
||||
def test_qualification_failure_does_not_reboot(monkeypatch, tmp_path):
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
import native_joycon_hub_check as check
|
||||
|
||||
capture = tmp_path / "failed-qualification.json"
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"native_joycon_hub_check.py",
|
||||
"--output",
|
||||
str(capture),
|
||||
"--build-dir",
|
||||
str(tmp_path / "absent-build"),
|
||||
],
|
||||
)
|
||||
|
||||
def forbidden_usb(*args, **kwargs):
|
||||
raise AssertionError(
|
||||
"failed qualification must not discover or reboot a device"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(usb.core, "find", forbidden_usb)
|
||||
assert check.main() == 2
|
||||
audit = json.loads(capture.read_text())
|
||||
assert not audit["success"]
|
||||
357
tests/test_native_joycon_hub_trace.py
Normal file
357
tests/test_native_joycon_hub_trace.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import usb.core
|
||||
|
||||
|
||||
def trace_reply(
|
||||
*,
|
||||
magic=b"NHTR",
|
||||
version=1,
|
||||
status=0,
|
||||
slot=4,
|
||||
reserved=0,
|
||||
time_us=123456,
|
||||
generation=17,
|
||||
):
|
||||
return struct.pack(
|
||||
"<4sBBBBII", magic, version, status, slot, reserved, time_us, generation
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trace_rig(monkeypatch, tmp_path):
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
import native_joycon_hub_check as check
|
||||
|
||||
rig = SimpleNamespace(
|
||||
check=check,
|
||||
clock=0.0,
|
||||
child_duration=0.25,
|
||||
events=[],
|
||||
child_error=usb.core.USBError("child descriptor timed out"),
|
||||
capture=io.StringIO(),
|
||||
)
|
||||
monkeypatch.setattr(check, "time", SimpleNamespace(monotonic=lambda: rig.clock))
|
||||
args = SimpleNamespace(
|
||||
pairs=2,
|
||||
timeout=10.0,
|
||||
duration=2.0,
|
||||
usb_timeout_ms=500,
|
||||
rumble_sample=None,
|
||||
input_only=True,
|
||||
neutral=False,
|
||||
output=tmp_path / "trace.json",
|
||||
build_dir=tmp_path,
|
||||
)
|
||||
# Intentionally omit the new option: existing Namespace callers remain valid.
|
||||
rig.scenario = check.Check(args, rig.capture)
|
||||
|
||||
class ControlPipe:
|
||||
def __init__(self, owner, response):
|
||||
self.owner = owner
|
||||
self.response = response
|
||||
self.transfers = []
|
||||
|
||||
def ctrl_transfer(
|
||||
self, request_type, request, value, index, length, *, timeout
|
||||
):
|
||||
assert request_type & 0x80, "trace tests must never issue USB OUT transfers"
|
||||
self.transfers.append(
|
||||
(request_type, request, value, index, length, timeout)
|
||||
)
|
||||
rig.events.append(self.owner)
|
||||
if self.owner != "root":
|
||||
rig.clock += rig.child_duration
|
||||
if isinstance(self.response, Exception):
|
||||
raise self.response
|
||||
return self.response
|
||||
|
||||
def attach_kernel_driver(self, interface):
|
||||
rig.events.append("reattach")
|
||||
|
||||
rig.root = ControlPipe("root", trace_reply())
|
||||
rig.child = ControlPipe("B_L", rig.child_error)
|
||||
rig.scenario.devices = {"root": rig.root, "B_L": rig.child}
|
||||
rig.scenario.util = SimpleNamespace(
|
||||
release_interface=lambda device, interface: rig.events.append("release"),
|
||||
dispose_resources=lambda device: rig.events.append("dispose"),
|
||||
)
|
||||
rig.scenario.claimed = [("B_L", 0)]
|
||||
rig.scenario.detached = [("B_L", 0)]
|
||||
|
||||
def control(owner="B_L", name="device_descriptor"):
|
||||
return rig.scenario.control(owner, name, 0x80, 6, 0x0100, 0, 18)
|
||||
|
||||
rig.control = control
|
||||
return rig
|
||||
|
||||
|
||||
@pytest.mark.parametrize("explicit_default", [False, True])
|
||||
def test_child_error_does_not_mark_without_opt_in(trace_rig, explicit_default):
|
||||
rig = trace_rig
|
||||
if explicit_default:
|
||||
rig.scenario.args.capture_trace_on_error = False
|
||||
|
||||
with pytest.raises(usb.core.USBError) as caught:
|
||||
rig.control()
|
||||
|
||||
assert caught.value is rig.child_error
|
||||
assert rig.root.transfers == []
|
||||
assert len(rig.child.transfers) == 1
|
||||
assert rig.scenario.result["failure_trace"] is None
|
||||
|
||||
|
||||
def test_opt_in_captures_before_error_propagation_and_cleanup(trace_rig):
|
||||
rig = trace_rig
|
||||
rig.scenario.args.capture_trace_on_error = True
|
||||
|
||||
with pytest.raises(usb.core.USBError) as caught:
|
||||
try:
|
||||
rig.control()
|
||||
except usb.core.USBError:
|
||||
rig.events.append("propagated")
|
||||
# The receipt is durable before the caller begins resource cleanup.
|
||||
audit = json.loads(rig.capture.getvalue())
|
||||
raise
|
||||
finally:
|
||||
rig.scenario.cleanup()
|
||||
|
||||
assert caught.value is rig.child_error
|
||||
assert rig.events == [
|
||||
"B_L",
|
||||
"root",
|
||||
"propagated",
|
||||
"release",
|
||||
"reattach",
|
||||
"dispose",
|
||||
"dispose",
|
||||
]
|
||||
assert len(rig.child.transfers) == 1
|
||||
assert rig.root.transfers == [(0xC0, 0x5E, 0x5452, 4, 16, 500)]
|
||||
trace = audit["failure_trace"]
|
||||
assert trace["status"] == "captured" and trace["captured"]
|
||||
assert trace["request_attempted"]
|
||||
assert trace["response_hex"] == trace_reply().hex()
|
||||
assert trace["receipt"] == {
|
||||
"version": 1,
|
||||
"status": 0,
|
||||
"slot": 4,
|
||||
"time_us": 123456,
|
||||
"control_generation": 17,
|
||||
}
|
||||
assert trace["failed_control"]["setup"] == [0x80, 6, 0x0100, 0, 18]
|
||||
assert trace["failed_control"]["side"] == "B_L"
|
||||
assert trace["failed_control"]["error"] == str(rig.child_error)
|
||||
|
||||
|
||||
def test_root_error_does_not_consume_the_single_child_marker(trace_rig):
|
||||
rig = trace_rig
|
||||
rig.scenario.args.capture_trace_on_error = True
|
||||
root_error = usb.core.USBError("root descriptor failed")
|
||||
rig.root.response = root_error
|
||||
with pytest.raises(usb.core.USBError) as caught:
|
||||
rig.control("root")
|
||||
assert caught.value is root_error
|
||||
assert len(rig.root.transfers) == 1
|
||||
assert rig.scenario.result["failure_trace"] is None
|
||||
|
||||
rig.root.response = trace_reply()
|
||||
for name in ("first_child_error", "later_child_error"):
|
||||
with pytest.raises(usb.core.USBError) as caught:
|
||||
rig.control(name=name)
|
||||
assert caught.value is rig.child_error
|
||||
|
||||
assert [transfer[1] for transfer in rig.root.transfers] == [6, 0x5E]
|
||||
audit = json.loads(rig.capture.getvalue())
|
||||
assert audit["failure_trace"]["failed_control"]["name"] == "first_child_error"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"marker_error",
|
||||
[usb.core.USBError("trace request stalled"), OSError("root disconnected")],
|
||||
)
|
||||
def test_failed_marker_preserves_original_error_and_is_not_retried(
|
||||
trace_rig, marker_error
|
||||
):
|
||||
rig = trace_rig
|
||||
rig.scenario.args.capture_trace_on_error = True
|
||||
rig.root.response = marker_error
|
||||
|
||||
for name in ("first_child_error", "later_child_error"):
|
||||
with pytest.raises(usb.core.USBError) as caught:
|
||||
rig.control(name=name)
|
||||
assert caught.value is rig.child_error
|
||||
|
||||
assert len(rig.root.transfers) == 1
|
||||
trace = json.loads(rig.capture.getvalue())["failure_trace"]
|
||||
assert trace["status"] == "error" and not trace["captured"]
|
||||
assert trace["response_hex"] is None and trace["receipt"] is None
|
||||
assert str(marker_error) in trace["error"]
|
||||
assert trace["failed_control"]["name"] == "first_child_error"
|
||||
|
||||
|
||||
def test_busy_marker_is_an_explicit_refusal_not_capture(trace_rig):
|
||||
rig = trace_rig
|
||||
rig.scenario.args.capture_trace_on_error = True
|
||||
rig.root.response = trace_reply(status=1, time_us=0, generation=0)
|
||||
|
||||
with pytest.raises(usb.core.USBError) as caught:
|
||||
rig.control()
|
||||
|
||||
assert caught.value is rig.child_error
|
||||
trace = json.loads(rig.capture.getvalue())["failure_trace"]
|
||||
assert trace["status"] == "busy" and not trace["captured"]
|
||||
assert trace["response_hex"] == rig.root.response.hex()
|
||||
assert trace["receipt"]["status"] == 1
|
||||
assert trace["receipt"]["time_us"] == trace["receipt"]["control_generation"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response",
|
||||
[
|
||||
trace_reply()[:-1],
|
||||
trace_reply() + b"\x00",
|
||||
trace_reply(magic=b"NOPE"),
|
||||
trace_reply(version=2),
|
||||
trace_reply(slot=3),
|
||||
trace_reply(reserved=1),
|
||||
trace_reply(status=2),
|
||||
trace_reply(status=1, time_us=1, generation=0),
|
||||
trace_reply(status=1, time_us=0, generation=1),
|
||||
],
|
||||
ids=[
|
||||
"short",
|
||||
"long",
|
||||
"magic",
|
||||
"version",
|
||||
"slot",
|
||||
"reserved",
|
||||
"status",
|
||||
"busy-time",
|
||||
"busy-generation",
|
||||
],
|
||||
)
|
||||
def test_malformed_marker_never_qualifies_as_a_capture(trace_rig, response):
|
||||
rig = trace_rig
|
||||
rig.scenario.args.capture_trace_on_error = True
|
||||
rig.root.response = response
|
||||
|
||||
with pytest.raises(usb.core.USBError) as caught:
|
||||
rig.control()
|
||||
|
||||
assert caught.value is rig.child_error
|
||||
trace = json.loads(rig.capture.getvalue())["failure_trace"]
|
||||
assert trace["status"] == "malformed" and not trace["captured"]
|
||||
assert trace["response_hex"] == response.hex()
|
||||
assert trace["receipt"] is None
|
||||
assert "error" in trace
|
||||
|
||||
|
||||
def test_marker_timeout_uses_only_remaining_deadline(trace_rig):
|
||||
rig = trace_rig
|
||||
rig.scenario.args.capture_trace_on_error = True
|
||||
rig.child_duration = rig.scenario.args.timeout - 0.125
|
||||
|
||||
with pytest.raises(usb.core.USBError) as caught:
|
||||
rig.control()
|
||||
|
||||
assert caught.value is rig.child_error
|
||||
assert rig.root.transfers == [(0xC0, 0x5E, 0x5452, 4, 16, 125)]
|
||||
|
||||
|
||||
def test_expired_deadline_after_child_error_records_without_request(trace_rig):
|
||||
rig = trace_rig
|
||||
rig.scenario.args.capture_trace_on_error = True
|
||||
rig.child_duration = rig.scenario.args.timeout
|
||||
|
||||
with pytest.raises(usb.core.USBError) as caught:
|
||||
rig.control()
|
||||
|
||||
assert caught.value is rig.child_error
|
||||
assert rig.root.transfers == []
|
||||
trace = json.loads(rig.capture.getvalue())["failure_trace"]
|
||||
assert trace["status"] == "deadline_expired" and not trace["captured"]
|
||||
assert not trace["request_attempted"]
|
||||
assert trace["failed_control"]["error"] == str(rig.child_error)
|
||||
|
||||
|
||||
def test_pretransfer_deadline_does_not_count_as_a_child_usb_failure(trace_rig):
|
||||
rig = trace_rig
|
||||
rig.scenario.args.capture_trace_on_error = True
|
||||
rig.clock = rig.scenario.deadline
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
rig.control()
|
||||
|
||||
assert rig.child.transfers == rig.root.transfers == []
|
||||
assert rig.scenario.result["failure_trace"] is None
|
||||
|
||||
|
||||
def test_capture_file_error_cannot_replace_child_transfer_error(trace_rig):
|
||||
rig = trace_rig
|
||||
rig.scenario.args.capture_trace_on_error = True
|
||||
rig.capture.close()
|
||||
|
||||
with pytest.raises(usb.core.USBError) as caught:
|
||||
rig.control()
|
||||
|
||||
assert caught.value is rig.child_error
|
||||
trace = rig.scenario.result["failure_trace"]
|
||||
assert trace["status"] == "captured"
|
||||
assert "ValueError" in trace["checkpoint_error"]
|
||||
|
||||
|
||||
def test_capture_option_marks_cli_failure_without_recovering(
|
||||
trace_rig, monkeypatch, tmp_path
|
||||
):
|
||||
rig = trace_rig
|
||||
capture = tmp_path / "option.json"
|
||||
rig.child.owner = "R"
|
||||
rig.root.response = trace_reply(slot=1)
|
||||
|
||||
def discover(scenario):
|
||||
scenario.devices = {"root": rig.root, "R": rig.child, "L": rig.child}
|
||||
scenario.util = rig.scenario.util
|
||||
|
||||
def claim(scenario):
|
||||
scenario.claimed = [("R", 0)]
|
||||
scenario.detached = [("R", 0)]
|
||||
|
||||
# Replace only private build references and the physical USB boundary. The
|
||||
# CLI, descriptor control, failure path, audit and cleanup execute normally.
|
||||
monkeypatch.setattr(
|
||||
rig.check, "model_references", lambda *args, **kwargs: rig.check.child_models(1)
|
||||
)
|
||||
monkeypatch.setattr(rig.check.Check, "discover", discover)
|
||||
monkeypatch.setattr(rig.check.Check, "permissions", lambda scenario: None)
|
||||
monkeypatch.setattr(rig.check.Check, "claim", claim)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"native_joycon_hub_check.py",
|
||||
"--capture-trace-on-error",
|
||||
"--output",
|
||||
str(capture),
|
||||
],
|
||||
)
|
||||
|
||||
assert rig.check.main() == 2
|
||||
audit = json.loads(capture.read_text())
|
||||
assert not audit["success"]
|
||||
assert audit["failure"] == str(rig.child_error)
|
||||
assert audit["failure_trace"]["status"] == "captured"
|
||||
assert audit["failure_trace"]["failed_control"]["side"] == "R"
|
||||
assert audit["parameters"]["capture_trace_on_error"]
|
||||
assert audit["safety"]["trace_marker_requested"]
|
||||
assert len(rig.child.transfers) == 1
|
||||
assert rig.root.transfers == [(0xC0, 0x5E, 0x5452, 1, 16, 500)]
|
||||
assert rig.events[:4] == ["R", "root", "release", "reattach"]
|
||||
|
|
@ -7,10 +7,12 @@ from pathlib import Path
|
|||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("controller_count", [2, 4], ids=["one-pair", "two-pair"])
|
||||
@pytest.mark.parametrize("imu_target", [1, 2, 3], ids=["right", "left", "both"])
|
||||
def test_native_gamepad_bridge_mapping_motion_and_backpressure(
|
||||
tmp_path: Path,
|
||||
imu_target: int,
|
||||
controller_count: int,
|
||||
) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
|
|
@ -29,6 +31,7 @@ def test_native_gamepad_bridge_mapping_motion_and_backpressure(
|
|||
"-DSWITCH2_BRIDGE_FULL_INPUT=1",
|
||||
"-DSWITCH2_BRIDGE_SOURCE_AUTO=1",
|
||||
"-DSWITCH2_PROBE_HUB=1",
|
||||
*(["-DPROBE_CONTROLLER_COUNT=4"] if controller_count == 4 else []),
|
||||
f"-DSWITCH2_BRIDGE_IMU_TARGET_MASK={imu_target}",
|
||||
"-DSWITCH_PICO_BLUEPAD32=1",
|
||||
"-DSWITCH_PICO_ENABLE_CLASSIC=1",
|
||||
|
|
|
|||
|
|
@ -9,15 +9,26 @@ import pytest
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("left", "composite"),
|
||||
[(False, False), (True, False), (False, True)],
|
||||
ids=["right", "left", "composite"],
|
||||
("left", "composite", "hub", "count"),
|
||||
[
|
||||
(False, False, False, 1),
|
||||
(True, False, False, 1),
|
||||
(False, True, False, 2),
|
||||
(False, False, True, 2),
|
||||
(False, False, True, 4),
|
||||
],
|
||||
ids=["right", "left", "composite", "hub-one-pair", "hub-two-pairs"],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"imu_mode", [None, "OMIT_NATIVE_IMU", "ZERO_NATIVE_IMU_PAYLOAD"]
|
||||
)
|
||||
def test_switch2_usb_probe_protocol(
|
||||
tmp_path: Path, left: bool, composite: bool, imu_mode: str | None
|
||||
tmp_path: Path,
|
||||
left: bool,
|
||||
composite: bool,
|
||||
hub: bool,
|
||||
count: int,
|
||||
imu_mode: str | None,
|
||||
) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("cc") or shutil.which("gcc")
|
||||
|
|
@ -41,20 +52,28 @@ def test_switch2_usb_probe_protocol(
|
|||
"Pico SDK mbedTLS required; configure firmware or set PICO_SDK_PATH"
|
||||
)
|
||||
probe = root / "tools" / "switch2_usb_probe"
|
||||
sides = [False, True] if composite else [left]
|
||||
sides = (
|
||||
[bool(instance & 1) for instance in range(count)]
|
||||
if composite or hub
|
||||
else [left]
|
||||
)
|
||||
factory_rows = []
|
||||
user_rows = []
|
||||
for is_left in sides:
|
||||
factory_center = "0x00, 0x09, 0x90" if is_left else "0x00, 0x08, 0x80"
|
||||
for instance, is_left in enumerate(sides):
|
||||
# A and B must differ even for the same side: detect side-indexed aliases.
|
||||
pair = instance // 2
|
||||
factory_center = (
|
||||
f"{pair * 0x20}, {9 if is_left else 8}, {0x90 if is_left else 0x80}"
|
||||
)
|
||||
factory_rows.append(
|
||||
f"{{[0xa8] = {factory_center}, 0, 3, 0x30, 0, 4, 0x40,"
|
||||
f" [8191] = {0xE2 if is_left else 0xE1}}}"
|
||||
f" [8191] = {(0xE2 if is_left else 0xE1) + pair * 2}}}"
|
||||
)
|
||||
# L deliberately has invalid user calibration despite valid magic.
|
||||
user_center = "0, 0, 0" if is_left else "0x10, 0x08, 0x81"
|
||||
user_center = "0, 0, 0" if is_left else f"{0x10 + pair * 0x20}, 0x08, 0x81"
|
||||
user_rows.append(
|
||||
f"{{[0x40] = 0xb2, 0xa1, {user_center}, 0, 3, 0x30, 0, 4, 0x40,"
|
||||
f" [4095] = {0xF2 if is_left else 0xF1}}}"
|
||||
f" [4095] = {(0xF2 if is_left else 0xF1) + pair * 2}}}"
|
||||
)
|
||||
(tmp_path / "probe_memory_data.h").write_text(
|
||||
'#include "model.h"\n'
|
||||
|
|
@ -77,6 +96,9 @@ def test_switch2_usb_probe_protocol(
|
|||
f'-DMBEDTLS_CONFIG_FILE="{probe / "mbedtls_config.h"}"',
|
||||
f"-DSWITCH2_PROBE_JOYCON_LEFT={int(left)}",
|
||||
f"-DSWITCH2_PROBE_COMPOSITE={int(composite)}",
|
||||
f"-DSWITCH2_PROBE_HUB={int(hub)}",
|
||||
f"-DPROBE_CONTROLLER_COUNT={count}",
|
||||
f"-DSWITCH2_PROBE_NEUTRAL_INPUT={int(hub)}",
|
||||
*([f"-DSWITCH2_PROBE_{imu_mode}=1"] if imu_mode else []),
|
||||
f"-I{probe}",
|
||||
f"-I{tmp_path}",
|
||||
|
|
|
|||
75
tests/test_switch2_usb_probe_storage_native.py
Normal file
75
tests/test_switch2_usb_probe_storage_native.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("left", "composite", "hub", "count", "overlap"),
|
||||
[
|
||||
(False, False, False, 1, False),
|
||||
(True, False, False, 1, False),
|
||||
(False, True, False, 2, False),
|
||||
(False, False, True, 2, False),
|
||||
(False, False, True, 4, False),
|
||||
(False, False, True, 4, True),
|
||||
],
|
||||
ids=[
|
||||
"right",
|
||||
"left",
|
||||
"composite",
|
||||
"hub-one-pair",
|
||||
"hub-two-pairs",
|
||||
"overlap-pair-b",
|
||||
],
|
||||
)
|
||||
def test_switch2_usb_probe_storage_native(
|
||||
tmp_path: Path, left: bool, composite: bool, hub: bool, count: int, overlap: bool
|
||||
) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
probe = root / "tools" / "switch2_usb_probe"
|
||||
# Stub geometry: 2 MiB flash, 8 KiB BTstack, 8 KiB configuration, 256 KiB
|
||||
# profiles. Link the SDK's end-of-image symbol at the exact reserved boundary,
|
||||
# or one byte into pair B while still safely below both original pair A banks.
|
||||
reserved_start = 0x1BC000 - max(2, count) * 8192
|
||||
binary_end = reserved_start + int(overlap)
|
||||
executable = tmp_path / "switch2_usb_probe_storage_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-DSWITCH2_PROBE_JOYCON_LEFT={int(left)}",
|
||||
f"-DSWITCH2_PROBE_COMPOSITE={int(composite)}",
|
||||
f"-DSWITCH2_PROBE_HUB={int(hub)}",
|
||||
f"-DSWITCH2_PROBE_NEUTRAL_INPUT={int(hub)}",
|
||||
f"-DPROBE_CONTROLLER_COUNT={count}",
|
||||
f"-DPROBE_TEST_STORAGE_OVERLAP={int(overlap)}",
|
||||
f"-I{root / 'tests' / 'switch2_usb_probe_storage_native_stubs'}",
|
||||
f"-I{root / 'src' / 'firmware'}",
|
||||
f"-I{probe}",
|
||||
str(root / "tests" / "switch2_usb_probe_storage_test.cpp"),
|
||||
str(probe / "storage.cpp"),
|
||||
str(
|
||||
root
|
||||
/ "src"
|
||||
/ "firmware"
|
||||
/ "configuration"
|
||||
/ "configuration_storage.cpp"
|
||||
),
|
||||
f"-Wl,--defsym=__flash_binary_end=probe_test_flash+{binary_end}",
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
Loading…
Add table
Add a link
Reference in a new issue