feat: give Joy-Con pairs independent persistent profile banks

This commit is contained in:
Joey Yakimowich-Payne 2026-09-07 15:41:06 -06:00
commit 34f8a50c19
25 changed files with 1644 additions and 117 deletions

View file

@ -14,6 +14,8 @@
#include "parser/uni_switch2_pairing.h"
#include "platform/pico/controller_color_config.h"
#include "input/switch2_wake.h"
#include "pico/critical_section.h"
#include "profile/profile_storage.h"
namespace {
@ -648,7 +650,26 @@ void switch2_wake_diagnostics(Switch2WakeDiagnostics*) {
}
#include "core/controller_identity.cpp"
namespace {
unsigned state_lock_depth = 0;
void tracked_state_lock_enter(critical_section_t* lock) {
critical_section_enter_blocking(lock);
++state_lock_depth;
}
void tracked_state_lock_exit(critical_section_t* lock) {
require(state_lock_depth != 0, "state lock exit must match an enter");
--state_lock_depth;
critical_section_exit(lock);
}
} // namespace
#define critical_section_enter_blocking tracked_state_lock_enter
#define critical_section_exit tracked_state_lock_exit
#include "input/bluepad32_input_backend.cpp"
#undef critical_section_enter_blocking
#undef critical_section_exit
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
namespace {
@ -701,6 +722,61 @@ void haptics_transport_probe_send(uint32_t, uint32_t, bool) {}
#endif
namespace {
uint8_t profile_flash[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE]{};
ProfileStorage runtime_profile_storage;
bool runtime_profile_storage_initialized = false;
bool fail_profile_program = false;
unsigned pair_observation_count = 0;
void (*before_pair_seed)(const ControllerIdentity&) = nullptr;
bool runtime_profile_read(void*, uint8_t arena, size_t offset,
uint8_t* output, size_t size) {
require(state_lock_depth == 0, "profile reads must not hold the input state lock");
if (arena >= PROFILE_STORAGE_ARENA_COUNT ||
offset > PROFILE_STORAGE_ARENA_SIZE ||
size > PROFILE_STORAGE_ARENA_SIZE - offset) {
return false;
}
memcpy(output, &profile_flash[arena][offset], size);
return true;
}
bool runtime_profile_erase(void*, uint8_t arena) {
require(state_lock_depth == 0, "profile erases must not hold the input state lock");
if (arena >= PROFILE_STORAGE_ARENA_COUNT) return false;
memset(profile_flash[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE);
return true;
}
bool runtime_profile_program(void*, uint8_t arena, size_t offset,
const uint8_t* page, size_t size) {
require(state_lock_depth == 0, "profile writes must not hold the input state lock");
if (fail_profile_program || arena >= PROFILE_STORAGE_ARENA_COUNT ||
size != PROFILE_STORAGE_PAGE_SIZE ||
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
offset > PROFILE_STORAGE_ARENA_SIZE - size) {
return false;
}
for (size_t index = 0; index < size; ++index) {
profile_flash[arena][offset + index] &= page[index];
}
return memcmp(&profile_flash[arena][offset], page, size) == 0;
}
ProfileStorageIo runtime_profile_io() {
return {nullptr, PROFILE_STORAGE_ARENA_SIZE,
PROFILE_STORAGE_SECTOR_SIZE, PROFILE_STORAGE_PAGE_SIZE,
runtime_profile_read, runtime_profile_erase, runtime_profile_program};
}
void initialize_runtime_profile_storage() {
if (runtime_profile_storage_initialized) return;
memset(profile_flash, 0xff, sizeof(profile_flash));
require(runtime_profile_storage.initialize(runtime_profile_io()),
"runtime profile catalog must initialize");
runtime_profile_storage_initialized = true;
}
void require_clear_completion_pending() {
if (expected_pending_clear_token != 0) {
require(!bluepad32_input_backend_clear_pairings_completed(
@ -752,8 +828,23 @@ bool profile_service_observe_identity_on_storage_core(
"profile identity observation fixture overflow");
observed_profile_identities[observed_profile_identity_count++] =
identity;
if (runtime_profile_storage_initialized) {
const ProfileStorageResult result = runtime_profile_storage.ensure_identity(identity);
return result == ProfileStorageResult::kOk ||
result == ProfileStorageResult::kUnchanged;
}
return true;
}
bool profile_service_observe_joycon_pair_on_storage_core(
const ControllerIdentity& identity) {
require(state_lock_depth == 0, "pair admission must release the input state lock");
++pair_observation_count;
if (before_pair_seed != nullptr) before_pair_seed(identity);
initialize_runtime_profile_storage();
const ProfileStorageResult result = runtime_profile_storage.ensure_joycon_pair(identity);
return result == ProfileStorageResult::kOk ||
result == ProfileStorageResult::kUnchanged;
}
void configuration_service_snapshot(ConfigurationServiceSnapshot* output) {
*output = {};
output->state = ConfigurationServiceState::kReady;
@ -840,6 +931,10 @@ void dispatch_identity_event(uint8_t event_type,
packet[2] = static_cast<uint8_t>(controller.conn.handle);
packet[3] = static_cast<uint8_t>(controller.conn.handle >> 8);
switch (event_type) {
case SM_EVENT_IDENTITY_RESOLVING_STARTED:
case SM_EVENT_IDENTITY_RESOLVING_FAILED:
packet_size = 11;
break;
case SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED:
packet_size = sizeof(packet);
packet[4] = BD_ADDR_TYPE_LE_RANDOM;
@ -968,6 +1063,32 @@ Bluepad32SlotSnapshot slot_snapshot(uint8_t index) {
return result;
}
void require_pair_owner(const ControllerIdentity& identity,
const uni_hid_device_t& left,
const uni_hid_device_t& right) {
ControllerIdentity left_member{};
ControllerIdentity right_member{};
require(controller_identity_is_joycon_pair(identity) &&
controller_identity_joycon_pair_members(
identity, &left_member, &right_member) &&
controller_identity_equal(left_member, identity_for_device(&left)) &&
controller_identity_equal(right_member, identity_for_device(&right)) &&
!controller_identity_equal(identity, left_member) &&
!controller_identity_equal(identity, right_member),
"logical owner must distinguish both typed physical members from their solo owners");
}
void require_active_profile(const ControllerIdentity& identity,
uint8_t expected_index, uint8_t expected_scale) {
const ProfileStorageIdentityIndex* owner = runtime_profile_storage.find(identity);
ControllerProfile profile{};
require(owner != nullptr && owner->active_profile == expected_index &&
runtime_profile_storage.get(identity, owner->active_profile, &profile) ==
ProfileStorageResult::kOk &&
profile.weak_rumble_scale == expected_scale,
"live owner must resolve its independent persisted active profile");
}
ControllerRumbleOutput ordered_switch2_hd(uint8_t frequency = 40) {
ControllerRumbleOutput rumble{17, 29};
rumble.hd.actuators[0].sample_count = 3;
@ -1315,6 +1436,18 @@ void test_switch2_pair_lifecycle(bool right_first) {
right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID);
uni_hid_device_t right = switch2_device(
right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID);
left.conn.btaddr[0] = 0xc1;
left.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
initialize_runtime_profile_storage();
const ControllerIdentity left_identity = identity_for_device(&left);
const ControllerIdentity right_identity = identity_for_device(&right);
ControllerProfile left_profile = controller_profile_default(left_identity, 2);
left_profile.weak_rumble_scale = 37;
require(runtime_profile_storage.set(left_identity, 2, left_profile) ==
ProfileStorageResult::kOk &&
runtime_profile_storage.activate(left_identity, 2) == ProfileStorageResult::kOk &&
runtime_profile_storage.activate(right_identity, 5) == ProfileStorageResult::kOk,
"solo owners must have independently selected profiles before pairing");
uni_hid_device_t& first = right_first ? right : left;
uni_hid_device_t& second = right_first ? left : right;
ready_switch2(first);
@ -1366,13 +1499,23 @@ void test_switch2_pair_lifecycle(bool right_first) {
Bluepad32SlotSnapshot merged = slot_snapshot(0);
require(merged.active && !slot_snapshot(1).active &&
merged.connection_generation != solo.connection_generation &&
controller_identity_equal(merged.identity, identity_for_device(&left)),
"either connection order must merge into first output with left profile owner");
merged.connection_generation != solo.connection_generation,
"either connection order must merge into the first-ready output");
require_pair_owner(merged.identity, left, right);
require_active_profile(merged.identity, 2, 37);
ControllerProfile pair_profile = controller_profile_default(merged.identity, 7);
pair_profile.weak_rumble_scale = 95;
require(runtime_profile_storage.set(merged.identity, 7, pair_profile) ==
ProfileStorageResult::kOk &&
runtime_profile_storage.activate(merged.identity, 7) == ProfileStorageResult::kOk,
"the merged bank must be independently editable");
require_active_profile(left_identity, 2, 37);
require_active_profile(right_identity, 5, UINT8_MAX);
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.controller_layout ==
Bluepad32ControllerLayout::kJoyCon2MergedPair &&
playtest.state.motion_sample_count == 0,
playtest.state.motion_sample_count == 0 &&
controller_identity_equal(playtest.identity, merged.identity),
"playtest must detect a live companion before any merged motion report");
Bluepad32CaptureSnapshot capture{};
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
@ -1391,6 +1534,28 @@ void test_switch2_pair_lifecycle(bool right_first) {
require(left.rumble_calls + right.rumble_calls == calls_after_merge &&
left.player_leds == 1 && right.player_leds == 1,
"old-generation solo feedback must not reach the pair");
require(!bluepad32_input_backend_identify(left_identity) &&
!bluepad32_input_backend_identify(right_identity) &&
bluepad32_input_backend_identify(merged.identity),
"identify must match the pair owner, not either physical solo owner");
process_rumble_timer(&g_rumble_timer);
require(left.last_low == UINT8_MAX && right.last_low == UINT8_MAX &&
left.player_leds == 1 && right.player_leds == 1,
"identifying the pair owner must reach both physical halves");
now_ms += 150;
process_rumble_timer(&g_rumble_timer);
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, left,
left.switch2_identity_address_type, left.conn.btaddr);
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, right,
right.switch2_identity_address_type, right.conn.btaddr);
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
"physical identity publication must not replace an enrolled composite owner");
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_FAILED, left,
left.switch2_identity_address_type, left.conn.btaddr);
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_STARTED, right,
right.switch2_identity_address_type, right.conn.btaddr);
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
"physical identity resolution resets must not demote a live pair to global");
platform_on_controller_data(&left, &left_data);
platform_on_controller_data(&right, &right_data);
merged = slot_snapshot(0);
@ -1456,6 +1621,10 @@ void test_switch2_pair_lifecycle(bool right_first) {
!detached.state.dpad_up && !detached.state.button_east &&
(right_first ? detached.state.button_south : detached.state.button_west),
"either half detach must immediately publish only the rotated survivor and own profile");
require_active_profile(detached.identity, right_first ? 5 : 2,
right_first ? UINT8_MAX : 37);
require(!bluepad32_input_backend_identify(merged.identity),
"an offline pair bank must not identify its surviving solo member");
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.controller_layout ==
(right_first ? Bluepad32ControllerLayout::kJoyCon2RightSolo
@ -1477,12 +1646,21 @@ void test_switch2_pair_lifecycle(bool right_first) {
slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons,
"late detached input and generation-bound feedback must be ignored");
uni_hid_device_t replacement = switch2_device(lost.idx, lost.product_id);
memcpy(replacement.conn.btaddr, lost.conn.btaddr, sizeof(bd_addr_t));
replacement.switch2_identity_address_type = lost.switch2_identity_address_type;
left_profile.weak_rumble_scale = 61;
require(runtime_profile_storage.set(left_identity, 2, left_profile) ==
ProfileStorageResult::kOk,
"changing the source solo must remain independent while detached");
ready_switch2(replacement);
platform_on_device_disconnected(&lost);
require(slot_snapshot(0).active && !slot_snapshot(1).active &&
slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons &&
slot_snapshot(0).connection_generation != detached.connection_generation,
"replacement must re-pair without stale presses or a late old disconnect");
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
"rejoining the same typed physical members must restore the same pair owner");
require_active_profile(slot_snapshot(0).identity, 7, 95);
const uint32_t clear_token = bluepad32_input_backend_clear_pairings();
process_rumble_timer(&g_rumble_timer);
Bluepad32PairingSnapshot cleared{};
@ -1506,11 +1684,11 @@ void test_switch2_multiple_pairs() {
ready_switch2(left1);
require(slot_snapshot(0).active && slot_snapshot(1).active &&
!slot_snapshot(2).active && !slot_snapshot(3).active &&
controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left0)) &&
controller_identity_equal(slot_snapshot(1).identity, identity_for_device(&left1)) &&
g_connection_policy_state == ConnectionPolicyState::Paused &&
!scanning_enabled && !incoming_connections,
"two deterministic pairs must consume four physical resources but only two outputs");
require_pair_owner(slot_snapshot(0).identity, left0, right0);
require_pair_owner(slot_snapshot(1).identity, left1, right1);
uni_controller_t data{};
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
data.gamepad.buttons = BUTTON_A;
@ -1552,6 +1730,128 @@ void test_switch2_multiple_pairs() {
"remapped ordinary physical index must isolate input and feedback from both pairs");
}
void test_switch2_pair_admission_failure(bool right_first) {
start_pairing_backend();
initialize_runtime_profile_storage();
auto left = switch2_device(right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID);
auto& first = right_first ? right : left;
auto& second = right_first ? left : right;
ready_switch2(first);
const ControllerIdentity solo_identity = identity_for_device(&first);
require(runtime_profile_storage.activate(solo_identity, 3) == ProfileStorageResult::kOk,
"the first solo must have an existing independent active profile");
uni_controller_t data{};
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
data.gamepad.buttons = BUTTON_TRIGGER_L | BUTTON_TRIGGER_R;
platform_on_controller_data(&first, &data);
const auto solo = slot_snapshot(0);
require(bluepad32_input_backend_capture_start(
0, solo.connection_generation, CaptureOptions{}),
"the first solo must remain recordable during pair admission");
bluepad32_input_backend_queue_profile_feedback(
0, solo.connection_generation, 2, ControllerProfileConfirmationPolicy::kRumble);
const int initial_rumble_calls = first.rumble_calls;
platform_on_device_connected(&second);
second.switch2_identity_valid = false;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
pair_observation_count == 0 &&
first.rumble_calls == initial_rumble_calls,
"an unstable member must reject pairing before storage or solo output changes");
second.switch2_identity_valid = true;
first.switch2_identity_valid = false;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
pair_observation_count == 0 &&
controller_identity_equal(slot_snapshot(0).identity, solo_identity),
"an unresolved first-ready member must not merge using its stale solo owner");
first.switch2_identity_valid = true;
require(runtime_profile_storage.ensure_identity(identity_for_device(&second)) ==
ProfileStorageResult::kOk,
"preexisting member rows must allow exercising failure of the actual pair seed");
before_pair_seed = [](const ControllerIdentity&) {
const auto first_solo = slot_snapshot(0);
Bluepad32CaptureSnapshot capture{};
require(first_solo.active &&
first_solo.identity.transport == ControllerTransport::kBle &&
!slot_snapshot(1).active &&
bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kRecording,
"persistent pair setup must precede any visible solo topology change");
};
fail_profile_program = true;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
pair_observation_count == 1,
"pair seed I/O failure must explicitly reject the second ready callback");
const auto rejected = slot_snapshot(0);
require(rejected.active && !slot_snapshot(1).active &&
rejected.connection_generation == solo.connection_generation &&
controller_identity_equal(rejected.identity, solo_identity) &&
rejected.state.left_trigger == solo.state.left_trigger &&
first.rumble_calls == initial_rumble_calls &&
first.player_leds == 1 && second.player_leds == 0,
"seed failure must leave the first solo's owner, input, generation and outputs intact");
ControllerIdentity pair_identity{};
require(controller_identity_make_joycon_pair(
identity_for_device(&left), identity_for_device(&right), &pair_identity) &&
runtime_profile_storage.find(pair_identity) == nullptr,
"a failed pair seed must not expose a partial persistent owner");
process_rumble_timer(&g_rumble_timer);
require(first.last_low == UINT8_MAX && second.rumble_calls == 0,
"a rejected merge must retain the first solo's queued generation-bound feedback");
fail_profile_program = false;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED,
"a retry before catalog replay must not expose a failed seed");
require(runtime_profile_storage.initialize(runtime_profile_io()),
"storage replay must recover intact member banks after admission failure");
require_active_profile(solo_identity, 3, UINT8_MAX);
require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS,
"a replayed catalog must allow the pending physical member to merge on retry");
before_pair_seed = nullptr;
require_pair_owner(slot_snapshot(0).identity, left, right);
require_active_profile(slot_snapshot(0).identity, right_first ? 0 : 3, UINT8_MAX);
}
void test_switch2_pair_member_replacement() {
start_pairing_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
right.conn.btaddr[0] = 0xc2;
ready_switch2(left);
ready_switch2(right);
const ControllerIdentity original = slot_snapshot(0).identity;
require(runtime_profile_storage.activate(original, 6) == ProfileStorageResult::kOk,
"the original physical pair must have a distinct saved selection");
platform_on_device_disconnected(&right);
auto replacement = switch2_device(1, UNI_SW2_JOYCON_R_PID);
replacement.conn.btaddr[5] = 0x35;
ready_switch2(replacement);
const ControllerIdentity replaced = slot_snapshot(0).identity;
require_pair_owner(replaced, left, replacement);
require(!controller_identity_equal(original, replaced) &&
!bluepad32_input_backend_identify(original),
"swapping only the right member must select a different pair bank and owner");
require_active_profile(replaced, 0, UINT8_MAX);
require(runtime_profile_storage.activate(replaced, 4) == ProfileStorageResult::kOk,
"the replacement pair bank must be independently selectable");
platform_on_device_disconnected(&replacement);
ready_switch2(right);
require(controller_identity_equal(slot_snapshot(0).identity, original),
"restoring the original right member must restore the original pair key");
require_active_profile(slot_snapshot(0).identity, 6, UINT8_MAX);
require_active_profile(replaced, 4, UINT8_MAX);
platform_on_device_disconnected(&right);
auto typed_right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
memcpy(typed_right.conn.btaddr, right.conn.btaddr, sizeof(bd_addr_t));
typed_right.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
ready_switch2(typed_right);
const ControllerIdentity retyped = slot_snapshot(0).identity;
require_pair_owner(retyped, left, typed_right);
require(!controller_identity_equal(retyped, original),
"a member's address type must participate in the pair key even with the same MAC");
require_active_profile(retyped, 0, UINT8_MAX);
require_active_profile(original, 6, UINT8_MAX);
}
void test_switch2_admission() {
start_backend();
require(!switch_pico_switch2_pairing_allowed(),
@ -1763,9 +2063,9 @@ void test_switch2_mate_reconnect() {
"mate setup must pause background scanning while reserving physical capacity");
require(platform_on_device_ready(&left) == UNI_ERROR_SUCCESS &&
slot_snapshot(0).active && !slot_snapshot(1).active &&
controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left)) &&
!scanning_enabled && !classic_scanning_enabled,
"ready remembered mate must merge without a pairing window and stop background scanning");
require_pair_owner(slot_snapshot(0).identity, left, right);
auto ordinary = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
platform_on_device_connected(&ordinary);
require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS &&
@ -4001,6 +4301,12 @@ int main(int argc, char** argv) {
test_switch2_pair_lifecycle(true);
} else if (scenario == "switch2-multiple-pairs") {
test_switch2_multiple_pairs();
} else if (scenario == "switch2-pair-failure-left") {
test_switch2_pair_admission_failure(false);
} else if (scenario == "switch2-pair-failure-right") {
test_switch2_pair_admission_failure(true);
} else if (scenario == "switch2-pair-replacement") {
test_switch2_pair_member_replacement();
} else if (scenario == "switch2-admission") {
test_switch2_admission();
} else if (scenario == "switch2-radio-policy") {

View file

@ -58,6 +58,7 @@ enum uni_error_t {
UNI_ERROR_IGNORE_DEVICE = 1,
UNI_ERROR_INVALID_CONTROLLER = 2,
UNI_ERROR_NO_SLOTS = 3,
UNI_ERROR_INIT_FAILED = 4,
};
enum {

View file

@ -39,6 +39,84 @@ bool read_encoded_database(void*, size_t offset, uint8_t* output,
return true;
}
void test_pair_identity_wire_and_member_validation() {
ControllerIdentity left = identity(1);
left.transport = ControllerTransport::kBle;
left.product_id = 0x2067;
const uint8_t left_address[6] = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15};
memcpy(left.address, left_address, sizeof(left_address));
ControllerIdentity right = left;
right.product_id = 0x2066;
right.address_type = 1;
right.address[0] = 0xc0;
ControllerIdentity pair{};
uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{};
const uint8_t expected[14] = {
5, 3, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
0xc0, 0x11, 0x12, 0x13, 0x14, 0x15};
require(controller_identity_make_joycon_pair(left, right, &pair) &&
controller_identity_encode(pair, encoded, sizeof(encoded)) &&
memcmp(encoded, expected, sizeof(expected)) == 0,
"pair identity did not encode both typed members");
ControllerIdentity decoded{};
ControllerIdentity decoded_left{};
ControllerIdentity decoded_right{};
require(controller_identity_decode(encoded, sizeof(encoded), &decoded) &&
controller_identity_equal(pair, decoded) &&
controller_identity_joycon_pair_members(
decoded, &decoded_left, &decoded_right) &&
controller_identity_equal(left, decoded_left) &&
controller_identity_equal(right, decoded_right),
"pair identity round trip lost a member or its model");
decoded.partner_address[5] ^= 1;
require(!controller_identity_equal(pair, decoded),
"pair equality ignored the right address");
for (uint8_t flags = 0; flags < 16; ++flags) {
if (flags == 1 || flags == 5) {
continue;
}
memcpy(encoded, expected, sizeof(encoded));
encoded[0] = flags;
require(!controller_identity_decode(encoded, sizeof(encoded), &decoded),
"pair decoder accepted unstable, reserved, or nonstatic flags");
}
require(!controller_identity_make_joycon_pair(right, left, &decoded),
"pair helper accepted reversed models");
right.product_id = 0x2069;
require(!controller_identity_make_joycon_pair(left, right, &decoded),
"pair helper accepted a non-Joy-Con member");
right.product_id = 0x2066;
right.address_type = 2;
require(!controller_identity_make_joycon_pair(left, right, &decoded),
"pair helper accepted a noncanonical identity address type");
right.address_type = left.address_type;
memcpy(right.address, left.address, sizeof(right.address));
require(!controller_identity_make_joycon_pair(left, right, &decoded),
"pair helper accepted one physical typed address twice");
left.address[0] = right.address[0] = 0xc0;
right.address_type = 1;
require(controller_identity_make_joycon_pair(left, right, &decoded),
"pair helper conflated public and static-random address namespaces");
left.partner_address[0] = 1;
require(!controller_identity_encode(left, encoded, sizeof(encoded)) &&
!controller_identity_make_joycon_pair(left, right, &decoded),
"ordinary member accepted stray partner fields");
left.partner_address[0] = 0;
const uint8_t ordinary[14] = {
1, 2, 0, 0, 0xc0, 0x11, 0x12, 0x13, 0x14, 0x15,
0x7e, 0x05, 0x67, 0x20};
require(controller_identity_encode(left, encoded, sizeof(encoded)) &&
memcmp(encoded, ordinary, sizeof(encoded)) == 0 &&
controller_identity_decode(encoded, sizeof(encoded), &decoded) &&
controller_identity_equal(left, decoded),
"ordinary identity wire encoding changed");
const uint8_t global[14]{};
require(controller_identity_encode(controller_identity_global(), encoded,
sizeof(encoded)) &&
memcmp(encoded, global, sizeof(encoded)) == 0,
"global identity wire encoding changed");
}
void test_profile_wire_schema() {
const ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
@ -605,6 +683,7 @@ void test_extra_control_schema_round_trip_and_output_limits() {
} // namespace
int main() {
test_pair_identity_wire_and_member_validation();
test_profile_wire_schema();
test_legacy_profile_migration();
test_database_round_trip_and_capacity();

View file

@ -12,9 +12,13 @@ namespace {
struct FakeFlash {
uint8_t bytes[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE];
bool fail_program_once = false;
};
FakeFlash flash{};
bool observe_pair_during_program = false;
ControllerIdentity pending_pair{};
ControllerIdentity pending_left{};
void require(bool condition, const char *message) {
if (!condition) {
@ -53,6 +57,20 @@ bool fake_program(void *context, uint8_t arena, size_t offset,
offset + size > PROFILE_STORAGE_ARENA_SIZE) {
return false;
}
if (observe_pair_during_program) {
ProfileServiceActiveProfileSnapshot pair_snapshot{};
ProfileServiceActiveProfileSnapshot left_snapshot{};
profile_service_active_profile_snapshot(pending_pair, &pair_snapshot);
profile_service_active_profile_snapshot(pending_left, &left_snapshot);
require(!pair_snapshot.valid && left_snapshot.valid &&
left_snapshot.profile_index == 7 &&
left_snapshot.profile.weak_rumble_scale == 27,
"pair seed exposed incomplete profiles while writing flash");
}
if (storage->fail_program_once) {
storage->fail_program_once = false;
return false;
}
for (size_t index = 0; index < size; ++index) {
storage->bytes[arena][offset + index] &= page[index];
}
@ -426,6 +444,123 @@ void test_catalog1_selected_and_active_snapshots_migrate() {
"old selection/activation snapshots lost migrated content or defaults");
}
void test_pair_publication_failure_recovery_and_independence() {
memset(flash.bytes, 0xff, sizeof(flash.bytes));
ControllerIdentity left = stable_identity();
left.transport = ControllerTransport::kBle;
left.vendor_id = 0x057e;
left.product_id = 0x2067;
ControllerIdentity right = left;
right.product_id = 0x2066;
right.address[5] = 8;
ControllerIdentity pair{};
require(controller_identity_make_joycon_pair(left, right, &pair),
"service pair identity is invalid");
ProfileStorage setup;
require(setup.initialize(fake_io()), "service pair fixture did not initialize");
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
ControllerProfile profile = controller_profile_default(left, slot);
profile.weak_rumble_scale = 20 + slot;
const char name[2] = {'L', static_cast<char>('0' + slot)};
require(setup.set(left, slot, profile) == ProfileStorageResult::kOk &&
setup.set_profile_name(left, slot, name, sizeof(name)) ==
ProfileStorageResult::kOk,
"service solo fixture did not persist");
}
require(setup.activate(left, 7) == ProfileStorageResult::kOk &&
setup.set_alias(left, "Solo", 4) == ProfileStorageResult::kOk,
"service solo activation did not persist");
profile_service_initialize_on_storage_core();
require(!active_snapshot(pair).valid &&
!profile_service_observe_identity_on_storage_core(pair) &&
profile_service_select(pair, 0) == ConfigurationTransactionStatus::kMalformed &&
profile_service_activate(100, pair, 0) ==
ConfigurationTransactionStatus::kMalformed &&
profile_service_activate_internal(0x80000001u, pair, 0) ==
ConfigurationTransactionStatus::kMalformed &&
profile_service_reset(101, pair, CONTROLLER_PROFILE_ALL) ==
ConfigurationTransactionStatus::kMalformed &&
profile_service_set_metadata(102, pair, 7, "Premature", 9) ==
ConfigurationTransactionStatus::kMalformed &&
profile_service_begin(103, pair, 0, CONTROLLER_PROFILE_SCHEMA_VERSION,
CONTROLLER_PROFILE_ENCODED_SIZE, 0) ==
ConfigurationTransactionStatus::kMalformed,
"service admitted an unseeded pair or supplied its fallback profile");
ProfileServiceListSnapshot before{};
profile_service_list_snapshot(&before);
pending_pair = pair;
pending_left = left;
observe_pair_during_program = true;
flash.fail_program_once = true;
require(!profile_service_observe_joycon_pair_on_storage_core(pair),
"failed seed published a ready pair");
observe_pair_during_program = false;
ProfileServiceListSnapshot failed{};
profile_service_list_snapshot(&failed);
const auto solo_after_failure = active_snapshot(left);
require(failed.count == before.count &&
failed.metadata.state == ProfileServiceState::kStorageError &&
failed.metadata.generation == before.metadata.generation &&
!active_snapshot(pair).valid && solo_after_failure.valid &&
solo_after_failure.profile_index == 7 &&
solo_after_failure.profile.weak_rumble_scale == 27 &&
!profile_service_observe_joycon_pair_on_storage_core(pair),
"rejected pair replaced the last committed solo publication");
profile_service_initialize_on_storage_core();
observe_pair_during_program = true;
require(profile_service_observe_joycon_pair_on_storage_core(pair),
"pair seed did not retry after recovery");
observe_pair_during_program = false;
ProfileServiceListSnapshot seeded{};
profile_service_list_snapshot(&seeded);
require(seeded.count == before.count + 2 &&
seeded.metadata.state == ProfileServiceState::kReady &&
active_snapshot(pair).valid &&
active_snapshot(pair).profile_index == 7 &&
active_snapshot(pair).profile.weak_rumble_scale == 27,
"seeded pair list and active profile were not published together");
require(profile_service_select(pair, 7) ==
ConfigurationTransactionStatus::kCommitted,
"seeded pair could not be selected");
ProfileServiceMetadataSnapshot metadata{};
profile_service_metadata_snapshot(&metadata);
require(metadata.valid && metadata.alias[0] == '\0' &&
strcmp(metadata.profile_names[7], "L7") == 0,
"service did not expose independent seeded metadata");
auto profile = controller_profile_default(pair, 7);
profile.weak_rumble_scale = 81;
write_profile(104, pair, 7, profile, 9000);
require(active_snapshot(pair).profile.weak_rumble_scale == 81 &&
active_snapshot(left).profile.weak_rumble_scale == 27 &&
profile_service_activate(105, pair, 3) ==
ConfigurationTransactionStatus::kPending,
"pair edit changed the solo active profile");
profile_service_task_on_storage_core(10000);
require(active_snapshot(pair).profile_index == 3 &&
active_snapshot(left).profile_index == 7,
"pair activation changed the solo selection");
require(profile_service_set_metadata(106, pair, 7, "Pair", 4) ==
ConfigurationTransactionStatus::kPending,
"pair metadata edit did not queue");
profile_service_task_on_storage_core(11000);
require(profile_service_observe_joycon_pair_on_storage_core(pair),
"existing pair reconnect failed");
profile_service_metadata_snapshot(&metadata);
require(metadata.valid && strcmp(metadata.profile_names[7], "Pair") == 0 &&
profile_service_select(left, 7) ==
ConfigurationTransactionStatus::kCommitted,
"pair reconnect overwrote pair-owned metadata");
profile_service_metadata_snapshot(&metadata);
require(metadata.valid && strcmp(metadata.profile_names[7], "L7") == 0 &&
strcmp(metadata.alias, "Solo") == 0,
"pair metadata edit changed solo metadata");
profile_service_initialize_on_storage_core();
require(profile_service_observe_joycon_pair_on_storage_core(pair) &&
active_snapshot(pair).profile_index == 3 &&
active_snapshot(left).profile_index == 7,
"service reload reseeded the pair active selection");
}
} // namespace
ProfileStorageIo pico_profile_storage_io() { return fake_io(); }
@ -435,6 +570,7 @@ int main() {
test_profile_bounds_and_transaction_namespace();
test_schema7_validation_and_atomic_selection();
test_catalog1_selected_and_active_snapshots_migrate();
test_pair_publication_failure_recovery_and_independence();
std::cout << "profile service tests passed\n";
return 0;
}

View file

@ -20,6 +20,7 @@ struct FakeFlash {
uint8_t corrupt_arena = 0;
size_t corrupt_offset = 0;
bool corrupt_next_program = false;
int fail_read_after_programs = -1;
};
FakeFlash flash{};
@ -45,6 +46,10 @@ bool fake_read(void *context, uint8_t arena, size_t offset, uint8_t *output,
size > PROFILE_STORAGE_ARENA_SIZE - offset) {
return false;
}
if (storage->fail_read_after_programs >= 0 &&
storage->programs >= storage->fail_read_after_programs) {
return false;
}
memcpy(output, &storage->bytes[arena][offset], size);
return true;
}
@ -116,6 +121,22 @@ ControllerIdentity identity(uint8_t suffix) {
return result;
}
ControllerIdentity joycon(uint8_t suffix, bool left) {
ControllerIdentity result = identity(suffix);
result.transport = ControllerTransport::kBle;
result.vendor_id = 0x057e;
result.product_id = left ? 0x2067 : 0x2066;
return result;
}
ControllerIdentity joycon_pair(const ControllerIdentity &left,
const ControllerIdentity &right) {
ControllerIdentity pair{};
require(controller_identity_make_joycon_pair(left, right, &pair),
"pair fixture identity is invalid");
return pair;
}
void write_u16(uint8_t *output, uint16_t value) {
output[0] = static_cast<uint8_t>(value);
output[1] = static_cast<uint8_t>(value >> 8);
@ -279,7 +300,7 @@ void install_populated_catalog(uint16_t version, bool fill_arena = false) {
index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) {
const ControllerIdentity id = catalog_identity(index);
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
const ControllerProfile profile = catalog_profile(index, slot, version == 2);
const ControllerProfile profile = catalog_profile(index, slot, version != 1);
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
if (version == 1) {
encode_schema5(profile, payload);
@ -558,14 +579,14 @@ void test_legacy_migration_is_atomic_and_complete() {
void test_populated_catalog_publication_power_loss() {
static FakeFlash baseline;
for (uint16_t version = 1; version <= 2; ++version) {
for (uint16_t version = 1; version <= 3; ++version) {
erase_all();
install_populated_catalog(version, true);
baseline = flash;
ProfileStorage completed;
require(completed.initialize(fake_io()), "populated catalog did not load");
require_populated_catalog(completed, version == 2);
if (version == 2) {
require_populated_catalog(completed, version != 1);
if (version == 3) {
require(completed.set_alias(catalog_identity(0), "New", 3) ==
ProfileStorageResult::kOk,
"populated catalog could not compact all 187 live records");
@ -578,7 +599,7 @@ void test_populated_catalog_publication_power_loss() {
flash = baseline;
flash.torn_page_bytes = torn_bytes;
ProfileStorage interrupted;
if (version == 2) {
if (version == 3) {
require(interrupted.initialize(fake_io()),
"catalog2 power-loss baseline did not load");
}
@ -587,9 +608,9 @@ void test_populated_catalog_publication_power_loss() {
} else {
flash.fail_after_programs = cut;
}
if (version == 1) {
if (version != 3) {
require(!interrupted.initialize(fake_io()),
"incomplete catalog1 migration silently initialized empty");
"incomplete old catalog migration silently initialized empty");
} else {
require(interrupted.set_alias(catalog_identity(0), "New", 3) ==
ProfileStorageResult::kIoError,
@ -604,7 +625,7 @@ void test_populated_catalog_publication_power_loss() {
ProfileStorage recovered;
require(recovered.initialize(fake_io()),
"catalog did not recover after an interrupted publication");
require_populated_catalog(recovered, version == 2);
require_populated_catalog(recovered, version != 1);
require(recovered.snapshot().generation >= 1248,
"catalog publication regressed the stored generation");
}
@ -738,7 +759,7 @@ void test_legacy_high_generation_remains_mutable() {
void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() {
erase_all();
install_populated_catalog(2);
install_populated_catalog(3);
ProfileStorage storage;
require(storage.initialize(fake_io()), "schema6 catalog did not load");
require_populated_catalog(storage, true);
@ -779,6 +800,219 @@ void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() {
"editing a schema6 profile lost its active index, alias, or name");
}
void require_pair_bank(const ProfileStorage &storage,
const ControllerIdentity &owner, uint8_t base,
uint8_t active, char name_prefix) {
const auto *entry = storage.find(owner);
require(entry != nullptr && entry->active_profile == active,
"pair bank active selection changed");
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
ControllerProfile actual{};
char name[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(storage.get(owner, slot, &actual) == ProfileStorageResult::kOk &&
actual.weak_rumble_scale == base + slot &&
storage.get_profile_name(owner, slot, name, sizeof(name)) ==
ProfileStorageResult::kOk &&
name[0] == name_prefix && name[1] == '0' + slot &&
name[2] == '\0',
"pair bank profile or name bled into another owner");
}
}
void write_pair_bank(ProfileStorage &storage, const ControllerIdentity &owner,
uint8_t base, char name_prefix) {
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
ControllerProfile profile = controller_profile_default(owner, slot);
profile.weak_rumble_scale = base + slot;
const char name[2] = {name_prefix, static_cast<char>('0' + slot)};
require(storage.set(owner, slot, profile) == ProfileStorageResult::kOk &&
storage.set_profile_name(owner, slot, name, sizeof(name)) ==
ProfileStorageResult::kOk,
"pair bank edit failed");
}
}
void test_pair_seed_independence_reconnect_and_compaction() {
erase_all();
const auto left = joycon(1, true);
const auto right = joycon(2, false);
const auto pair = joycon_pair(left, right);
ProfileStorage storage;
require(storage.initialize(fake_io()), "pair catalog did not initialize");
write_pair_bank(storage, left, 20, 'L');
write_pair_bank(storage, right, 40, 'R');
require(storage.set_alias(left, "Solo", 4) == ProfileStorageResult::kOk &&
storage.activate(left, 7) == ProfileStorageResult::kOk &&
storage.activate(right, 3) == ProfileStorageResult::kOk,
"solo bank setup failed");
const int programs = flash.programs;
const int erases = flash.erases;
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kOk &&
flash.programs == programs + 2 && flash.erases == erases,
"first pair did not seed with one atomic append");
require_pair_bank(storage, pair, 20, 7, 'L');
char alias[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(storage.get_alias(pair, alias, sizeof(alias)) ==
ProfileStorageResult::kOk && alias[0] == '\0' &&
storage.get_alias(left, alias, sizeof(alias)) ==
ProfileStorageResult::kOk && strcmp(alias, "Solo") == 0,
"pair inherited or changed the solo alias");
write_pair_bank(storage, left, 60, 'S');
require(storage.activate(left, 1) == ProfileStorageResult::kOk,
"left selection edit failed");
ProfileStorage replayed;
require(replayed.initialize(fake_io()), "pair seed did not replay");
require_pair_bank(replayed, pair, 20, 7, 'L');
require_pair_bank(replayed, left, 60, 1, 'S');
require_pair_bank(replayed, right, 40, 3, 'R');
write_pair_bank(replayed, pair, 80, 'P');
require(replayed.activate(pair, 6) == ProfileStorageResult::kOk &&
replayed.set_alias(pair, "Together", 8) == ProfileStorageResult::kOk,
"pair selection or alias edit failed");
const uint32_t generation = replayed.snapshot().generation;
require(replayed.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged &&
replayed.snapshot().generation == generation,
"pair reconnect reseeded or wrote the bank");
const auto other_pair = joycon_pair(left, joycon(3, false));
require(replayed.ensure_joycon_pair(other_pair) == ProfileStorageResult::kOk,
"different right member did not receive a separate bank");
require_pair_bank(replayed, other_pair, 60, 1, 'S');
require_pair_bank(replayed, pair, 80, 6, 'P');
require_pair_bank(replayed, left, 60, 1, 'S');
require_pair_bank(replayed, right, 40, 3, 'R');
const uint8_t old_arena = replayed.snapshot().active_bank;
for (size_t write = 0; write <= PROFILE_STORAGE_RECORD_CAPACITY; ++write) {
require(replayed.activate(left, (write + 2) % CONTROLLER_PROFILE_COUNT) ==
ProfileStorageResult::kOk,
"pair compaction trigger failed");
}
require(replayed.snapshot().active_bank != old_arena,
"pair catalog never compacted");
ProfileStorage compacted;
require(compacted.initialize(fake_io()) &&
compacted.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
"materialized pair did not reconnect after compaction");
require_pair_bank(compacted, pair, 80, 6, 'P');
require_pair_bank(compacted, other_pair, 60, 1, 'S');
require_pair_bank(compacted, right, 40, 3, 'R');
require(compacted.get_alias(pair, alias, sizeof(alias)) ==
ProfileStorageResult::kOk && strcmp(alias, "Together") == 0,
"compaction lost pair-owned alias");
require(compacted.reset(pair, CONTROLLER_PROFILE_ALL) ==
ProfileStorageResult::kOk,
"pair reset failed");
require_pair_bank(compacted, other_pair, 60, 1, 'S');
require_pair_bank(compacted, right, 40, 3, 'R');
}
void test_pair_capacity_and_unseeded_mutations() {
erase_all();
const auto left = joycon(1, true);
const auto right = joycon(2, false);
const auto pair = joycon_pair(left, right);
ProfileStorage storage;
require(storage.initialize(fake_io()), "pair capacity catalog did not initialize");
ControllerProfile profile = controller_profile_default(pair, 0);
profile.weak_rumble_scale = 42;
require(storage.ensure_identity(pair) == ProfileStorageResult::kInvalidArgument &&
storage.set(pair, 0, profile) == ProfileStorageResult::kInvalidArgument &&
storage.activate(pair, 7) == ProfileStorageResult::kInvalidArgument &&
storage.set_alias(pair, "Pair", 4) == ProfileStorageResult::kInvalidArgument &&
storage.set_profile_name(pair, 7, "Pair", 4) ==
ProfileStorageResult::kInvalidArgument &&
storage.reset(pair, CONTROLLER_PROFILE_ALL) ==
ProfileStorageResult::kInvalidArgument &&
storage.find(pair) == nullptr,
"mutation implicitly created an unseeded pair");
for (uint8_t index = 1; index <= 14; ++index) {
require(storage.ensure_identity(identity(index)) == ProfileStorageResult::kOk,
"capacity setup failed");
}
const int programs = flash.programs;
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kFull &&
storage.find(left) == nullptr && storage.find(right) == nullptr &&
storage.find(pair) == nullptr && flash.programs == programs,
"capacity preflight changed owners before rejecting the pair");
erase_all();
require(storage.initialize(fake_io()), "exact-fit catalog did not initialize");
for (uint8_t index = 1; index <= 13; ++index) {
require(storage.ensure_identity(identity(index)) == ProfileStorageResult::kOk,
"exact-fit setup failed");
}
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kOk &&
storage.identity_count() == CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1 &&
storage.find(left) != nullptr && storage.find(right) != nullptr &&
storage.get(pair, 7, &profile) == ProfileStorageResult::kOk &&
profile.weak_rumble_scale ==
controller_profile_default(left, 7).weak_rumble_scale &&
storage.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
"exact-capacity pair did not create both members and the default bank");
}
void test_pair_seed_interruption_and_ambiguous_readback() {
erase_all();
const auto left = joycon(1, true);
const auto right = joycon(2, false);
const auto pair = joycon_pair(left, right);
ProfileStorage initial;
require(initial.initialize(fake_io()), "seed fault catalog did not initialize");
write_pair_bank(initial, left, 20, 'L');
require(initial.activate(left, 7) == ProfileStorageResult::kOk,
"seed fault active setup failed");
static FakeFlash baseline;
baseline = flash;
// Right row and seed each use two pages. Exercise every cut, including a
// fully committed header whose program call nevertheless reports failure.
for (size_t torn_bytes : {size_t{0}, size_t{64}, size_t{256}}) {
for (int cut = 0; cut < 4; ++cut) {
flash = baseline;
ProfileStorage interrupted;
require(interrupted.initialize(fake_io()), "seed fault baseline did not load");
flash.fail_after_programs = flash.programs + cut;
flash.torn_page_bytes = torn_bytes;
require(interrupted.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
interrupted.find(pair) == nullptr,
"interrupted seed exposed a partially initialized bank");
flash.fail_after_programs = -1;
require(interrupted.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
interrupted.activate(left, 0) == ProfileStorageResult::kIoError,
"ambiguous seed allowed retry or source mutation before replay");
flash.torn_page_bytes = 0;
ProfileStorage recovered;
require(recovered.initialize(fake_io()), "interrupted seed did not recover");
const auto result = recovered.ensure_joycon_pair(pair);
require(result == ProfileStorageResult::kOk ||
result == ProfileStorageResult::kUnchanged,
"seed retry failed after durable replay");
require_pair_bank(recovered, pair, 20, 7, 'L');
require_pair_bank(recovered, left, 20, 7, 'L');
}
}
flash = baseline;
ProfileStorage ambiguous;
require(ambiguous.initialize(fake_io()) &&
ambiguous.ensure_identity(right) == ProfileStorageResult::kOk,
"readback fault setup failed");
flash.fail_read_after_programs = flash.programs + 2;
require(ambiguous.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
ambiguous.find(pair) == nullptr,
"unacknowledged seed was published in memory");
ProfileStorage unreadable;
require(!unreadable.initialize(fake_io()), "unreadable seed replay failed open");
flash.fail_read_after_programs = -1;
require(ambiguous.activate(left, 0) == ProfileStorageResult::kIoError,
"unacknowledged seed did not freeze source changes");
ProfileStorage recovered;
require(recovered.initialize(fake_io()) &&
recovered.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
"committed unacknowledged seed was reseeded");
require_pair_bank(recovered, pair, 20, 7, 'L');
write_pair_bank(recovered, left, 60, 'S');
require(recovered.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
"recovered pair was reseeded after source edits");
require_pair_bank(recovered, pair, 20, 7, 'L');
}
} // namespace
int main() {
@ -795,6 +1029,9 @@ int main() {
test_late_second_page_program_is_not_reused();
test_unreadable_legacy_data_is_not_erased();
test_schema6_read_migration_is_lazy_and_edit_preserves_metadata();
test_pair_seed_independence_reconnect_and_compaction();
test_pair_capacity_and_unseeded_mutations();
test_pair_seed_interruption_and_ambiguous_readback();
std::cout << "profile storage tests passed\n";
return 0;
}

View file

@ -83,6 +83,8 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
f"-I{root / 'src' / 'firmware'}",
f"-I{root / 'bluepad32_config'}",
str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"),
str(root / "src" / "firmware" / "profile" / "controller_profile.cpp"),
str(root / "src" / "firmware" / "profile" / "profile_storage.cpp"),
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
str(
root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"
@ -97,6 +99,9 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"switch2-forward",
"switch2-reverse",
"switch2-multiple-pairs",
"switch2-pair-failure-left",
"switch2-pair-failure-right",
"switch2-pair-replacement",
"switch2-admission",
"switch2-radio-policy",
"switch2-radio-settling",

View file

@ -819,7 +819,7 @@ def test_native_rumble_configuration_rejects_invalid_lists(malformation: str) ->
@pytest.mark.parametrize(
"malformation", ("duplicate", "overflow", "global", "ble", "vendor", "product")
"malformation", ("duplicate", "overflow", "global", "ble", "pair", "vendor", "product")
)
def test_native_rumble_write_rejects_invalid_approvals_before_transaction(
malformation: str,
@ -833,6 +833,11 @@ def test_native_rumble_write_rejects_invalid_approvals_before_transaction(
),
"global": (config_manager.ControllerIdentity.global_fallback(),),
"ble": (replace(identity, transport=config_manager.TRANSPORT_BLE),),
"pair": (
config_manager.ControllerIdentity.from_bytes(
bytes.fromhex("0503102030405060C12233445566")
),
),
"vendor": (replace(identity, vendor_id=0x045E),),
"product": (replace(identity, product_id=0x2019),),
}[malformation]
@ -1478,6 +1483,129 @@ def test_requested_and_active_mode_response_validation() -> None:
config_manager.read_info(device)
@pytest.mark.parametrize(
("left_type", "right_type", "flags"), [(0, 0, 1), (1, 0, 3), (0, 1, 5), (1, 1, 7)]
)
def test_joycon_pair_wire_round_trip_preserves_both_typed_members(
left_type: int, right_type: int, flags: int,
) -> None:
left = config_manager.ControllerIdentity(
True, config_manager.TRANSPORT_BLE, left_type,
bytes.fromhex("C10203040506"), 0x057E, 0x2067,
)
right = config_manager.ControllerIdentity(
True, config_manager.TRANSPORT_BLE, right_type,
bytes.fromhex("D11213141516"), 0x057E, 0x2066,
)
pair = config_manager.ControllerIdentity.make_joycon_pair(left, right)
wire = bytes((flags, 3)) + left.address + right.address
assert pair.to_bytes() == wire
assert config_manager.ControllerIdentity.from_bytes(wire) == pair
assert pair.joycon_pair_members() == (left, right)
assert pair.to_bytes() not in (left.to_bytes(), right.to_bytes())
other_right = replace(right, address=bytes.fromhex("D11213141517"))
assert config_manager.ControllerIdentity.make_joycon_pair(left, other_right) != pair
def test_joycon_pair_distinguishes_address_types_without_normalizing_members() -> None:
left = config_manager.ControllerIdentity(
True, config_manager.TRANSPORT_BLE, 0,
bytes.fromhex("C10203040506"), 0x057E, 0x2067,
)
right = replace(left, address_type=1, product_id=0x2066)
pair = config_manager.ControllerIdentity.make_joycon_pair(left, right)
assert pair.to_bytes() == bytes.fromhex("0503C10203040506C10203040506")
assert pair.joycon_pair_members() == (left, right)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerIdentity.make_joycon_pair(left, replace(right, address_type=0))
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerIdentity.make_joycon_pair(right, left)
@pytest.mark.parametrize("wire", [
"0003102030405060C12233445566", # Stable flag missing.
"0D03102030405060C12233445566", # Reserved flag.
"0303102030405060C12233445566", # Left random address is not static.
"0503C12233445566102030405060", # Right random address is not static.
"0103102030405060102030405060", # Duplicate typed members.
])
def test_joycon_pair_rejects_malformed_wire(wire: str) -> None:
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerIdentity.from_bytes(bytes.fromhex(wire))
@pytest.mark.parametrize("malformation", [
"global", "classic", "vendor", "model", "address_type", "random_address",
])
def test_joycon_pair_requires_legitimate_ble_members(malformation: str) -> None:
pair = config_manager.ControllerIdentity.from_bytes(
bytes.fromhex("0503102030405060C12233445566")
)
left, right = pair.joycon_pair_members()
invalid = {
"global": config_manager.ControllerIdentity.global_fallback(),
"classic": replace(right, transport=config_manager.TRANSPORT_CLASSIC),
"vendor": replace(right, vendor_id=0x045E),
"model": replace(right, product_id=0x2069),
"address_type": replace(right, address_type=3),
"random_address": replace(right, address=bytes.fromhex("412233445566")),
}[malformation]
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerIdentity.make_joycon_pair(left, invalid)
@pytest.mark.parametrize("fields", [
{"stable": False},
{"vendor_id": 0x045E},
{"product_id": 0x2066},
{"address_type": 2},
{"partner_address_type": 3},
])
def test_joycon_pair_rejects_inconsistent_in_memory_identity(fields: dict[str, object]) -> None:
pair = config_manager.ControllerIdentity.from_bytes(
bytes.fromhex("0503102030405060C12233445566")
)
with pytest.raises(config_manager.ConfigManagerError):
replace(pair, **fields)
@pytest.mark.parametrize("fields", [
{"partner_address_type": 1},
{"partner_address": bytes.fromhex("C12233445566")},
])
def test_physical_identity_cannot_hide_pair_members(fields: dict[str, object]) -> None:
with pytest.raises(config_manager.ConfigManagerError):
replace(native_rumble_identity(), **fields)
def test_pairing_inventory_rejects_logical_profile_owners() -> None:
device = FakeDevice()
device.records = [(config_manager.TRANSPORT_JOYCON_PAIR, 0, bytes.fromhex("102030405060"))]
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_pairings(device)
def test_profile_cli_lists_both_pair_addresses_but_native_inventory_omits_pair(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
device = FakeDevice()
pair = config_manager.ControllerIdentity.from_bytes(
bytes.fromhex("0503102030405060C12233445566")
)
device.profile_identities.append(pair)
device.active_profiles[pair.to_bytes()] = 4
monkeypatch.setattr(config_manager, "find_pico", lambda *_: device)
assert config_manager.main(["profiles", "list"]) == 0
listing = capsys.readouterr().out
assert pair.address_text in listing
assert pair.partner_address_text in listing
assert pair.transport_text in listing
assert config_manager.main(["config", "native-rumble", "list"]) == 0
physical = capsys.readouterr().out
assert pair.partner_address_text not in physical
assert device.stable_identity.address_text in physical
def test_identity_and_profile_binary_json_round_trip() -> None:
identity = config_manager.ControllerIdentity(
True,

View file

@ -235,6 +235,145 @@ def test_editor_identifies_connected_controller_artwork(
]
@pytest.fixture
def joycon_pair_device() -> FakeDevice:
device = FakeDevice()
pair = config_manager.ControllerIdentity.from_bytes(
bytes.fromhex("0503102030405060C12233445566")
)
left, right = pair.joycon_pair_members()
device.profile_identities = [device.global_identity, left, right, pair]
device.stable_identity = pair
device.active_profiles = {
identity.to_bytes(): index for index, identity in enumerate(device.profile_identities)
}
default_profile = config_manager.ControllerProfile.default().to_bytes()
device.profiles = {
(identity.to_bytes(), index): default_profile
for identity in device.profile_identities
for index in range(config_manager.PROFILE_CAPACITY)
}
device.playtest_layout = 3
device.playtest_motion = None
return device
def test_persistent_pair_owner_is_offline_capable_and_isolated_from_solo_banks(
monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice,
) -> None:
device = joycon_pair_device
_, left, right, pair = device.profile_identities
device.playtest_connected = False
with running_server(monkeypatch, device) as (base_url, token):
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
owners = listing["identities"]
assert [owner["key"] for owner in owners] == [
identity.to_bytes().hex() for identity in device.profile_identities
]
assert [owner["controller"]["layout"] for owner in owners[1:]] == [
"joycon2-left", "joycon2-right", "joycon2-pair",
]
owner = owners[3]
assert owner["controller"]["model"] != owners[1]["controller"]["model"]
assert "50:60" in owner["label"] and "55:66" in owner["label"]
assert owner["identity"]["members"]["left"]["address"] == left.address_text
assert owner["identity"]["members"]["left"]["address_type"] == 0
assert owner["identity"]["members"]["right"]["address"] == right.address_text
assert owner["identity"]["members"]["right"]["address_type"] == 1
assert set(owner["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == {
"c", "left_sl", "left_sr", "right_sl", "right_sr",
}
status, offline = request_json(f"{base_url}/api/profiles/3/8/playtest")
assert status == 200
assert offline["connected"] is False
assert offline["controller"] == owner["controller"]
assert offline["source_controls"] == owner["source_controls"]
expected = {
index: config_manager.ControllerProfile.default().to_json_object()
for index in (1, 2, 3)
}
for bank, output in ((3, "north"), (1, "east"), (2, "west")):
expected[bank]["button_map"]["south"] = output
status, _ = request_json(
f"{base_url}/api/profiles/{bank}/8", method="PUT",
value=expected[bank], token=token,
)
assert status == 200
for other_bank, expected_profile in expected.items():
status, stored = request_json(f"{base_url}/api/profiles/{other_bank}/8")
assert status == 200
assert stored["profile"] == expected_profile
for path, value in (
("profiles/3/8/name", "Pair platformer"),
("identities/3/alias", "Couch pair"),
):
status, _ = request_json(
f"{base_url}/api/{path}", method="PUT", value={"value": value}, token=token,
)
assert status == 200
status, _ = request_json(
f"{base_url}/api/profiles/3/8/activate", method="POST", token=token,
)
assert status == 200
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
assert [owner["active_profile"] for owner in listing["identities"]] == [1, 2, 3, 8]
assert listing["identities"][3]["label"] == "Couch pair"
assert listing["identities"][3]["key"] == pair.to_bytes().hex()
for bank in (1, 2, 3):
status, stored = request_json(f"{base_url}/api/profiles/{bank}/8")
assert status == 200
assert stored["name"] == ("Pair platformer" if bank == 3 else "")
assert stored["alias"] == ("Couch pair" if bank == 3 else "")
device.playtest_connected = True
for bank in (1, 2, 3):
status, live = request_json(f"{base_url}/api/profiles/{bank}/8/playtest")
assert status == 200
assert live["owner_key"] == device.profile_identities[bank].to_bytes().hex()
assert live["identity_key"] == pair.to_bytes().hex()
assert (live["identity_key"] == live["owner_key"]) == (bank == 3)
assert live["label"] == "Couch pair"
other_pair = config_manager.ControllerIdentity.make_joycon_pair(
left, replace(right, address=bytes.fromhex("C12233445567"))
)
device.stable_identity = other_pair
status, other_live = request_json(f"{base_url}/api/profiles/3/8/playtest")
assert status == 200
assert other_live["owner_key"] == pair.to_bytes().hex()
assert other_live["identity_key"] == other_pair.to_bytes().hex()
assert other_live["owner_key"] != other_live["identity_key"]
def test_capture_cannot_bind_pair_input_to_a_solo_owner(
monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice,
) -> None:
device = joycon_pair_device
left = device.profile_identities[1]
with running_server(monkeypatch, device) as (base_url, token):
status, _ = request_json(f"{base_url}/api/profiles/1/1/playtest")
assert status == 200
status, _ = request_json(
f"{base_url}/api/profiles/1/1/capture/start", method="POST", token=token,
value={
"owner_key": left.to_bytes().hex(),
"capture_id": "solo-bank-paired-input",
"slot": device.playtest_slot,
"connection_generation": device.playtest_connection_generation,
"macro_index": 0,
"profile": config_manager.ControllerProfile.default().to_json_object(),
"channels": 1,
"max_events": 8,
"axis_quantum": 512,
"trigger_quantum": 1024,
"max_duration_ms": 1000,
},
)
assert status == 400
@pytest.mark.parametrize("owner_index", [0, 1])
def test_live_layout_transitions_do_not_infer_topology_from_profile_owner(
monkeypatch: pytest.MonkeyPatch, owner_index: int,
@ -254,6 +393,7 @@ def test_live_layout_transitions_do_not_infer_topology_from_profile_owner(
assert sample["controller"]["layout"] == expected
assert sample["owner_key"] == device.profile_identities[owner_index].to_bytes().hex()
assert sample["identity_key"] == left.to_bytes().hex()
assert sample["identity"]["is_joycon_pair"] is False
expected_extras = (
{"c", "left_sl", "left_sr", "right_sl", "right_sr"}
if code == 3 else {"left_sl", "left_sr"}
@ -261,6 +401,9 @@ def test_live_layout_transitions_do_not_infer_topology_from_profile_owner(
assert set(sample["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == expected_extras
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
assert [owner["key"] for owner in listing["identities"]] == [
device.global_identity.to_bytes().hex(), left.to_bytes().hex(),
]
assert listing["identities"][1]["controller"]["layout"] == "joycon2-left"
device.playtest_connected = False
status, offline = request_json(f"{base_url}/api/profiles/{owner_index}/1/playtest")