feat: select Joy-Con player modes and join pairs with a shortcut

This commit is contained in:
Joey Yakimowich-Payne 2026-09-07 17:42:33 -06:00
commit 06a89b5ae1
22 changed files with 2680 additions and 168 deletions

File diff suppressed because it is too large Load diff

View file

@ -133,6 +133,7 @@ void test_service_lifecycle_and_mutations(bool v2) {
require(snapshot.state == ConfigurationServiceState::kReady &&
snapshot.configuration.pairing_window_seconds == 90 &&
snapshot.configuration.requested_mode == original_mode &&
snapshot.configuration.joycon_mode == JoyConMode::kPaired &&
snapshot.configuration.native_switch_controller_count == 0,
"Core 0 did not preserve legacy settings without approval");
require(g_flash.program_count == programs_after_seed &&
@ -160,6 +161,7 @@ void test_service_lifecycle_and_mutations(bool v2) {
configuration_service_snapshot(&snapshot);
require(snapshot.state == ConfigurationServiceState::kReady &&
snapshot.configuration.requested_mode == original_mode &&
snapshot.configuration.joycon_mode == JoyConMode::kPaired &&
snapshot.configuration.native_switch_controller_count == 0 &&
snapshot.transaction.status ==
ConfigurationTransactionStatus::kIdle,
@ -172,7 +174,7 @@ void test_service_lifecycle_and_mutations(bool v2) {
ADAPTER_CONFIGURATION_SCHEMA_VERSION &&
after_migration.snapshot().payload_size ==
ADAPTER_CONFIGURATION_ENCODED_SIZE,
"power cycle did not observe the migrated v3 record");
"power cycle did not observe the migrated v4 record");
AdapterConfiguration migrated{};
require(adapter_configuration_decode(
after_migration.snapshot().schema_version,
@ -180,8 +182,9 @@ void test_service_lifecycle_and_mutations(bool v2) {
after_migration.snapshot().payload_size, &migrated) &&
migrated.pairing_window_seconds == 90 &&
migrated.requested_mode == original_mode &&
migrated.joycon_mode == JoyConMode::kPaired &&
migrated.native_switch_controller_count == 0,
"migrated v3 bytes did not preserve legacy configuration");
"migrated v4 bytes did not preserve legacy configuration");
require(configuration_service_set_mode(
10, AdapterRequestedMode::kSwitch, implemented) ==
@ -476,6 +479,17 @@ void test_service_lifecycle_and_mutations(bool v2) {
}
void test_abandoned_host_receive_does_not_block_recovery() {
AdapterConfiguration original{};
original.pairing_window_seconds = 90;
original.requested_mode = AdapterRequestedMode::kSwitch;
original.joycon_mode = JoyConMode::kIndividual;
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
ConfigurationStorage seed;
require(adapter_configuration_encode(original, payload, sizeof(payload)) &&
seed.initialize(fake_io()) &&
seed.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload)) == ConfigurationStorageResult::kOk,
"recovery configuration fixture did not persist");
configuration_service_prepare();
configuration_service_initialize_pre_usb();
configuration_service_initialize_on_storage_core();
@ -484,7 +498,7 @@ void test_abandoned_host_receive_does_not_block_recovery() {
configuration_service_snapshot(&snapshot);
AdapterConfiguration abandoned = snapshot.configuration;
abandoned.pairing_window_seconds = 180;
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
abandoned.joycon_mode = JoyConMode::kPaired;
require(adapter_configuration_encode(abandoned, payload,
sizeof(payload)),
"abandoned recovery-race configuration did not encode");
@ -513,6 +527,11 @@ void test_abandoned_host_receive_does_not_block_recovery() {
require_mode_status(kRecoveryAuto,
ConfigurationTransactionStatus::kCommitted,
"recovery Auto did not commit after abandoned receive");
configuration_service_snapshot(&snapshot);
require(snapshot.configuration.requested_mode == AdapterRequestedMode::kAuto &&
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
snapshot.configuration.pairing_window_seconds == 90,
"recovery Auto replaced durable settings with defaults or abandoned data");
require(configuration_service_mode_transaction_reboot_ready(
kRecoveryAuto),
"recovery Auto did not become latest reboot authority");
@ -535,7 +554,7 @@ void queue_configuration(uint32_t transaction_id,
"approval configuration did not reach pending commit");
}
void test_native_switch_approval_preservation_and_revocation() {
void test_native_switch_approval_preservation_and_revocation(bool migrate_v3) {
ControllerIdentity pro{};
pro.stable = true;
pro.transport = ControllerTransport::kClassic;
@ -548,6 +567,8 @@ void test_native_switch_approval_preservation_and_revocation() {
left.product_id = 0x2006;
AdapterConfiguration configuration{};
configuration.pairing_window_seconds = 90;
configuration.joycon_mode =
migrate_v3 ? JoyConMode::kPaired : JoyConMode::kIndividual;
configuration.native_switch_controller_count = 2;
configuration.native_switch_controllers[0] = pro;
configuration.native_switch_controllers[1] = left;
@ -555,28 +576,59 @@ void test_native_switch_approval_preservation_and_revocation() {
ConfigurationStorage seed;
require(adapter_configuration_encode(configuration, payload, sizeof(payload)) &&
seed.initialize(fake_io()) &&
seed.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload)) == ConfigurationStorageResult::kOk,
seed.commit(migrate_v3 ? ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION
: ADAPTER_CONFIGURATION_SCHEMA_VERSION,
payload, sizeof(payload)) ==
ConfigurationStorageResult::kOk,
"approved controller configuration did not persist");
configuration_service_prepare();
configuration_service_initialize_pre_usb();
ConfigurationServiceSnapshot snapshot{};
configuration_service_snapshot(&snapshot);
require(adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
require(snapshot.configuration.joycon_mode == configuration.joycon_mode &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"pre-USB snapshot did not expose persisted approvals");
configuration_service_initialize_on_storage_core();
if (migrate_v3) {
configuration_service_task_on_storage_core(0);
ConfigurationStorage migrated;
AdapterConfiguration decoded{};
require(migrated.initialize(fake_io()) &&
migrated.snapshot().schema_version ==
ADAPTER_CONFIGURATION_SCHEMA_VERSION &&
adapter_configuration_decode(
migrated.snapshot().schema_version,
migrated.snapshot().payload,
migrated.snapshot().payload_size, &decoded) &&
decoded.joycon_mode == JoyConMode::kPaired &&
decoded.pairing_window_seconds == 90 &&
adapter_configuration_native_switch_approved(decoded, pro) &&
adapter_configuration_native_switch_approved(decoded, left),
"v3 service migration changed settings or approvals");
configuration.joycon_mode = JoyConMode::kIndividual;
queue_configuration(100, configuration);
configuration_service_snapshot(&snapshot);
require(snapshot.configuration.joycon_mode == JoyConMode::kPaired,
"player mode changed before durable commit");
configuration_service_task_on_storage_core(1000);
configuration_service_snapshot(&snapshot);
require(snapshot.configuration.joycon_mode == JoyConMode::kIndividual,
"committed Individual mode was not published");
}
const uint32_t start_ms = migrate_v3 ? 2000 : 0;
const AdapterModeAvailability implemented{true, true, true, true};
require(configuration_service_set_mode(
1, AdapterRequestedMode::kSwitch, implemented) ==
ConfigurationTransactionStatus::kPending,
"approved configuration blocked host mode selection");
configuration_service_task_on_storage_core(0);
configuration_service_task_on_storage_core(start_ms);
configuration_service_snapshot(&snapshot);
require(snapshot.configuration.requested_mode == AdapterRequestedMode::kSwitch &&
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
@ -586,9 +638,10 @@ void test_native_switch_approval_preservation_and_revocation() {
0x80000002u, AdapterRequestedMode::kXInput, implemented) ==
ConfigurationTransactionStatus::kPending,
"approved configuration blocked internal mode selection");
configuration_service_task_on_storage_core(1000);
configuration_service_task_on_storage_core(start_ms + 1000);
configuration_service_snapshot(&snapshot);
require(snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
@ -598,10 +651,11 @@ void test_native_switch_approval_preservation_and_revocation() {
configuration = snapshot.configuration;
configuration.pairing_window_seconds = 120;
queue_configuration(3, configuration);
configuration_service_task_on_storage_core(2000);
configuration_service_task_on_storage_core(start_ms + 2000);
configuration_service_snapshot(&snapshot);
require(snapshot.configuration.pairing_window_seconds == 120 &&
snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
@ -619,13 +673,14 @@ void test_native_switch_approval_preservation_and_revocation() {
snapshot.configuration, pro),
"approval was revoked before durable commit");
g_flash.fail_program = true;
configuration_service_task_on_storage_core(3000);
configuration_service_task_on_storage_core(start_ms + 3000);
g_flash.fail_program = false;
configuration_service_snapshot(&snapshot);
require(snapshot.transaction.status ==
ConfigurationTransactionStatus::kStorageError &&
snapshot.generation == approved_generation &&
snapshot.payload_crc == approved_crc &&
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro),
"failed revocation changed the published durable configuration");
@ -636,17 +691,19 @@ void test_native_switch_approval_preservation_and_revocation() {
after_failure.snapshot().schema_version,
after_failure.snapshot().payload,
after_failure.snapshot().payload_size, &recovered) &&
recovered.joycon_mode == JoyConMode::kIndividual &&
adapter_configuration_native_switch_approved(recovered, pro) &&
adapter_configuration_native_switch_approved(recovered, left),
"interrupted revocation destroyed persisted approvals");
queue_configuration(5, configuration);
configuration_service_task_on_storage_core(3000);
configuration_service_task_on_storage_core(start_ms + 3000);
configuration_service_snapshot(&snapshot);
require(snapshot.transaction.status ==
ConfigurationTransactionStatus::kCommitted &&
snapshot.configuration.pairing_window_seconds == 120 &&
snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
!adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
@ -658,6 +715,7 @@ void test_native_switch_approval_preservation_and_revocation() {
after_revocation.snapshot().schema_version,
after_revocation.snapshot().payload,
after_revocation.snapshot().payload_size, &recovered) &&
recovered.joycon_mode == JoyConMode::kIndividual &&
!adapter_configuration_native_switch_approved(recovered, pro) &&
adapter_configuration_native_switch_approved(recovered, left),
"controller-specific revocation did not survive power cycle");
@ -669,9 +727,10 @@ void test_native_switch_approval_preservation_and_revocation() {
require(adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"reset erased approval before commit");
configuration_service_task_on_storage_core(4000);
configuration_service_task_on_storage_core(start_ms + 4000);
configuration_service_snapshot(&snapshot);
require(snapshot.transaction.status == ConfigurationTransactionStatus::kCommitted &&
snapshot.configuration.joycon_mode == JoyConMode::kPaired &&
!adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"configuration reset did not revoke persisted approval");
@ -685,7 +744,9 @@ int main(int argc, char** argv) {
} else if (strcmp(argv[1], "v2-migration") == 0) {
test_service_lifecycle_and_mutations(true);
} else if (strcmp(argv[1], "native-approvals") == 0) {
test_native_switch_approval_preservation_and_revocation();
test_native_switch_approval_preservation_and_revocation(false);
} else if (strcmp(argv[1], "v3-migration") == 0) {
test_native_switch_approval_preservation_and_revocation(true);
} else {
require(strcmp(argv[1], "abandoned-receive") == 0,
"unknown service test scenario");

View file

@ -90,22 +90,23 @@ void test_schema_encoding() {
AdapterConfiguration configuration{};
configuration.pairing_window_seconds = 90;
configuration.requested_mode = AdapterRequestedMode::kXInput;
configuration.joycon_mode = JoyConMode::kIndividual;
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_encode(configuration, payload,
sizeof(payload)),
"valid v3 configuration did not encode");
"valid v4 configuration did not encode");
const uint8_t expected[ADAPTER_CONFIGURATION_ENCODED_SIZE] = {
90,
0,
static_cast<uint8_t>(AdapterRequestedMode::kXInput),
0,
0,
static_cast<uint8_t>(JoyConMode::kIndividual),
0,
0,
0,
};
require(memcmp(payload, expected, sizeof(expected)) == 0,
"v3 configuration bytes are not canonical");
"v4 configuration bytes are not canonical");
AdapterConfiguration decoded{};
require(adapter_configuration_decode(
@ -113,8 +114,9 @@ void test_schema_encoding() {
sizeof(payload), &decoded) &&
decoded.pairing_window_seconds == 90 &&
decoded.requested_mode == AdapterRequestedMode::kXInput &&
decoded.joycon_mode == JoyConMode::kIndividual &&
decoded.native_switch_controller_count == 0,
"v3 configuration did not round trip");
"v4 configuration did not round trip");
const AdapterRequestedMode valid_modes[] = {
AdapterRequestedMode::kAuto,
@ -130,7 +132,8 @@ void test_schema_encoding() {
adapter_configuration_decode(
ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload), &decoded) &&
decoded.requested_mode == mode,
decoded.requested_mode == mode &&
decoded.joycon_mode == JoyConMode::kIndividual,
"valid requested mode did not round trip");
}
@ -156,6 +159,7 @@ void test_schema_encoding() {
sizeof(legacy), &decoded) &&
decoded.pairing_window_seconds == 120 &&
decoded.requested_mode == AdapterRequestedMode::kAuto &&
decoded.joycon_mode == JoyConMode::kPaired &&
decoded.native_switch_controller_count == 0,
"v1 configuration did not migrate without approvals");
uint8_t malformed_legacy[sizeof(legacy)];
@ -178,6 +182,7 @@ void test_schema_encoding() {
&decoded) &&
decoded.pairing_window_seconds == 120 &&
decoded.requested_mode == AdapterRequestedMode::kMac &&
decoded.joycon_mode == JoyConMode::kPaired &&
decoded.native_switch_controller_count == 0,
"v2 migration changed settings or granted native rumble");
for (size_t index = 3; index < sizeof(v2); ++index) {
@ -195,13 +200,16 @@ void test_schema_encoding() {
"v2 record with wrong size was accepted");
for (size_t index = 3; index < sizeof(payload); ++index) {
if (index == 4) {
continue;
}
uint8_t malformed[sizeof(payload)];
memcpy(malformed, payload, sizeof(payload));
malformed[index] = 1;
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_SCHEMA_VERSION, malformed,
sizeof(malformed), &decoded),
"v3 nonzero reserved or unused byte was accepted");
"v4 nonzero reserved or unused byte was accepted");
}
uint8_t invalid_mode[sizeof(payload)];
memcpy(invalid_mode, payload, sizeof(payload));
@ -210,16 +218,33 @@ void test_schema_encoding() {
ADAPTER_CONFIGURATION_SCHEMA_VERSION, invalid_mode,
sizeof(invalid_mode), &decoded),
"out-of-range requested mode was accepted");
const uint8_t invalid_joycon_modes[] = {2, 0xff};
for (uint8_t mode : invalid_joycon_modes) {
memcpy(invalid_mode, payload, sizeof(payload));
invalid_mode[4] = mode;
require(!adapter_configuration_decode(invalid_mode, sizeof(invalid_mode),
&decoded),
"out-of-range Joy-Con2 mode was accepted");
configuration.joycon_mode = static_cast<JoyConMode>(mode);
require(!adapter_configuration_encode(configuration, payload,
sizeof(payload)),
"out-of-range Joy-Con2 mode encoded");
}
configuration.joycon_mode = JoyConMode::kPaired;
require(adapter_configuration_encode(configuration, payload, sizeof(payload)) &&
adapter_configuration_decode(payload, sizeof(payload), &decoded) &&
decoded.joycon_mode == JoyConMode::kPaired,
"Paired mode did not round trip");
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload) - 1, &decoded),
"short v3 record was accepted");
"short v4 record was accepted");
uint8_t oversized[ADAPTER_CONFIGURATION_ENCODED_SIZE + 1]{};
memcpy(oversized, payload, sizeof(payload));
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_SCHEMA_VERSION, oversized,
sizeof(oversized), &decoded),
"oversized v3 record was accepted");
"oversized v4 record was accepted");
configuration.pairing_window_seconds = 9;
require(!adapter_configuration_encode(configuration, payload,
@ -234,7 +259,7 @@ void test_schema_encoding() {
configuration.requested_mode = AdapterRequestedMode::kAuto;
require(!adapter_configuration_encode(configuration, oversized,
sizeof(oversized)),
"v3 encoder accepted a noncanonical output size");
"v4 encoder accepted a noncanonical output size");
}
void test_native_switch_approval_identity_and_canonical_encoding() {
@ -262,6 +287,25 @@ void test_native_switch_approval_identity_and_canonical_encoding() {
decoded, nintendo_identity(3, 0x2006)),
"approval did not remain specific to the complete controller identity");
decoded.joycon_mode = JoyConMode::kIndividual;
require(adapter_configuration_decode(
ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION, payload,
sizeof(payload), &decoded) &&
decoded.joycon_mode == JoyConMode::kPaired &&
adapter_configuration_native_switch_approved(decoded, pro) &&
adapter_configuration_native_switch_approved(decoded, left) &&
adapter_configuration_native_switch_approved(decoded, right),
"v3 migration erased approvals or retained a stale player mode");
for (size_t index = 4; index < ADAPTER_CONFIGURATION_HEADER_SIZE; ++index) {
uint8_t malformed[sizeof(payload)];
memcpy(malformed, payload, sizeof(payload));
malformed[index] = 1;
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION, malformed,
sizeof(malformed), &decoded),
"v3 reserved header byte was accepted");
}
configuration.native_switch_controllers[0] = right;
configuration.native_switch_controllers[1] = pro;
configuration.native_switch_controllers[2] = left;
@ -368,16 +412,27 @@ void test_legacy_migration_power_loss() {
const uint16_t versions[] = {
ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION,
ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION,
ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION,
};
for (uint16_t version : versions) {
const bool v2 = version == ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION;
const uint8_t legacy[] = {
90, 0, static_cast<uint8_t>(v2 ? AdapterRequestedMode::kXInput
: AdapterRequestedMode::kAuto),
0, 0, 0, 0, 0,
};
const size_t legacy_size = v2 ? ADAPTER_CONFIGURATION_V2_ENCODED_SIZE
: ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE;
const bool v1 = version == ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION;
const bool v3 = version == ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION;
AdapterConfiguration source{};
source.pairing_window_seconds = 90;
source.requested_mode = v1 ? AdapterRequestedMode::kAuto
: AdapterRequestedMode::kXInput;
if (v3) {
source.native_switch_controller_count = 2;
source.native_switch_controllers[0] = nintendo_identity(1);
source.native_switch_controllers[1] = nintendo_identity(2, 0x2006);
}
uint8_t legacy[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_encode(source, legacy, sizeof(legacy)),
"legacy migration fixture did not encode");
const size_t legacy_size =
v3 ? ADAPTER_CONFIGURATION_ENCODED_SIZE
: (v1 ? ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE
: ADAPTER_CONFIGURATION_V2_ENCODED_SIZE);
FakeFlash flash;
ConfigurationStorage store;
require(store.initialize(fake_io(&flash)) &&
@ -390,7 +445,7 @@ void test_legacy_migration_power_loss() {
&migrated) &&
adapter_configuration_encode(migrated, payload,
sizeof(payload)),
"legacy settings did not convert to schema3");
"legacy settings did not convert to schema4");
flash.fail_after_programs = flash.successful_programs;
require(store.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload)) ==
@ -418,8 +473,15 @@ void test_legacy_migration_power_loss() {
rebooted.snapshot().payload_size, &decoded) &&
decoded.pairing_window_seconds == 90 &&
decoded.requested_mode == migrated.requested_mode &&
decoded.native_switch_controller_count == 0,
"migration retry changed settings or granted rumble approval");
decoded.joycon_mode == JoyConMode::kPaired &&
decoded.native_switch_controller_count ==
source.native_switch_controller_count &&
(!v3 ||
(adapter_configuration_native_switch_approved(
decoded, source.native_switch_controllers[0]) &&
adapter_configuration_native_switch_approved(
decoded, source.native_switch_controllers[1]))),
"migration retry changed settings or controller approvals");
}
}
@ -553,18 +615,33 @@ void test_transaction_validation() {
transaction.begin(
14, ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION,
ADAPTER_CONFIGURATION_V2_ENCODED_SIZE, 0) ==
ConfigurationTransactionStatus::kUnsupportedSchema &&
transaction.begin(
15, ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION,
ADAPTER_CONFIGURATION_ENCODED_SIZE, 0) ==
ConfigurationTransactionStatus::kUnsupportedSchema,
"legacy host writes could silently erase stored approvals");
"legacy host writes could silently erase stored settings");
payload[3] = ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY + 1;
require(transaction.begin(
15, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload),
16, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload),
configuration_crc32(payload, sizeof(payload))) ==
ConfigurationTransactionStatus::kReceiving &&
transaction.append(15, 0, payload, sizeof(payload)) ==
transaction.append(16, 0, payload, sizeof(payload)) ==
ConfigurationTransactionStatus::kReceiving &&
transaction.finish(15) ==
transaction.finish(16) ==
ConfigurationTransactionStatus::kMalformed,
"valid CRC allowed a malformed approval list to reach storage");
payload[3] = 0;
payload[4] = 2;
require(transaction.begin(
17, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload),
configuration_crc32(payload, sizeof(payload))) ==
ConfigurationTransactionStatus::kReceiving &&
transaction.append(17, 0, payload, sizeof(payload)) ==
ConfigurationTransactionStatus::kReceiving &&
transaction.finish(17) ==
ConfigurationTransactionStatus::kMalformed,
"valid CRC allowed an invalid Joy-Con2 mode to reach storage");
}
} // namespace

View file

@ -84,6 +84,9 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
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" / "controller_profile_transform.cpp"),
str(root / "src" / "firmware" / "profile" / "controller_synthetic_input.cpp"),
str(root / "src" / "firmware" / "profile" / "controller_profile_runtime.cpp"),
str(root / "src" / "firmware" / "profile" / "profile_storage.cpp"),
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
str(
@ -96,6 +99,23 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
subprocess.run(command, check=True, cwd=root)
subprocess.run([str(executable), "xbox-rumble"], check=True, cwd=root)
for scenario in (
"switch2-individual-core-start",
"switch2-individual-forward",
"switch2-individual-reverse",
"switch2-mode-forward",
"switch2-mode-reverse",
"switch2-mode-two-pairs",
"switch2-mode-seed-failure",
"switch2-mode-identity-failure",
"switch2-gesture-timing",
"switch2-gesture-stale",
"switch2-gesture-clock-wrap",
"switch2-gesture-masking-epochs",
"switch2-gesture-override-lifetime",
"switch2-gesture-two-pairs",
"switch2-gesture-ambiguous",
"switch2-gesture-seed-failure",
"switch2-gesture-device-scope",
"switch2-forward",
"switch2-reverse",
"switch2-multiple-pairs",

View file

@ -62,6 +62,7 @@ class FakeDevice:
self.transaction_expected_size = 0
self.transaction_expected_crc = 0
self.transaction_status = config_manager.STATUS_OK
self.fail_configuration_commit_status: int | None = None
self.pending_requested_mode: int | None = None
self.mode_pending_reads = 0
self.fail_mode_status: int | None = None
@ -450,16 +451,19 @@ class FakeDevice:
assert (
zlib.crc32(self.transaction_payload) & 0xFFFFFFFF
) == self.transaction_expected_crc
self.configuration = bytes(self.transaction_payload)
self.configuration_generation += 1
self.transaction_status = config_manager.STATUS_OK
if self.fail_configuration_commit_status is not None:
self.transaction_status = self.fail_configuration_commit_status
else:
self.configuration = bytes(self.transaction_payload)
self.configuration_generation += 1
self.transaction_status = config_manager.STATUS_OK
elif request == config_manager.OP_CONFIGURATION_RESET:
self.transaction_id = struct.unpack("<I", payload)[0]
assert 0 < self.transaction_id <= (config_manager.HOST_TRANSACTION_ID_MASK)
self.configuration = struct.pack(
"<HB5x", 60, config_manager.REQUESTED_MODE_AUTO
)
if self.configuration_schema == config_manager.CONFIGURATION_SCHEMA_VERSION:
if self.configuration_schema >= 3:
self.configuration += bytes(config_manager.CONFIGURATION_SIZE - 8)
self.configuration_generation += 1
self.transaction_payload = bytearray(self.configuration)
@ -710,9 +714,12 @@ def native_rumble_identity(
def native_rumble_configuration(
identities: tuple[config_manager.ControllerIdentity, ...] = (),
joycon_mode: int = config_manager.JOYCON_MODE_PAIRED,
) -> bytes:
return (
struct.pack("<HBB4x", 90, config_manager.REQUESTED_MODE_XINPUT, len(identities))
struct.pack(
"<HBBB3x", 90, config_manager.REQUESTED_MODE_XINPUT, len(identities), joycon_mode
)
+ b"".join(identity.to_bytes() for identity in identities)
+ bytes((16 - len(identities)) * 14)
)
@ -734,6 +741,7 @@ def test_legacy_configuration_has_no_native_rumble_approval(
configuration = config_manager.read_configuration(device)
assert configuration.pairing_window_seconds == 75
assert configuration.requested_mode == mode
assert configuration.joycon_mode == config_manager.JOYCON_MODE_PAIRED
assert configuration.native_switch_controllers == ()
with pytest.raises(config_manager.ConfigManagerError):
config_manager.set_native_switch_rumble_approval(
@ -750,7 +758,8 @@ def test_legacy_configuration_has_no_native_rumble_approval(
assert not device.out_requests
def test_native_rumble_configuration_canonical_wire_round_trip() -> None:
@pytest.mark.parametrize("schema", (3, 4))
def test_native_rumble_configuration_canonical_wire_round_trip(schema: int) -> None:
device = FakeDevice()
identities = tuple(
native_rumble_identity(
@ -761,17 +770,19 @@ def test_native_rumble_configuration_canonical_wire_round_trip() -> None:
config_manager.write_configuration(
device,
config_manager.AdapterConfiguration(
90, 0, 0, config_manager.REQUESTED_MODE_XINPUT, tuple(reversed(identities))
90, 0, 0, config_manager.REQUESTED_MODE_XINPUT, tuple(reversed(identities)),
schema_version=schema,
),
1.0,
)
assert device.configuration_schema == 3
assert device.configuration_schema == schema
assert device.configuration == native_rumble_configuration(identities)
stored = config_manager.read_configuration(device)
assert stored.native_switch_controllers == identities
assert stored.crc == zlib.crc32(device.configuration) & 0xFFFFFFFF
assert stored.pairing_window_seconds == 90
assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT
assert stored.joycon_mode == config_manager.JOYCON_MODE_PAIRED
@pytest.mark.parametrize(
@ -852,12 +863,17 @@ def test_native_rumble_write_rejects_invalid_approvals_before_transaction(
assert not device.out_requests
@pytest.mark.parametrize(
("schema", "joycon_mode"),
((3, config_manager.JOYCON_MODE_PAIRED), (4, config_manager.JOYCON_MODE_INDIVIDUAL)),
)
def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
schema: int, joycon_mode: int,
) -> None:
device = FakeDevice()
device.configuration_schema = 3
device.configuration = native_rumble_configuration()
device.configuration_schema = schema
device.configuration = native_rumble_configuration(joycon_mode=joycon_mode)
first = native_rumble_identity()
second = native_rumble_identity(bytes.fromhex("A1A2A3A4A5A6"))
for identity in (first, second):
@ -888,13 +904,10 @@ def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings(
assert approved.native_switch_controllers == (first,)
assert approved.pairing_window_seconds == 90
assert approved.requested_mode == config_manager.REQUESTED_MODE_XINPUT
assert approved.joycon_mode == joycon_mode
assert device.profiles == previous_profiles
assert device.active_profiles == previous_active
assert device.records == previous_pairings
assert config_manager.main(["config", "native-rumble", "list"]) == 0
output = capsys.readouterr().out
assert f"Approved identity 2: {first.address_text}" in output
assert f"Approved identity 3: {second.address_text}" not in output
assert (
config_manager.main(["config", "set", "--pairing-window-seconds", "120"]) == 0
)
@ -902,10 +915,12 @@ def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings(
assert preserved.pairing_window_seconds == 120
assert preserved.requested_mode == config_manager.REQUESTED_MODE_XINPUT
assert preserved.native_switch_controllers == (first,)
assert preserved.joycon_mode == joycon_mode
config_manager.set_mode(device, config_manager.REQUESTED_MODE_DINPUT, 1.0)
preserved = config_manager.read_configuration(device)
assert preserved.requested_mode == config_manager.REQUESTED_MODE_DINPUT
assert preserved.native_switch_controllers == (first,)
assert preserved.joycon_mode == joycon_mode
assert (
config_manager.main(["config", "native-rumble", "revoke", "--identity", "2"])
== 0
@ -914,6 +929,7 @@ def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings(
assert revoked.native_switch_controllers == ()
assert revoked.requested_mode == config_manager.REQUESTED_MODE_DINPUT
assert revoked.pairing_window_seconds == 120
assert revoked.joycon_mode == joycon_mode
@pytest.mark.parametrize("identity_index", ("0", "1", "99"))
@ -995,6 +1011,7 @@ def test_configuration_transaction_and_reset() -> None:
before.generation,
before.crc,
config_manager.REQUESTED_MODE_XINPUT,
joycon_mode=config_manager.JOYCON_MODE_INDIVIDUAL,
),
1.0,
)
@ -1002,12 +1019,139 @@ def test_configuration_transaction_and_reset() -> None:
stored = config_manager.read_configuration(device)
assert stored.pairing_window_seconds == 90
assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT
assert stored.joycon_mode == config_manager.JOYCON_MODE_INDIVIDUAL
reset = config_manager.reset_configuration(device, 1.0)
assert reset.stored_generation == 5
reset_configuration = config_manager.read_configuration(device)
assert reset_configuration.pairing_window_seconds == 60
assert reset_configuration.requested_mode == config_manager.REQUESTED_MODE_AUTO
assert reset_configuration.native_switch_controllers == ()
assert reset_configuration.joycon_mode == config_manager.JOYCON_MODE_PAIRED
@pytest.mark.parametrize(
("schema", "payload"),
(
(1, struct.pack("<H2x", 75)),
(2, struct.pack("<HB5x", 75, config_manager.REQUESTED_MODE_DINPUT)),
(3, native_rumble_configuration((native_rumble_identity(),))),
),
)
def test_joycon_mode_legacy_read_and_write_refusal(
schema: int, payload: bytes,
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
device = FakeDevice()
device.configuration_schema = schema
device.configuration = payload
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
assert config_manager.main(["joycon-mode", "--json"]) == 0
assert json.loads(capsys.readouterr().out) == {
"mode": "paired", "generation": 3, "supported": False,
}
configuration = config_manager.read_configuration(device)
assert configuration.joycon_mode == config_manager.JOYCON_MODE_PAIRED
if schema == 3:
assert configuration.native_switch_controllers == (native_rumble_identity(),)
for mode in config_manager.JOYCON_MODE_NAMES:
assert config_manager.main(["joycon-mode", mode, "--json"]) == 1
assert capsys.readouterr().out == ""
with pytest.raises(config_manager.ConfigManagerError):
config_manager.write_configuration(
device, replace(configuration, joycon_mode=config_manager.JOYCON_MODE_INDIVIDUAL),
1.0,
)
assert device.configuration == payload
assert not device.out_requests
@pytest.mark.parametrize(
("offset", "value"), ((4, 2), (4, 255), (5, 1), (6, 1), (7, 1))
)
def test_joycon_mode_rejects_invalid_wire_encoding(offset: int, value: int) -> None:
device = FakeDevice()
device.configuration_schema = 4
payload = bytearray(native_rumble_configuration((native_rumble_identity(),)))
payload[offset] = value
device.configuration = bytes(payload)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_configuration(device)
@pytest.mark.parametrize("size", (8, 231, 233))
def test_joycon_mode_requires_exact_configuration_size(size: int) -> None:
device = FakeDevice()
device.configuration_schema = 4
payload = native_rumble_configuration()
device.configuration = (payload + b"\x00")[:size]
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_configuration(device)
@pytest.mark.parametrize("mode", (-1, 2, True, 1.0, "individual"))
def test_joycon_mode_rejects_invalid_values_before_transaction(mode: object) -> None:
device = FakeDevice()
device.configuration_schema = 4
device.configuration = native_rumble_configuration()
before = config_manager.read_configuration(device)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.set_joycon_mode(device, mode, 1.0)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.write_configuration(device, replace(before, joycon_mode=mode), 1.0)
assert not device.out_requests
def test_joycon_mode_cli_commits_and_reads_without_reboot_or_profile_changes(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
device = FakeDevice()
device.configuration_schema = 4
identity = native_rumble_identity()
device.configuration = native_rumble_configuration((identity,))
previous_profiles = dict(device.profiles)
previous_active = dict(device.active_profiles)
previous_aliases = dict(device.profile_aliases)
previous_names = dict(device.profile_names)
previous_pairings = list(device.records)
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
for generation, mode in enumerate(("individual", "paired"), start=4):
assert config_manager.main(["joycon-mode", mode, "--json"]) == 0
expected = {"mode": mode, "generation": generation, "supported": True}
assert json.loads(capsys.readouterr().out) == expected
stored = config_manager.read_configuration(device)
assert stored.joycon_mode == config_manager.JOYCON_MODE_NAMES.index(mode)
assert stored.pairing_window_seconds == 90
assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT
assert stored.native_switch_controllers == (identity,)
assert device.configuration == native_rumble_configuration(
(identity,), config_manager.JOYCON_MODE_NAMES.index(mode)
)
writes_before_read = tuple(device.out_requests)
assert config_manager.main(["joycon-mode", "--json"]) == 0
assert json.loads(capsys.readouterr().out) == expected
assert tuple(device.out_requests) == writes_before_read
assert device.profiles == previous_profiles
assert device.active_profiles == previous_active
assert device.profile_aliases == previous_aliases
assert device.profile_names == previous_names
assert device.records == previous_pairings
assert not device.reboot_transaction_ids
assert not device.bootsel_reboot_requested
def test_joycon_mode_cli_commit_failure_does_not_claim_new_preference(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
device = FakeDevice()
device.configuration_schema = 4
device.configuration = native_rumble_configuration((native_rumble_identity(),))
before = config_manager.read_configuration(device)
device.fail_configuration_commit_status = 8
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
assert config_manager.main(["joycon-mode", "individual", "--json"]) == 1
assert capsys.readouterr().out == ""
assert config_manager.read_configuration(device) == before
assert not device.reboot_transaction_ids
def test_configuration_transaction_ids_stay_in_host_range(

View file

@ -58,6 +58,7 @@ def test_configuration_service_native(tmp_path: Path) -> None:
for scenario in (
"lifecycle",
"v2-migration",
"v3-migration",
"native-approvals",
"abandoned-receive",
):

View file

@ -10,9 +10,10 @@ from dataclasses import replace
from typing import Any
import pytest
import usb.core
from switch_pico_bridge import config_manager, profile_web
from tests.test_config_manager import FakeDevice, custom_profile
from tests.test_config_manager import FakeDevice, custom_profile, native_rumble_identity
@contextmanager
@ -604,6 +605,184 @@ def test_editor_rejects_invalid_or_unauthorized_mutations(
assert device.profiles[(device.global_identity.to_bytes(), 0)] == original
def test_joycon_mode_preserves_configuration_and_all_profile_banks(
monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice,
) -> None:
device = joycon_pair_device
config_manager.write_configuration(
device,
config_manager.AdapterConfiguration(
135, 0, 0, config_manager.REQUESTED_MODE_XINPUT,
(native_rumble_identity(),),
),
1.0,
)
for index, identity in enumerate(device.profile_identities):
key = identity.to_bytes()
device.profiles[(key, index)] = custom_profile().to_bytes()
device.profile_names[(key, index)] = f"Layout {index}"
device.profile_aliases[key] = f"Controller {index}"
before = config_manager.read_configuration(device)
profiles = device.profiles.copy()
names = device.profile_names.copy()
aliases = device.profile_aliases.copy()
active = device.active_profiles.copy()
pairings = config_manager.read_pairings(device)
with running_server(monkeypatch, device) as (base_url, token):
status, initial = request_json(f"{base_url}/api/joycon-mode")
assert status == 200
assert initial == {
"mode": "paired", "generation": before.generation, "supported": True,
}
for mode in ("individual", "paired"):
status, committed = request_json(
f"{base_url}/api/joycon-mode", method="PUT",
value={"mode": mode}, token=token,
)
assert status == 200
stored = config_manager.read_configuration(device)
assert committed == {
"mode": mode, "generation": stored.generation, "supported": True,
}
assert stored.joycon_mode == config_manager.JOYCON_MODE_NAMES.index(mode)
assert stored.generation > before.generation
assert replace(
stored, joycon_mode=before.joycon_mode,
generation=before.generation, crc=before.crc,
) == before
assert request_json(f"{base_url}/api/joycon-mode") == (200, committed)
assert device.profiles == profiles
assert device.profile_names == names
assert device.profile_aliases == aliases
assert device.active_profiles == active
assert config_manager.read_pairings(device) == pairings
assert device.reboot_transaction_ids == []
@pytest.mark.parametrize("schema", [1, 2, 3])
def test_joycon_mode_legacy_reads_default_and_refuses_mutations(
monkeypatch: pytest.MonkeyPatch, schema: int,
) -> None:
device = FakeDevice()
config_manager.write_configuration(
device,
config_manager.AdapterConfiguration(
95, 0, 0,
native_switch_controllers=(native_rumble_identity(),) if schema == 3 else (),
schema_version=schema,
),
1.0,
)
before = config_manager.read_configuration(device)
with running_server(monkeypatch, device) as (base_url, token):
status, legacy = request_json(f"{base_url}/api/joycon-mode")
assert status == 200
assert legacy == {
"mode": "paired", "generation": before.generation, "supported": False,
}
for mode in ("paired", "individual"):
status, _ = request_json(
f"{base_url}/api/joycon-mode", method="PUT",
value={"mode": mode}, token=token,
)
assert status == 400
assert config_manager.read_configuration(device) == before
def test_joycon_mode_rejects_unauthorized_and_malformed_requests(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
config_manager.write_configuration(
device, config_manager.AdapterConfiguration(110, 0, 0), 1.0,
)
before = config_manager.read_configuration(device)
with running_server(monkeypatch, device) as (base_url, token):
endpoint = f"{base_url}/api/joycon-mode"
for supplied_token in (None, "invalid"):
status, _ = request_json(
endpoint, method="PUT", value={"mode": "individual"},
token=supplied_token,
)
assert status == 403
for body in (
{}, [], {"mode": 1}, {"mode": True}, {"mode": None},
{"mode": ["individual"]}, {"mode": "Individual"},
{"mode": "individual", "pairing_window_seconds": 10},
):
status, _ = request_json(
endpoint, method="PUT", value=body, token=token,
)
assert status == 400
for raw in (b"", b"{", b"\xff", b" " * (profile_web._MAXIMUM_REQUEST_BYTES + 1)):
request = urllib.request.Request(
endpoint, data=raw, method="PUT",
headers={"X-Switch-Pico-Token": token},
)
with pytest.raises(urllib.error.HTTPError) as rejected:
urllib.request.urlopen(request, timeout=2)
assert rejected.value.code == 400
for method in ("GET", "PUT"):
request = urllib.request.Request(
endpoint, method=method,
data=b'{"mode":"individual"}' if method == "PUT" else None,
headers={"Host": "untrusted.example", "X-Switch-Pico-Token": token},
)
with pytest.raises(urllib.error.HTTPError) as rejected:
urllib.request.urlopen(request, timeout=2)
assert rejected.value.code == 403
assert config_manager.read_configuration(device) == before
def test_joycon_mode_commit_is_not_reported_as_confirmed_when_readback_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
config_manager.write_configuration(
device, config_manager.AdapterConfiguration(110, 0, 0), 1.0,
)
read_configuration = config_manager.read_configuration
before = read_configuration(device)
def disconnect_after_commit(device: config_manager.UsbDevice) -> config_manager.AdapterConfiguration:
configuration = read_configuration(device)
if configuration.generation != before.generation:
raise usb.core.USBError("device disconnected")
return configuration
monkeypatch.setattr(config_manager, "read_configuration", disconnect_after_commit)
with running_server(monkeypatch, device) as (base_url, token):
status, response = request_json(
f"{base_url}/api/joycon-mode", method="PUT",
value={"mode": "individual"}, token=token,
)
assert status == 503
assert "error" in response
assert "mode" not in response
assert read_configuration(device).joycon_mode == config_manager.JOYCON_MODE_INDIVIDUAL
@pytest.mark.parametrize("method", ["GET", "PUT"])
def test_joycon_mode_usb_failure_returns_service_unavailable(
monkeypatch: pytest.MonkeyPatch, method: str,
) -> None:
device = FakeDevice()
before = device.configuration
def disconnected(*args: Any, **kwargs: Any) -> Any:
raise usb.core.USBError("device disconnected")
monkeypatch.setattr(device, "ctrl_transfer", disconnected)
with running_server(monkeypatch, device) as (base_url, token):
status, response = request_json(
f"{base_url}/api/joycon-mode", method=method,
value={"mode": "individual"} if method == "PUT" else None, token=token,
)
assert status == 503
assert "error" in response
assert device.configuration == before
def test_recorder_accepts_first_connection_generation_zero(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -302,6 +302,7 @@ void test_mode_vendor_requests() {
using namespace UsbConfigurationManagement;
current_configuration.configuration.requested_mode =
AdapterRequestedMode::kXInput;
current_configuration.configuration.joycon_mode = JoyConMode::kIndividual;
current_active_mode = AdapterUsbMode::kSwitchProbe;
tusb_control_request_t request =
@ -339,8 +340,10 @@ void test_mode_vendor_requests() {
control_payload[10] ==
ADAPTER_CONFIGURATION_SCHEMA_VERSION &&
control_payload[kResponseHeaderSize + 2] ==
static_cast<uint8_t>(AdapterRequestedMode::kXInput),
"configuration response did not keep requested mode separate");
static_cast<uint8_t>(AdapterRequestedMode::kXInput) &&
control_payload[kResponseHeaderSize + 4] ==
static_cast<uint8_t>(JoyConMode::kIndividual),
"configuration response did not report the saved USB and player modes");
std::vector<uint8_t> mode_set(5);
write_u32(&mode_set, 0, 0x12345678);