feat: add opt-in native Nintendo rumble backend

This commit is contained in:
Joey Yakimowich-Payne 2026-09-05 23:58:50 -06:00
commit 3fc28b1fa4
44 changed files with 4834 additions and 240 deletions

View file

@ -101,18 +101,27 @@ ConfigurationStorageIo pico_configuration_storage_io() {
namespace {
void seed_legacy_configuration() {
void seed_legacy_configuration(bool v2) {
ConfigurationStorage seed;
const uint8_t legacy[] = {90, 0, 0, 0};
const uint8_t legacy[] = {
90, 0, static_cast<uint8_t>(v2 ? AdapterRequestedMode::kXInput
: AdapterRequestedMode::kAuto),
0, 0, 0, 0, 0,
};
require(seed.initialize(fake_io()), "legacy seed storage init failed");
require(seed.commit(ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION,
legacy, sizeof(legacy)) ==
require(seed.commit(
v2 ? ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION
: ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION,
legacy, v2 ? ADAPTER_CONFIGURATION_V2_ENCODED_SIZE
: ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE) ==
ConfigurationStorageResult::kOk,
"legacy seed commit failed");
}
void test_service_lifecycle_and_mutations() {
seed_legacy_configuration();
void test_service_lifecycle_and_mutations(bool v2) {
seed_legacy_configuration(v2);
const AdapterRequestedMode original_mode =
v2 ? AdapterRequestedMode::kXInput : AdapterRequestedMode::kAuto;
const int programs_after_seed = g_flash.program_count;
const int erases_after_seed = g_flash.erase_count;
@ -123,9 +132,9 @@ void test_service_lifecycle_and_mutations() {
configuration_service_snapshot(&snapshot);
require(snapshot.state == ConfigurationServiceState::kReady &&
snapshot.configuration.pairing_window_seconds == 90 &&
snapshot.configuration.requested_mode ==
AdapterRequestedMode::kAuto,
"Core 0 did not decode and publish the v1 configuration");
snapshot.configuration.requested_mode == original_mode &&
snapshot.configuration.native_switch_controller_count == 0,
"Core 0 did not preserve legacy settings without approval");
require(g_flash.program_count == programs_after_seed &&
g_flash.erase_count == erases_after_seed,
"pre-USB initialization wrote flash");
@ -145,16 +154,16 @@ void test_service_lifecycle_and_mutations() {
require(configuration_service_set_mode(
2, AdapterRequestedMode::kSwitch, implemented) ==
ConfigurationTransactionStatus::kBusy,
"host mutation displaced the pending v1 migration");
"host mutation displaced the pending legacy migration");
configuration_service_task_on_storage_core(0);
configuration_service_snapshot(&snapshot);
require(snapshot.state == ConfigurationServiceState::kReady &&
snapshot.configuration.requested_mode ==
AdapterRequestedMode::kAuto &&
snapshot.configuration.requested_mode == original_mode &&
snapshot.configuration.native_switch_controller_count == 0 &&
snapshot.transaction.status ==
ConfigurationTransactionStatus::kIdle,
"v1 migration changed configuration or host transaction state");
"legacy migration changed configuration or host transaction state");
ConfigurationStorage after_migration;
require(after_migration.initialize(fake_io()) &&
@ -163,15 +172,16 @@ void test_service_lifecycle_and_mutations() {
ADAPTER_CONFIGURATION_SCHEMA_VERSION &&
after_migration.snapshot().payload_size ==
ADAPTER_CONFIGURATION_ENCODED_SIZE,
"power cycle did not observe the migrated v2 record");
"power cycle did not observe the migrated v3 record");
AdapterConfiguration migrated{};
require(adapter_configuration_decode(
after_migration.snapshot().schema_version,
after_migration.snapshot().payload,
after_migration.snapshot().payload_size, &migrated) &&
migrated.pairing_window_seconds == 90 &&
migrated.requested_mode == AdapterRequestedMode::kAuto,
"migrated v2 bytes did not preserve v1 configuration");
migrated.requested_mode == original_mode &&
migrated.native_switch_controller_count == 0,
"migrated v3 bytes did not preserve legacy configuration");
require(configuration_service_set_mode(
10, AdapterRequestedMode::kSwitch, implemented) ==
@ -391,7 +401,7 @@ void test_service_lifecycle_and_mutations() {
snapshot.transaction.status ==
ConfigurationTransactionStatus::kCommitted &&
snapshot.reset_generation == reset_generation_before + 1,
"configuration reset did not durably restore v2 defaults");
"configuration reset did not durably restore v3 defaults");
constexpr uint32_t kCapturedHostMode = 50;
require(configuration_service_set_mode(
@ -508,13 +518,178 @@ void test_abandoned_host_receive_does_not_block_recovery() {
"recovery Auto did not become latest reboot authority");
}
void queue_configuration(uint32_t transaction_id,
const AdapterConfiguration& configuration) {
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_encode(configuration, payload, sizeof(payload)),
"service approval fixture did not encode");
require(configuration_service_begin(
transaction_id, ADAPTER_CONFIGURATION_SCHEMA_VERSION,
sizeof(payload), configuration_crc32(payload, sizeof(payload))) ==
ConfigurationTransactionStatus::kReceiving &&
configuration_service_append(transaction_id, 0, payload,
sizeof(payload)) ==
ConfigurationTransactionStatus::kReceiving &&
configuration_service_commit(transaction_id) ==
ConfigurationTransactionStatus::kPending,
"approval configuration did not reach pending commit");
}
void test_native_switch_approval_preservation_and_revocation() {
ControllerIdentity pro{};
pro.stable = true;
pro.transport = ControllerTransport::kClassic;
pro.address[0] = 0x02;
pro.address[5] = 1;
pro.vendor_id = 0x057e;
pro.product_id = 0x2009;
ControllerIdentity left = pro;
left.address[5] = 2;
left.product_id = 0x2006;
AdapterConfiguration configuration{};
configuration.pairing_window_seconds = 90;
configuration.native_switch_controller_count = 2;
configuration.native_switch_controllers[0] = pro;
configuration.native_switch_controllers[1] = left;
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
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,
"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) &&
adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"pre-USB snapshot did not expose persisted approvals");
configuration_service_initialize_on_storage_core();
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_snapshot(&snapshot);
require(snapshot.configuration.requested_mode == AdapterRequestedMode::kSwitch &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"host mode selection erased approval");
require(configuration_service_set_mode_internal(
0x80000002u, AdapterRequestedMode::kXInput, implemented) ==
ConfigurationTransactionStatus::kPending,
"approved configuration blocked internal mode selection");
configuration_service_task_on_storage_core(1000);
configuration_service_snapshot(&snapshot);
require(snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"internal mode selection erased approval");
configuration = snapshot.configuration;
configuration.pairing_window_seconds = 120;
queue_configuration(3, configuration);
configuration_service_task_on_storage_core(2000);
configuration_service_snapshot(&snapshot);
require(snapshot.configuration.pairing_window_seconds == 120 &&
snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"pairing-window update erased approvals or requested mode");
const uint32_t approved_generation = snapshot.generation;
const uint32_t approved_crc = snapshot.payload_crc;
configuration = snapshot.configuration;
configuration.native_switch_controller_count = 1;
configuration.native_switch_controllers[0] = left;
queue_configuration(4, configuration);
configuration_service_snapshot(&snapshot);
require(adapter_configuration_native_switch_approved(
snapshot.configuration, pro),
"approval was revoked before durable commit");
g_flash.fail_program = true;
configuration_service_task_on_storage_core(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 &&
adapter_configuration_native_switch_approved(
snapshot.configuration, pro),
"failed revocation changed the published durable configuration");
ConfigurationStorage after_failure;
AdapterConfiguration recovered{};
require(after_failure.initialize(fake_io()) &&
adapter_configuration_decode(
after_failure.snapshot().schema_version,
after_failure.snapshot().payload,
after_failure.snapshot().payload_size, &recovered) &&
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_snapshot(&snapshot);
require(snapshot.transaction.status ==
ConfigurationTransactionStatus::kCommitted &&
snapshot.configuration.pairing_window_seconds == 120 &&
snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
!adapter_configuration_native_switch_approved(
snapshot.configuration, pro) &&
adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"successful revocation did not preserve other approval and settings");
ConfigurationStorage after_revocation;
require(after_revocation.initialize(fake_io()) &&
adapter_configuration_decode(
after_revocation.snapshot().schema_version,
after_revocation.snapshot().payload,
after_revocation.snapshot().payload_size, &recovered) &&
!adapter_configuration_native_switch_approved(recovered, pro) &&
adapter_configuration_native_switch_approved(recovered, left),
"controller-specific revocation did not survive power cycle");
require(configuration_service_reset(6) ==
ConfigurationTransactionStatus::kPending,
"approved configuration reset was not queued");
configuration_service_snapshot(&snapshot);
require(adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"reset erased approval before commit");
configuration_service_task_on_storage_core(4000);
configuration_service_snapshot(&snapshot);
require(snapshot.transaction.status == ConfigurationTransactionStatus::kCommitted &&
!adapter_configuration_native_switch_approved(
snapshot.configuration, left),
"configuration reset did not revoke persisted approval");
}
} // namespace
int main(int argc, char**) {
if (argc > 1) {
test_abandoned_host_receive_does_not_block_recovery();
int main(int argc, char** argv) {
if (argc < 2 || strcmp(argv[1], "lifecycle") == 0) {
test_service_lifecycle_and_mutations(false);
} 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();
} else {
test_service_lifecycle_and_mutations();
require(strcmp(argv[1], "abandoned-receive") == 0,
"unknown service test scenario");
test_abandoned_host_receive_does_not_block_recovery();
}
return 0;
}

View file

@ -74,6 +74,18 @@ ConfigurationStorageIo fake_io(FakeFlash* flash) {
};
}
ControllerIdentity nintendo_identity(uint8_t address_suffix,
uint16_t product_id = 0x2009) {
ControllerIdentity identity{};
identity.stable = true;
identity.transport = ControllerTransport::kClassic;
identity.address[0] = 0x02;
identity.address[5] = address_suffix;
identity.vendor_id = 0x057e;
identity.product_id = product_id;
return identity;
}
void test_schema_encoding() {
AdapterConfiguration configuration{};
configuration.pairing_window_seconds = 90;
@ -81,8 +93,8 @@ void test_schema_encoding() {
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_encode(configuration, payload,
sizeof(payload)),
"valid v2 configuration did not encode");
const uint8_t expected[] = {
"valid v3 configuration did not encode");
const uint8_t expected[ADAPTER_CONFIGURATION_ENCODED_SIZE] = {
90,
0,
static_cast<uint8_t>(AdapterRequestedMode::kXInput),
@ -93,15 +105,16 @@ void test_schema_encoding() {
0,
};
require(memcmp(payload, expected, sizeof(expected)) == 0,
"v2 configuration bytes are not canonical");
"v3 configuration bytes are not canonical");
AdapterConfiguration decoded{};
require(adapter_configuration_decode(
ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload), &decoded) &&
decoded.pairing_window_seconds == 90 &&
decoded.requested_mode == AdapterRequestedMode::kXInput,
"v2 configuration did not round trip");
decoded.requested_mode == AdapterRequestedMode::kXInput &&
decoded.native_switch_controller_count == 0,
"v3 configuration did not round trip");
const AdapterRequestedMode valid_modes[] = {
AdapterRequestedMode::kAuto,
@ -136,12 +149,15 @@ void test_schema_encoding() {
"availability API could not enable future modes");
const uint8_t legacy[] = {120, 0, 0, 0};
decoded.native_switch_controller_count = 1;
decoded.native_switch_controllers[0] = nintendo_identity(1);
require(adapter_configuration_decode(
ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION, legacy,
sizeof(legacy), &decoded) &&
decoded.pairing_window_seconds == 120 &&
decoded.requested_mode == AdapterRequestedMode::kAuto,
"v1 configuration did not migrate to auto");
decoded.requested_mode == AdapterRequestedMode::kAuto &&
decoded.native_switch_controller_count == 0,
"v1 configuration did not migrate without approvals");
uint8_t malformed_legacy[sizeof(legacy)];
memcpy(malformed_legacy, legacy, sizeof(legacy));
malformed_legacy[3] = 1;
@ -154,6 +170,30 @@ void test_schema_encoding() {
sizeof(legacy) - 1, &decoded),
"v1 record with wrong size was accepted");
const uint8_t v2[] = {
120, 0, static_cast<uint8_t>(AdapterRequestedMode::kMac), 0, 0, 0, 0, 0,
};
require(adapter_configuration_decode(
ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION, v2, sizeof(v2),
&decoded) &&
decoded.pairing_window_seconds == 120 &&
decoded.requested_mode == AdapterRequestedMode::kMac &&
decoded.native_switch_controller_count == 0,
"v2 migration changed settings or granted native rumble");
for (size_t index = 3; index < sizeof(v2); ++index) {
uint8_t malformed[sizeof(v2)];
memcpy(malformed, v2, sizeof(v2));
malformed[index] = 1;
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION, malformed,
sizeof(malformed), &decoded),
"v2 nonzero reserved byte was accepted");
}
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION, v2, sizeof(v2) - 1,
&decoded),
"v2 record with wrong size was accepted");
for (size_t index = 3; index < sizeof(payload); ++index) {
uint8_t malformed[sizeof(payload)];
memcpy(malformed, payload, sizeof(payload));
@ -161,7 +201,7 @@ void test_schema_encoding() {
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_SCHEMA_VERSION, malformed,
sizeof(malformed), &decoded),
"v2 nonzero reserved byte was accepted");
"v3 nonzero reserved or unused byte was accepted");
}
uint8_t invalid_mode[sizeof(payload)];
memcpy(invalid_mode, payload, sizeof(payload));
@ -173,13 +213,13 @@ void test_schema_encoding() {
require(!adapter_configuration_decode(
ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload) - 1, &decoded),
"short v2 record was accepted");
"short v3 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 v2 record was accepted");
"oversized v3 record was accepted");
configuration.pairing_window_seconds = 9;
require(!adapter_configuration_encode(configuration, payload,
@ -194,7 +234,193 @@ void test_schema_encoding() {
configuration.requested_mode = AdapterRequestedMode::kAuto;
require(!adapter_configuration_encode(configuration, oversized,
sizeof(oversized)),
"v2 encoder accepted a noncanonical output size");
"v3 encoder accepted a noncanonical output size");
}
void test_native_switch_approval_identity_and_canonical_encoding() {
const ControllerIdentity pro = nintendo_identity(3);
const ControllerIdentity left = nintendo_identity(1, 0x2006);
const ControllerIdentity right = nintendo_identity(2, 0x2007);
AdapterConfiguration configuration{};
require(!adapter_configuration_native_switch_approved(configuration, pro),
"a supported model was approved without an explicit identity");
configuration.native_switch_controller_count = 3;
configuration.native_switch_controllers[0] = pro;
configuration.native_switch_controllers[1] = left;
configuration.native_switch_controllers[2] = right;
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_encode(configuration, payload, sizeof(payload)),
"original Switch controller approvals did not encode");
AdapterConfiguration decoded{};
require(adapter_configuration_decode(payload, sizeof(payload), &decoded) &&
adapter_configuration_native_switch_approved(decoded, pro) &&
adapter_configuration_native_switch_approved(decoded, left) &&
adapter_configuration_native_switch_approved(decoded, right) &&
!adapter_configuration_native_switch_approved(
decoded, nintendo_identity(4)) &&
!adapter_configuration_native_switch_approved(
decoded, nintendo_identity(3, 0x2006)),
"approval did not remain specific to the complete controller identity");
configuration.native_switch_controllers[0] = right;
configuration.native_switch_controllers[1] = pro;
configuration.native_switch_controllers[2] = left;
uint8_t reordered[sizeof(payload)]{};
require(adapter_configuration_encode(configuration, reordered,
sizeof(reordered)) &&
memcmp(payload, reordered, sizeof(payload)) == 0,
"approval insertion order changed the persisted bytes or CRC");
configuration.native_switch_controller_count = 0;
require(!adapter_configuration_native_switch_approved(configuration, pro) &&
adapter_configuration_encode(configuration, reordered,
sizeof(reordered)) &&
adapter_configuration_decode(reordered, sizeof(reordered),
&decoded) &&
!adapter_configuration_native_switch_approved(decoded, pro),
"revoked array entries remained approved after encode and decode");
configuration.native_switch_controller_count =
ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY;
for (size_t index = 0;
index < configuration.native_switch_controller_count; ++index) {
configuration.native_switch_controllers[index] =
nintendo_identity(static_cast<uint8_t>(
configuration.native_switch_controller_count - index));
}
require(adapter_configuration_encode(configuration, payload, sizeof(payload)) &&
adapter_configuration_decode(payload, sizeof(payload), &decoded) &&
adapter_configuration_native_switch_approved(
decoded, nintendo_identity(1)) &&
adapter_configuration_native_switch_approved(
decoded, nintendo_identity(16)),
"a full approval array lost its boundary identities");
++configuration.native_switch_controller_count;
require(!adapter_configuration_encode(configuration, payload, sizeof(payload)) &&
!adapter_configuration_native_switch_approved(configuration, pro),
"an oversized approval array was accepted");
payload[3] = configuration.native_switch_controller_count;
require(!adapter_configuration_decode(payload, sizeof(payload), &decoded),
"an oversized encoded approval count was accepted");
}
void test_native_switch_approval_rejects_invalid_records() {
AdapterConfiguration configuration{};
configuration.native_switch_controller_count = 2;
configuration.native_switch_controllers[0] = nintendo_identity(1);
configuration.native_switch_controllers[1] = nintendo_identity(2);
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_encode(configuration, payload, sizeof(payload)),
"approval validation fixture did not encode");
configuration.native_switch_controllers[1] =
configuration.native_switch_controllers[0];
uint8_t malformed[sizeof(payload)]{};
require(!adapter_configuration_encode(configuration, malformed,
sizeof(malformed)),
"duplicate approval identities encoded");
memcpy(malformed, payload, sizeof(payload));
constexpr size_t first = ADAPTER_CONFIGURATION_HEADER_SIZE;
constexpr size_t second = first + CONTROLLER_IDENTITY_ENCODED_SIZE;
memcpy(malformed + second, malformed + first,
CONTROLLER_IDENTITY_ENCODED_SIZE);
AdapterConfiguration decoded{};
require(!adapter_configuration_decode(malformed, sizeof(malformed), &decoded),
"duplicate encoded approval identities were accepted");
memcpy(malformed + first, payload + second, CONTROLLER_IDENTITY_ENCODED_SIZE);
memcpy(malformed + second, payload + first, CONTROLLER_IDENTITY_ENCODED_SIZE);
require(!adapter_configuration_decode(malformed, sizeof(malformed), &decoded),
"noncanonical approval ordering was accepted");
configuration.native_switch_controller_count = 1;
ControllerIdentity invalid[] = {
controller_identity_global(), nintendo_identity(1),
nintendo_identity(1), nintendo_identity(1), nintendo_identity(1, 0x2069),
};
invalid[1].stable = false;
invalid[2].transport = ControllerTransport::kBle;
invalid[3].vendor_id = 0x1234;
for (const ControllerIdentity& identity : invalid) {
configuration.native_switch_controllers[0] = identity;
require(!adapter_configuration_encode(configuration, malformed,
sizeof(malformed)) &&
!adapter_configuration_native_switch_approved(
configuration, identity),
"global, unstable, BLE, or ineligible controller was approved");
}
const uint8_t invalid_fields[][2] = {
{0, 0}, {0, 2}, {1, 0}, {1, 2}, {1, 3}, {3, 1},
{10, 0x34}, {12, 0x69},
};
for (const auto& field : invalid_fields) {
memcpy(malformed, payload, sizeof(payload));
malformed[first + field[0]] = field[1];
require(!adapter_configuration_decode(malformed, sizeof(malformed),
&decoded),
"malformed or ineligible encoded identity was accepted");
}
memcpy(malformed, payload, sizeof(payload));
malformed[sizeof(malformed) - 1] = 1;
require(!adapter_configuration_decode(malformed, sizeof(malformed), &decoded),
"nonzero unused approval bytes were accepted");
}
void test_legacy_migration_power_loss() {
const uint16_t versions[] = {
ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION,
ADAPTER_CONFIGURATION_V2_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;
FakeFlash flash;
ConfigurationStorage store;
require(store.initialize(fake_io(&flash)) &&
store.commit(version, legacy, legacy_size) ==
ConfigurationStorageResult::kOk,
"legacy migration seed did not persist");
AdapterConfiguration migrated{};
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
require(adapter_configuration_decode(version, legacy, legacy_size,
&migrated) &&
adapter_configuration_encode(migrated, payload,
sizeof(payload)),
"legacy settings did not convert to schema3");
flash.fail_after_programs = flash.successful_programs;
require(store.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload)) ==
ConfigurationStorageResult::kIoError,
"failed migration reported success");
flash.fail_after_programs = -1;
ConfigurationStorage recovered;
require(recovered.initialize(fake_io(&flash)) &&
recovered.snapshot().schema_version == version &&
recovered.snapshot().payload_size == legacy_size &&
memcmp(recovered.snapshot().payload, legacy, legacy_size) == 0,
"migration power loss destroyed the legacy settings");
require(recovered.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
sizeof(payload)) ==
ConfigurationStorageResult::kOk,
"migration retry failed");
ConfigurationStorage rebooted;
AdapterConfiguration decoded{};
require(rebooted.initialize(fake_io(&flash)) &&
rebooted.snapshot().schema_version ==
ADAPTER_CONFIGURATION_SCHEMA_VERSION &&
adapter_configuration_decode(
rebooted.snapshot().schema_version,
rebooted.snapshot().payload,
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");
}
}
void test_two_copy_recovery() {
@ -320,12 +546,34 @@ void test_transaction_validation() {
CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE + 1, crc) ==
ConfigurationTransactionStatus::kTooLarge,
"oversized transaction was accepted");
require(transaction.begin(
13, ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION,
ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE, 0) ==
ConfigurationTransactionStatus::kUnsupportedSchema &&
transaction.begin(
14, ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION,
ADAPTER_CONFIGURATION_V2_ENCODED_SIZE, 0) ==
ConfigurationTransactionStatus::kUnsupportedSchema,
"legacy host writes could silently erase stored approvals");
payload[3] = ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY + 1;
require(transaction.begin(
15, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload),
configuration_crc32(payload, sizeof(payload))) ==
ConfigurationTransactionStatus::kReceiving &&
transaction.append(15, 0, payload, sizeof(payload)) ==
ConfigurationTransactionStatus::kReceiving &&
transaction.finish(15) ==
ConfigurationTransactionStatus::kMalformed,
"valid CRC allowed a malformed approval list to reach storage");
}
} // namespace
int main() {
test_schema_encoding();
test_native_switch_approval_identity_and_canonical_encoding();
test_native_switch_approval_rejects_invalid_records();
test_legacy_migration_power_loss();
test_two_copy_recovery();
test_interrupted_write_retains_previous_generation();
test_transaction_validation();

View file

@ -0,0 +1,323 @@
#include "usb/switch/switch_native_haptics.h"
#include "profile/controller_profile.h"
#include "profile/controller_profile_transform.h"
#include <cstdlib>
#include <cstring>
#include <iostream>
namespace {
constexpr uint8_t kNeutral[8] = {0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kSeed[8] = {0x00, 0x21, 0x40, 0x48, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kOne[8] = {0x00, 0x00, 0x10, 0x69, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kTwo[8] = {0x00, 0x74, 0x1c, 0xa9, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kThree[8] = {0x78, 0x77, 0x1c, 0xe9, 0x00, 0x01, 0x40, 0x40};
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
void expect_packet(const SwitchNativeHapticsPackets& packets, const uint8_t expected[8],
const char* message) {
require(packets.count == 1 && std::memcmp(packets.bytes[0], expected, 8) == 0, message);
}
ControllerRumbleOutput single(SwitchHapticsSample left, SwitchHapticsSample right = {}) {
ControllerRumbleOutput output{};
output.hd.actuators[0].sample_count = 1;
output.hd.actuators[1].sample_count = 1;
output.hd.actuators[0].samples[0] = left;
output.hd.actuators[1].samples[0] = right;
return output;
}
ControllerRumbleOutput play(SwitchHapticsDecoder& device, const SwitchNativeHapticsPackets& packets) {
require(packets.count >= 1 && packets.count <= 2, "encoder exceeded bounded packet schedule");
ControllerRumbleOutput result{};
for (uint8_t i = 0; i < packets.count; ++i) {
result = device.decode(packets.bytes[i]);
for (const auto& side : result.hd.actuators) {
require(side.sample_count >= 1 && side.sample_count <= 3, "invalid generated substep count");
for (uint8_t step = 0; step < side.sample_count; ++step) {
const auto& sample = side.samples[step];
require(sample.low_amplitude_q15 <= 17867 && sample.high_amplitude_q15 <= 17867,
"generated an amplitude above documented absolute code 100");
require(sample.low_frequency_index >= 1 && sample.low_frequency_index <= 127 &&
sample.high_frequency_index >= 1 && sample.high_frequency_index <= 127,
"generated frequency outside public absolute range");
}
}
}
return result;
}
void expect_sample(const SwitchHapticsSample& actual, const SwitchHapticsSample& expected) {
require(actual.low_frequency_index == expected.low_frequency_index &&
actual.high_frequency_index == expected.high_frequency_index &&
actual.low_amplitude_q15 == expected.low_amplitude_q15 &&
actual.high_amplitude_q15 == expected.high_amplitude_q15,
"native output lost band frequency/amplitude or substep order");
}
void test_public_absolute_goldens() {
// Independent dekuNukem rumble_data_table.md byte example: HF=0x1a8,
// HA=0x88, LF=0x63, LA=0x804d => a8 89 e3 4d. The input amplitudes are
// this project's normalized LUT values, not the public physical amplitudes.
SwitchNativeHapticsEncoder encoder;
constexpr uint8_t expected[8] = {0xa8, 0x89, 0xe3, 0x4d, 0x80, 0x00, 0x40, 0x52};
const auto output = encoder.encode(single({99, 106, 3371, 8933}, {64, 32, 4467, 0}), false, false);
expect_packet(output, expected, "public absolute vector/band-actuator isolation mismatch");
require(!output.raw && !output.quantized, "exact absolute vector was changed");
expect_packet(encoder.encode({}, false, false), kNeutral, "conventional zero was not exact neutral");
constexpr uint8_t safe_max[8] = {0x00, 0xc9, 0x40, 0x72, 0x00, 0xc9, 0x40, 0x72};
expect_packet(encoder.encode({255, 255}, false, true), safe_max,
"conventional maximum did not map to safe fixed carriers");
const auto clamped = encoder.encode(single({64, 64, 32767, 32767}, {64, 64, 32767, 32767}), false, true);
expect_packet(clamped, safe_max, "unsafe HD amplitude escaped wire code100 clamp");
require(clamped.quantized && !clamped.raw, "safety clamp was not observable");
}
void test_provenance_and_profile_gains() {
SwitchHapticsDecoder host;
SwitchNativeHapticsEncoder encoder;
const ControllerRumbleOutput conventional{33, 71};
require(!conventional.raw_valid && !conventional.raw_unmodified,
"conventional rumble acquired Nintendo wire provenance");
require(!host.decode(nullptr).raw_valid, "missing payload acquired raw provenance");
constexpr uint8_t max_left[8] = {0x00, 0xc9, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40};
const auto decoded = host.decode(max_left);
require(decoded.raw_valid && decoded.raw_unmodified && std::memcmp(decoded.raw, max_left, 8) == 0,
"Switch decode did not preserve original bytes and provenance");
ControllerProfile profile{};
profile.strong_rumble_scale = 255;
profile.weak_rumble_scale = 255;
const auto unity = controller_profile_scale_host_rumble(decoded, profile);
const auto raw = encoder.encode(unity, false, true);
expect_packet(raw, max_left, "unity did not preserve independently specified wire bytes");
require(raw.raw, "safe synchronized unity did not use raw path");
profile.strong_rumble_scale = 64;
profile.weak_rumble_scale = 128;
const auto scaled = controller_profile_scale_host_rumble(decoded, profile);
require(scaled.raw_valid && !scaled.raw_unmodified, "profile gains failed to revoke raw fast-path permission");
constexpr uint8_t intermediate[8] = {0x00, 0x89, 0x40, 0x52, 0x00, 0x01, 0x40, 0x40};
const auto intermediate_packets = encoder.encode(scaled, false, true);
expect_packet(intermediate_packets, intermediate, "intermediate band gains treated Q15 as wire amplitude");
require(!intermediate_packets.raw && intermediate_packets.quantized,
"intermediate Q15 rounding was not reported");
profile.strong_rumble_scale = 255;
profile.weak_rumble_scale = 255;
require(!controller_profile_scale_host_rumble(scaled, profile).raw_unmodified,
"later unity gain restored revoked raw provenance");
profile.strong_rumble_scale = 0;
profile.weak_rumble_scale = 0;
expect_packet(encoder.encode(controller_profile_scale_host_rumble(decoded, profile), false, true),
kNeutral, "zero gains did not produce exact silence");
}
ControllerRumbleOutput sequence(uint8_t count) {
auto output = single({65, 65, 2139, 2282});
auto& side = output.hd.actuators[0];
side.sample_count = count;
side.samples[1] = {65, 66, 2139, 2093};
side.samples[2] = {65, 66, 2093, 2093};
return output;
}
void test_compressed_goldens_and_resynchronization() {
// Independently hand-packed command indices from existing protocol forms:
// H=[+4/+1Hz, -4/+1Hz, hold], L=[+1/+1Hz, hold, -1/hold].
// These defend bit placement/order independently of decoder round trips;
// they do not claim physical-controller acceptance of compressed forms.
const uint8_t* goldens[3] = {kOne, kTwo, kThree};
for (uint8_t count = 1; count <= 3; ++count) {
SwitchNativeHapticsEncoder encoder;
encoder.encode(single({64, 64, 2093, 2093}), false, false);
const auto packets = encoder.encode(sequence(count), false, false);
expect_packet(packets, goldens[count - 1], "compressed one/two/three-step golden mismatch");
require(!packets.quantized, "representable ordered substeps were quantized");
}
SwitchHapticsDecoder host;
SwitchNativeHapticsEncoder encoder;
encoder.encode(host.decode(kSeed), false, true);
const auto commands = host.decode(kThree);
const auto forwarded = encoder.encode(commands, false, true);
expect_packet(forwarded, kThree, "synchronized compressed unity was not exact");
require(forwarded.raw, "safe synchronized compressed unity did not use raw");
const auto repeated = host.decode(kThree);
require(repeated.hd.actuators[0].sample_count == 1, "host repeat did not retain endpoint");
require(encoder.encode(repeated, false, true).raw, "same-word hold unexpectedly lost synchronization");
encoder.reset();
SwitchHapticsDecoder device;
const auto recovery = encoder.encode(commands, false, true);
require(!recovery.raw && recovery.count == 2 && !recovery.quantized,
"dropped history did not trigger exact baseline recovery");
const auto result = play(device, recovery);
require(result.hd.actuators[0].sample_count == 3, "recovery collapsed three substeps");
for (uint8_t step = 0; step < 3; ++step) expect_sample(result.hd.actuators[0].samples[step], sequence(3).hd.actuators[0].samples[step]);
// A stale raw envelope after changed profile/output must be compared with
// physical state, not trusted solely because its flags still say unity.
encoder.encode(single({64, 64, 4467, 0}), false, false);
require(!encoder.encode(repeated, false, true).raw, "raw reuse ignored changed physical state");
}
void test_absolute_plus_commands_and_selected_coordinate() {
SwitchNativeHapticsEncoder encoder;
SwitchHapticsDecoder host;
SwitchHapticsDecoder device;
play(device, encoder.encode(host.decode(kSeed), false, true));
// Type 4: H absolute code32/frequency70; L command20, then H24/L17.
constexpr uint8_t type4[8] = {0x8d, 0x38, 0x52, 0x90, 0x00, 0x01, 0x40, 0x40};
const auto desired = host.decode(type4);
const auto encoded = encoder.encode(desired, false, false);
require(encoded.count == 1, "representable mixed form needed extra packets");
const auto actual = play(device, encoded);
require(actual.hd.actuators[0].sample_count == 2, "mixed form lost a substep");
expect_sample(actual.hd.actuators[0].samples[0], {65, 70, 2139, 4096});
expect_sample(actual.hd.actuators[0].samples[1], {66, 70, 2332, 4096});
require(!encoded.quantized, "representable mixed absolute/relative form was quantized");
// Preserve non-absolute low amplitude index134 while updating only H freq.
constexpr uint8_t selected[8] = {0x07, 0x00, 0x00, 0x68, 0x00, 0x01, 0x40, 0x40};
const auto frequency = host.decode(selected);
const auto selected_packets = encoder.encode(frequency, false, false);
require(selected_packets.count == 1, "selected coordinate needed extra packets");
const auto selected_result = play(device, selected_packets);
expect_sample(selected_result.hd.actuators[0].samples[0], {66, 80, 2332, 4096});
}
void test_relative_only_amplitude_survives_prefixes() {
SwitchNativeHapticsEncoder encoder;
SwitchHapticsDecoder host;
SwitchHapticsDecoder device;
// Reach internal index2 through two distinct increment words. It is below
// the first nonzero absolute code (index15), yet is a legal relative state.
constexpr uint8_t first[8] = {0x00, 0x00, 0x50, 0x6b, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t second[8] = {0x00, 0x00, 0x50, 0x69, 0x00, 0x01, 0x40, 0x40};
play(device, encoder.encode(host.decode(first), false, true));
play(device, encoder.encode(host.decode(second), false, true));
auto input = single({80, 64, 134, 134});
input.hd.actuators[0].sample_count = 3;
input.hd.actuators[0].samples[1] = {81, 64, 137, 134};
input.hd.actuators[0].samples[2] = {82, 64, 140, 134};
const auto packets = encoder.encode(input, false, false);
constexpr uint8_t frequency_prefix[4] = {0x06, 0x00, 0x00, 0x68};
require(packets.count == 2 && !packets.quantized &&
std::memcmp(packets.bytes[0], frequency_prefix, 4) == 0,
"single-coordinate prefix rounded an existing relative-only amplitude");
const auto result = play(device, packets);
require(result.hd.actuators[0].sample_count == 3, "state prefix lost temporal slots");
for (uint8_t step = 0; step < 3; ++step)
expect_sample(result.hd.actuators[0].samples[step], input.hd.actuators[0].samples[step]);
// The right side now needs an absolute baseline; its partner's relative
// amplitude must not be rounded just to make both setup words absolute.
input.hd.actuators[0].samples[0] = {82, 64, 140, 134};
input.hd.actuators[0].samples[1] = {82, 64, 143, 134};
input.hd.actuators[0].samples[2] = {83, 64, 146, 134};
input.hd.actuators[1] = sequence(3).hd.actuators[0];
const auto partner_recovery = encoder.encode(input, false, false);
const auto partner_result = play(device, partner_recovery);
require(partner_recovery.count == 2 && !partner_recovery.quantized,
"partner recovery quantized an independently representable side");
for (uint8_t side = 0; side < 2; ++side)
for (uint8_t step = 0; step < 3; ++step)
expect_sample(partner_result.hd.actuators[side].samples[step], input.hd.actuators[side].samples[step]);
// Resync from established neutral must distinguish internal silent index1
// from absolute zero, or the representable first index2 step gets rounded.
encoder.reset();
device.reset();
input = single({64, 64, 134, 0});
input.hd.actuators[0].sample_count = 3;
input.hd.actuators[0].samples[1] = {65, 64, 137, 0};
input.hd.actuators[0].samples[2] = {66, 64, 140, 0};
const auto relative_recovery = encoder.encode(input, false, false);
const auto recovered = play(device, relative_recovery);
require(relative_recovery.count == 2 && !relative_recovery.quantized,
"relative-only predecessor was lost during recovery");
for (uint8_t step = 0; step < 3; ++step)
expect_sample(recovered.hd.actuators[0].samples[step], input.hd.actuators[0].samples[step]);
encoder.reset();
device.reset();
const auto collision = encoder.encode(single({66, 64, 134, 0}), false, false);
const auto collision_result = play(device, collision);
require(collision.count == 2 && !collision.quantized &&
std::memcmp(collision.bytes[0], collision.bytes[1], 4) != 0,
"same-word prefix collision suppressed a representable relative increment");
expect_sample(collision_result.hd.actuators[0].samples[0], {66, 64, 134, 0});
}
void test_mono_bands_ties_and_temporal_policy() {
SwitchNativeHapticsEncoder encoder;
auto input = single({32, 80, 4467, 2093}, {96, 100, 2093, 8933});
constexpr uint8_t mono[8] = {0x90, 0x89, 0x20, 0x52, 0x90, 0x89, 0x20, 0x52};
expect_packet(encoder.encode(input, true, true), mono, "mono did not independently select dominant bands");
input.hd.actuators[1].samples[0].low_amplitude_q15 = 4467;
expect_packet(encoder.encode(input, true, true), mono, "mono tie did not retain left band frequency");
// Dominance is evaluated BEFORE LUT rounding: 4468 > 4467 although both
// round to the same safe amplitude. The right low-band frequency must win.
input.hd.actuators[1].samples[0].low_amplitude_q15 = 4468;
constexpr uint8_t near_tie[8] = {0x90, 0x89, 0x60, 0x52, 0x90, 0x89, 0x60, 0x52};
expect_packet(encoder.encode(input, true, true), near_tie, "LUT rounding changed mono dominance");
encoder.reset();
SwitchHapticsDecoder device;
input = sequence(3);
input.hd.actuators[1].samples[0] = {65, 66, 0, 4467};
const auto packets = encoder.encode(input, true, false);
const auto result = play(device, packets);
require(packets.quantized && result.hd.actuators[0].sample_count == 3 &&
result.hd.actuators[1].sample_count == 3,
"unequal-side temporal quantization was hidden or dropped slots");
for (uint8_t step = 0; step < 3; ++step) {
const auto expected = SwitchHapticsSample{65, 66, sequence(3).hd.actuators[0].samples[step].low_amplitude_q15, 4467};
expect_sample(result.hd.actuators[0].samples[step], expected);
expect_sample(result.hd.actuators[1].samples[step], expected);
}
}
void test_unrepresentable_timeline_and_safety() {
SwitchNativeHapticsEncoder encoder;
SwitchHapticsDecoder device;
auto input = single({64, 64, 17867, 0});
auto& left = input.hd.actuators[0];
left.sample_count = 3;
left.samples[1] = {127, 1, 0, 17867};
left.samples[2] = {1, 127, 17867, 0};
const auto packets = encoder.encode(input, false, false);
const auto result = play(device, packets);
const auto& actual = result.hd.actuators[0];
require(packets.count == 2 && packets.quantized && actual.sample_count == 3,
"unrepresentable sequence did not expose bounded three-slot quantization");
require(actual.samples[0].low_amplitude_q15 == 17867 && actual.samples[0].high_amplitude_q15 == 0 &&
actual.samples[1].low_amplitude_q15 == 0 && actual.samples[1].high_amplitude_q15 > 0 &&
actual.samples[2].low_amplitude_q15 > 0 && actual.samples[2].high_amplitude_q15 == 0,
"quantization collapsed band transitions or introduced sound into a zero band");
SwitchHapticsDecoder host;
// Unsafe host command substitute240 briefly peaks then stops; checking only
// final endpoint would mistakenly authorize the unsafe raw packet.
constexpr uint8_t unsafe_steps[8] = {0x21, 0x84, 0x10, 0xc4, 0x00, 0x01, 0x40, 0x40};
const auto unsafe = encoder.encode(host.decode(unsafe_steps), false, true);
require(!unsafe.raw && unsafe.quantized, "unsafe intermediate raw amplitude passed through");
play(device, unsafe);
constexpr uint8_t reserved[8] = {0x01, 0x00, 0x00, 0x40, 0x00, 0x01, 0x40, 0x40};
require(!encoder.encode(host.decode(reserved), false, true).raw,
"reserved discriminator was passed through as a qualified native form");
}
} // namespace
int main() {
test_public_absolute_goldens();
test_provenance_and_profile_gains();
test_compressed_goldens_and_resynchronization();
test_absolute_plus_commands_and_selected_coordinate();
test_relative_only_amplitude_survives_prefixes();
test_mono_bands_ties_and_temporal_policy();
test_unrepresentable_timeline_and_safety();
return 0;
}

View file

@ -0,0 +1,31 @@
#pragma once
#include <stdint.h>
enum btstack_data_source_callback_type_t {
DATA_SOURCE_CALLBACK_POLL = 4,
};
struct btstack_data_source_t {
void (*handler)(btstack_data_source_t*, btstack_data_source_callback_type_t) = nullptr;
uint16_t callbacks = 0;
};
struct btstack_timer_source_t {
void (*handler)(btstack_timer_source_t*) = nullptr;
uint64_t due_us = 0;
};
void btstack_run_loop_set_data_source_handler(
btstack_data_source_t* source,
void (*handler)(btstack_data_source_t*, btstack_data_source_callback_type_t));
void btstack_run_loop_enable_data_source_callbacks(btstack_data_source_t* source,
uint16_t callbacks);
void btstack_run_loop_add_data_source(btstack_data_source_t* source);
void btstack_run_loop_poll_data_sources_from_irq();
void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer,
void (*handler)(btstack_timer_source_t*));
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms);
void btstack_run_loop_add_timer(btstack_timer_source_t* timer);
bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer);
uint8_t l2cap_request_can_send_now_event(uint16_t cid);

View file

@ -0,0 +1,17 @@
#pragma once
#include <uni.h>
#ifdef __cplusplus
extern "C" {
#endif
bool uni_hid_parser_switch_native_info(uni_hid_device_t* device, uint8_t* type,
uint8_t* firmware_hi, uint8_t* firmware_lo);
bool uni_hid_parser_switch_native_acquire(uni_hid_device_t* device);
bool uni_hid_parser_switch_native_send(uni_hid_device_t* device,
const uint8_t rumble[8]);
void uni_hid_parser_switch_native_release(uni_hid_device_t* device);
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,27 @@
#pragma once
#include <stdint.h>
struct uni_hid_device_s;
using uni_hid_device_t = uni_hid_device_s;
using uni_play_dual_rumble_t = void (*)(uni_hid_device_t*, uint16_t, uint16_t,
uint8_t, uint8_t);
struct uni_report_parser_t {
uni_play_dual_rumble_t play_dual_rumble = nullptr;
};
// Only the parser boundary is faked. Native sends either enter the byte sink
// synchronously or fail without retaining a packet; no hidden transmit queue.
struct uni_hid_device_s {
uni_report_parser_t report_parser{};
struct { uint16_t interrupt_cid = 0; } conn;
bool connected = true;
bool info_ready = true;
bool acquire_allowed = true;
bool native_owned = false;
uint8_t controller_type = 3;
unsigned acquisitions = 0;
unsigned releases = 0;
};

View file

@ -0,0 +1,712 @@
#include "input/switch_native_output.h"
#include <btstack.h>
#include <parser/uni_hid_parser_switch.h>
#include <uni.h>
#include <algorithm>
#include <array>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <initializer_list>
#include <iostream>
#include <map>
#include <vector>
namespace {
// These are rumble payloads at the parser's native-send boundary, not simulated
// HCI packets or evidence of physical playback. Only accepted sends advance the
// independent decoder representing the controller's received command history.
constexpr uint8_t kNeutral[8] = {0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kSeed[8] = {0x00, 0x21, 0x40, 0x48, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kThree[8] = {0x78, 0x77, 0x1c, 0xe9, 0x00, 0x01, 0x40, 0x40};
constexpr uint8_t kDifferent[8] = {0xa8, 0x89, 0xe3, 0x4d, 0x80, 0x00, 0x40, 0x52};
constexpr SwitchHapticsSample kSteps[3] = {
{65, 65, 2139, 2282}, {65, 66, 2139, 2093}, {65, 66, 2093, 2093},
};
constexpr uint32_t kGeneration = 7;
const char* scenario = "startup";
uint64_t now_us = 1000000;
bool poll_requested = false;
bool writable = true;
std::deque<bool> send_results;
std::vector<btstack_data_source_t*> sources;
std::vector<btstack_timer_source_t*> timers;
std::map<uint16_t, uni_hid_device_t*> radio_devices;
std::deque<uint16_t> permission_requests;
uint16_t next_cid = 0x40;
bool credit_event_only = false, in_credit_event = false;
struct WireFrame {
uni_hid_device_t* device;
uint64_t submitted_us;
std::array<uint8_t, 8> bytes;
ControllerRumbleOutput decoded;
};
struct CompatibilityCall {
uni_hid_device_t* device;
uint16_t delay_ms;
uint16_t duration_ms;
uint8_t weak;
uint8_t strong;
size_t wire_position;
uint64_t submitted_us;
};
std::vector<WireFrame> wire;
std::vector<CompatibilityCall> compatibility;
std::map<uni_hid_device_t*, SwitchHapticsDecoder> physical;
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << scenario << ": " << message << '\n';
std::exit(1);
}
}
// Run the actual registered data-source and timer callbacks. The IRQ wake is
// deliberately deferred: submitting on the producer never calls the owner.
void run_until(uint64_t target_us) {
require(target_us >= now_us, "fake clock moved backwards");
for (unsigned dispatches = 0; dispatches < 10000; ++dispatches) {
if (writable && !credit_event_only && !permission_requests.empty()) {
const auto cid = permission_requests.front();
permission_requests.pop_front();
switch_native_output_on_can_send_now(radio_devices[cid], cid);
continue;
}
if (poll_requested) {
poll_requested = false;
const auto ready = sources;
for (auto* source : ready) {
if (source->callbacks & DATA_SOURCE_CALLBACK_POLL) {
require(source->handler != nullptr, "data source lacks handler");
source->handler(source, DATA_SOURCE_CALLBACK_POLL);
}
}
continue;
}
const auto next = std::min_element(timers.begin(), timers.end(),
[](const auto* a, const auto* b) { return a->due_us < b->due_us; });
if (next == timers.end() || (*next)->due_us > target_us) {
now_us = target_us;
return;
}
auto* timer = *next;
timers.erase(next);
require(timer->due_us >= now_us && timer->handler != nullptr,
"invalid timer deadline or callback");
now_us = timer->due_us;
timer->handler(timer);
}
require(false, "runloop did not quiesce within bounded dispatches");
}
void flush() { run_until(now_us); }
void advance_ms(uint32_t milliseconds) { run_until(now_us + uint64_t{milliseconds} * 1000); }
void conventional(uni_hid_device_t* device, uint16_t delay_ms,
uint16_t duration_ms, uint8_t weak, uint8_t strong) {
require(device->connected && !device->native_owned,
"compatibility output ran before native ownership was released");
compatibility.push_back({device, delay_ms, duration_ms, weak, strong, wire.size(), now_us});
}
uni_hid_device_t device() {
uni_hid_device_t result{};
result.report_parser.play_dual_rumble = conventional;
return result;
}
ControllerIdentity identity(uint8_t address = 1, uint16_t product = 0x2009) {
ControllerIdentity result{};
result.stable = true;
result.transport = ControllerTransport::kClassic;
result.address[0] = 0x24;
result.address[1] = 0x68;
result.address[5] = address;
result.vendor_id = 0x057e;
result.product_id = product;
return result;
}
AdapterConfiguration persisted(std::initializer_list<ControllerIdentity> identities) {
auto configuration = adapter_configuration_default();
for (const auto& approved : identities) {
configuration.native_switch_controllers[configuration.native_switch_controller_count++] = approved;
}
std::array<uint8_t, ADAPTER_CONFIGURATION_ENCODED_SIZE> bytes{};
require(adapter_configuration_encode(configuration, bytes.data(), bytes.size()),
"could not persist approval fixture through real configuration codec");
AdapterConfiguration restored{};
require(adapter_configuration_decode(ADAPTER_CONFIGURATION_SCHEMA_VERSION,
bytes.data(), bytes.size(), &restored),
"could not restore persisted approval fixture");
return restored;
}
void attach_approved(uni_hid_device_t& target) {
switch_native_output_configure(persisted({identity()}), 1);
switch_native_output_attach(0, kGeneration, &target, identity());
require(switch_native_output_owns(&target), "persisted physical approval did not acquire owner");
}
SwitchNativeOutputDiagnostics diagnostics(uint8_t slot = 0) {
SwitchNativeOutputDiagnostics result{};
switch_native_output_snapshot(slot, &result);
return result;
}
void submit(const ControllerRumbleOutput& rumble, bool stateful = false,
uint8_t slot = 0, uint32_t generation = kGeneration) {
require(switch_native_output_submit(slot, generation, now_us, rumble, stateful),
"current approved host command was rejected");
}
ControllerRumbleOutput three_steps() {
SwitchHapticsDecoder host;
host.decode(kSeed);
return host.decode(kThree);
}
const WireFrame& last_frame(uni_hid_device_t& target) {
const auto found = std::find_if(wire.rbegin(), wire.rend(),
[&](const auto& frame) { return frame.device == &target; });
require(found != wire.rend(), "controller has no accepted output");
return *found;
}
size_t frame_count(uni_hid_device_t& target) {
return std::count_if(wire.begin(), wire.end(),
[&](const auto& frame) { return frame.device == &target; });
}
bool is_neutral(const WireFrame& frame) {
return std::memcmp(frame.bytes.data(), kNeutral, sizeof(kNeutral)) == 0;
}
void expect_bytes(const WireFrame& frame, const uint8_t expected[8], const char* message) {
require(std::memcmp(frame.bytes.data(), expected, 8) == 0, message);
}
void expect_sample(const SwitchHapticsSample& actual, const SwitchHapticsSample& expected) {
require(actual.low_frequency_index == expected.low_frequency_index &&
actual.high_frequency_index == expected.high_frequency_index &&
actual.low_amplitude_q15 == expected.low_amplitude_q15 &&
actual.high_amplitude_q15 == expected.high_amplitude_q15,
"accepted output has wrong actuator band amplitude or frequency");
}
void expect_state(uni_hid_device_t& target, SwitchHapticsSample left,
SwitchHapticsSample right = {}) {
const auto& frame = last_frame(target).decoded.hd;
require(frame.actuators[0].sample_count > 0 && frame.actuators[1].sample_count > 0,
"accepted payload has no decoded endpoint");
expect_sample(frame.actuators[0].samples[frame.actuators[0].sample_count - 1], left);
expect_sample(frame.actuators[1].samples[frame.actuators[1].sample_count - 1], right);
}
void expect_three_steps(uni_hid_device_t& target) {
const auto& left = last_frame(target).decoded.hd.actuators[0];
require(left.sample_count == 3, "bounded schedule collapsed ordered host substeps");
for (uint8_t i = 0; i < 3; ++i) expect_sample(left.samples[i], kSteps[i]);
expect_state(target, kSteps[2]);
}
void expect_silence_since(size_t first) {
for (size_t i = first; i < wire.size(); ++i) {
require(is_neutral(wire[i]), "stopped or disconnected host vibration reappeared");
}
}
void test_approval() {
auto target = device();
switch_native_output_configure(persisted({}), 1);
switch_native_output_attach(0, kGeneration, &target, identity());
require(!switch_native_output_owns(&target) && target.acquisitions == 0,
"Nintendo VID/PID automatically enabled native output");
require(!switch_native_output_submit(0, kGeneration, now_us, {255, 255}, true) &&
!switch_native_output_feedback(&target, 255, 255, 10),
"unapproved native host or feedback output was accepted");
flush();
require(wire.empty() && compatibility.empty(), "unapproved attach disturbed conventional output");
switch_native_output_configure(persisted({identity(2)}), 2);
require(!switch_native_output_owns(&target) && wire.empty(),
"approval leaked to another physical address of the same model");
switch_native_output_configure(persisted({identity()}), 3);
require(switch_native_output_owns(&target) && target.acquisitions == 1,
"persisted matching identity did not acquire native output");
require(is_neutral(last_frame(target)), "approval handoff did not establish physical neutral");
SwitchHapticsDecoder host;
const size_t before = wire.size();
submit(host.decode(kSeed));
require(wire.size() == before, "producer submission bypassed deferred runloop wake");
flush();
expect_bytes(last_frame(target), kSeed, "approved unity output changed a safe native payload");
}
void test_model_gate() {
auto mismatch = device();
mismatch.controller_type = 1;
switch_native_output_configure(persisted({identity()}), 1);
switch_native_output_attach(0, kGeneration, &mismatch, identity());
require(!switch_native_output_owns(&mismatch) && mismatch.acquisitions == 0 && wire.empty(),
"approved identity overrode mismatched parser controller type");
auto unavailable = device();
unavailable.info_ready = false;
switch_native_output_attach(0, kGeneration, &unavailable, identity());
require(!switch_native_output_owns(&unavailable) && wire.empty(),
"missing parser device-info enabled native output");
auto unstable = device();
auto transient = identity();
transient.stable = false;
switch_native_output_attach(0, kGeneration, &unstable, transient);
require(!switch_native_output_owns(&unstable) && wire.empty(),
"unstable identity inherited a persisted approval");
switch_native_output_detach(&unstable);
auto refused = device();
refused.acquire_allowed = false;
switch_native_output_attach(0, kGeneration, &refused, identity());
require(!switch_native_output_owns(&refused) &&
!switch_native_output_submit(0, kGeneration, now_us, {255, 0}, true) && wire.empty(),
"failed parser acquisition swallowed compatibility host commands");
}
void test_revocation(bool expired) {
auto target = device();
attach_approved(target);
SwitchHapticsDecoder host;
submit(host.decode(kSeed));
flush();
advance_ms(5);
const auto latest = host.decode(kDifferent);
const uint64_t receipt = now_us;
submit(latest); // Revoke before the queued replacement reaches the owner.
const size_t before = wire.size();
writable = false;
switch_native_output_configure(persisted({}), 2);
require(switch_native_output_owns(&target) && target.releases == 0 && compatibility.empty(),
"revocation released owner before congestion allowed neutral");
require(!switch_native_output_submit(0, kGeneration, now_us, {255, 255}, true),
"revoked identity continued accepting native host updates");
advance_ms(expired ? 55 : 10);
require(wire.size() == before && compatibility.empty(),
"blocked neutral leaked output or resumed compatibility early");
writable = true;
advance_ms(1);
require(!switch_native_output_owns(&target) && target.releases == 1 && compatibility.size() == 1,
"neutral completion did not release and resume conventional output");
const auto& resumed = compatibility.back();
require(resumed.device == &target && resumed.delay_ms == 0 &&
resumed.wire_position > before && is_neutral(wire[resumed.wire_position - 1]),
"compatibility did not follow the accepted neutral barrier");
if (expired) {
require(resumed.duration_ms == 0 && resumed.weak == 0 && resumed.strong == 0,
"revocation resurrected an expired host effect");
} else {
const uint16_t remaining = static_cast<uint16_t>((receipt + 50000 - resumed.submitted_us + 999) / 1000);
require(resumed.duration_ms == remaining &&
resumed.weak == latest.high_frequency_magnitude &&
resumed.strong == latest.low_frequency_magnitude,
"compatibility resumed stale magnitudes or restarted the 50ms host lifetime");
}
const size_t released = wire.size();
advance_ms(100);
require(wire.size() == released && compatibility.size() == 1,
"retired native timer wrote after compatibility resumed");
}
void test_generation() {
auto old = device();
auto other = device();
auto replacement = device();
switch_native_output_configure(persisted({identity(), identity(2)}), 1);
switch_native_output_attach(0, kGeneration, &old, identity());
switch_native_output_attach(1, 21, &other, identity(2));
submit({255, 0}, true);
flush();
submit(three_steps());
old.connected = false;
const size_t old_count = frame_count(old);
switch_native_output_detach(&old);
switch_native_output_attach(0, kGeneration + 1, &replacement, identity());
const size_t replacement_count = frame_count(replacement);
require(is_neutral(last_frame(replacement)), "reconnect inherited a previous physical baseline");
require(!switch_native_output_submit(0, kGeneration, now_us, {255, 255}, true),
"old generation was accepted after same-address reconnect");
submit({0, 255}, true, 1, 21);
advance_ms(60); // Includes the outstanding producer wake and old refresh/expiry deadlines.
require(frame_count(old) == old_count && frame_count(replacement) == replacement_count,
"old queued command or timer touched disconnected/replacement device");
expect_state(other, {}, {64, 64, 0, 17867});
submit({255, 0}, true, 0, kGeneration + 1);
flush();
expect_state(replacement, {64, 64, 17867, 0});
expect_state(other, {}, {64, 64, 0, 17867});
require(diagnostics(0).completed_commands == 1 && diagnostics(1).completed_commands == 1,
"slot or generation completion accounting crossed controllers");
}
void test_overflow() {
auto target = device();
attach_approved(target);
const auto before_diagnostics = diagnostics();
const size_t before = wire.size();
SwitchHapticsDecoder host;
for (unsigned i = 0; i < 39; ++i)
submit(host.decode((i & 1u) ? kDifferent : kSeed));
submit(host.decode(kThree));
require(wire.size() == before && diagnostics().queue_depth <= 16,
"producer bypassed bounded deferred command queue");
flush();
require(wire.size() > before && wire.size() <= before + 3 && is_neutral(wire[before]),
"queue loss replayed a backlog instead of neutral plus bounded newest schedule");
expect_three_steps(target);
const auto after = diagnostics();
require(after.received_commands == 40 && after.dropped_commands == 39 &&
after.completed_commands == 1 && after.queue_depth == 0 &&
after.resynchronizations > before_diagnostics.resynchronizations,
"queue pressure did not distinguish discarded commands from the completed newest state");
const size_t drained = wire.size();
advance_ms(12);
require(wire.size() == drained, "owner caught up obsolete queued vibrations after draining");
}
void test_retry() {
auto target = device();
attach_approved(target);
const size_t before = wire.size();
const auto before_diagnostics = diagnostics();
send_results = {false};
submit(three_steps());
flush();
require(wire.size() == before && diagnostics().completed_commands == 0,
"failed first schedule packet counted as physical output or completion");
send_results = {true, false};
advance_ms(1);
require(wire.size() == before + 1 && diagnostics().completed_commands == 0,
"baseline-only partial submission counted as complete host command");
advance_ms(1);
expect_three_steps(target);
const auto after = diagnostics();
require(wire.size() == before + 2 && after.completed_commands == 1 &&
after.dropped_commands == 0 && after.congested_attempts == 2 &&
after.resynchronizations == before_diagnostics.resynchronizations,
"short congestion retry duplicated baseline, dropped history, or miscounted completion");
}
void test_partial_replacement() {
auto target = device();
attach_approved(target);
send_results = {true, false};
submit(three_steps());
flush();
require(diagnostics().completed_commands == 0, "partial schedule was already complete");
const size_t before = wire.size();
SwitchHapticsDecoder latest_host;
submit(latest_host.decode(kDifferent));
flush();
require(wire.size() == before + 2 && is_neutral(wire[before]),
"replacing prepared/partially sent schedule omitted the physical reset barrier");
expect_bytes(last_frame(target), kDifferent, "old prepared tail replaced newest host output");
const auto after = diagnostics();
require(after.completed_commands == 1 && after.dropped_commands == 1,
"discarded partial command was reported as completed or vanished from loss accounting");
const size_t replaced = wire.size();
advance_ms(12);
require(wire.size() == replaced, "discarded prepared tail was retried after replacement");
}
void test_stalled_schedule(bool partial) {
auto target = device();
attach_approved(target);
writable = partial;
if (partial) send_results = {true, false};
const size_t before = wire.size();
submit(three_steps());
flush();
require(wire.size() == before + (partial ? 1 : 0), "incorrect initial blocked schedule setup");
writable = false;
advance_ms(20);
require(diagnostics().completed_commands == 0, "unsubmitted stalled schedule was counted complete");
const size_t stalled = wire.size();
writable = true;
advance_ms(1);
require(wire.size() > stalled, "current endpoint was not submitted after congestion");
if (partial) require(is_neutral(wire[stalled]),
"partially submitted expired timeline lacked physical resynchronization");
for (size_t i = stalled; i < wire.size(); ++i) {
if (is_neutral(wire[i])) continue;
const auto& left = wire[i].decoded.hd.actuators[0];
require(left.sample_count == 1, "congestion replayed host substeps whose time had passed");
expect_sample(left.samples[0], kSteps[2]);
}
expect_state(target, kSteps[2]);
require(diagnostics().completed_commands == 1,
"resynchronized latest endpoint did not finish its current command");
}
void test_feedback_resume() {
auto target = device();
attach_approved(target);
SwitchHapticsDecoder host;
submit(host.decode(kSeed));
flush();
const uint64_t receipt = now_us;
require(switch_native_output_feedback(&target, 0, 255, 6), "approved local feedback was rejected");
submit(host.decode(kThree));
flush();
expect_state(target, {}, {64, 64, 0, 17867});
advance_ms(5);
expect_state(target, {}, {64, 64, 0, 17867});
const size_t before_resume = wire.size();
advance_ms(1);
require(wire.size() > before_resume && is_neutral(wire[before_resume]),
"local-feedback handoff omitted host resynchronization");
require(last_frame(target).decoded.hd.actuators[0].sample_count == 1,
"feedback resumed host timeline from its beginning instead of current substep");
expect_state(target, kSteps[2]);
run_until(receipt + 49000);
expect_state(target, kSteps[2]);
run_until(receipt + 50000);
require(is_neutral(last_frame(target)), "Switch host effect survived its original 50ms deadline");
const size_t expired = wire.size();
advance_ms(100);
expect_silence_since(expired);
}
void test_feedback_outlives_host() {
auto target = device();
attach_approved(target);
require(switch_native_output_feedback(&target, 0, 255, 60), "local feedback was rejected");
const uint64_t receipt = now_us;
submit(three_steps());
flush();
run_until(receipt + 49000);
expect_state(target, {}, {64, 64, 0, 17867});
const size_t before_expiry = wire.size();
run_until(receipt + 50000);
require(wire.size() == before_expiry,
"expiry of suppressed host command interrupted active local feedback");
run_until(receipt + 59000);
expect_state(target, {}, {64, 64, 0, 17867});
run_until(receipt + 60000);
require(is_neutral(last_frame(target)), "feedback completion resurrected already expired host output");
const size_t finished = wire.size();
advance_ms(100);
expect_silence_since(finished);
}
void test_feedback_congestion() {
auto target = device();
attach_approved(target);
SwitchHapticsDecoder host;
submit(host.decode(kSeed));
flush();
send_results = {true, false}; // Neutral reaches the sink; prepared feedback does not.
require(switch_native_output_feedback(&target, 0, 255, 30), "local feedback was rejected");
require(is_neutral(last_frame(target)), "feedback congestion fixture did not accept its neutral barrier");
submit(host.decode(kThree));
flush();
advance_ms(1);
expect_state(target, {}, {64, 64, 0, 17867});
advance_ms(28);
expect_state(target, {}, {64, 64, 0, 17867});
advance_ms(1);
expect_state(target, kSteps[2]);
require(last_frame(target).decoded.hd.actuators[0].sample_count == 1,
"congested feedback restarted suppressed host substeps on resume");
}
void test_stateful() {
auto target = device();
attach_approved(target);
submit({255, 0}, true);
flush();
expect_state(target, {64, 64, 17867, 0});
advance_ms(120);
expect_state(target, {64, 64, 17867, 0});
require(diagnostics().completed_commands == 1,
"stateful refreshes were counted as additional host commands");
require(switch_native_output_feedback(&target, 0, 255, 5), "stateful overlay feedback was rejected");
expect_state(target, {}, {64, 64, 0, 17867});
advance_ms(5);
expect_state(target, {64, 64, 17867, 0});
advance_ms(200);
expect_state(target, {64, 64, 17867, 0});
const size_t before_zero = wire.size();
submit({}, true);
flush();
require(wire.size() > before_zero && is_neutral(wire[before_zero]),
"explicit XInput zero did not immediately stop physical output");
expect_silence_since(before_zero);
const size_t stopped = wire.size();
advance_ms(200);
require(wire.size() == stopped && diagnostics().completed_commands == 2,
"stateful zero kept refreshing or changed host completion accounting");
}
void test_credit_driven_delivery() {
auto target = device();
attach_approved(target);
credit_event_only = true;
SwitchHapticsDecoder host;
const auto before = wire.size();
submit(host.decode(kSeed));
flush();
advance_ms(5);
require(wire.size() == before, "output attempted without an available credit window");
require(!permission_requests.empty(), "native owner did not request credit notification");
const auto cid = permission_requests.front();
permission_requests.pop_front();
in_credit_event = true;
require(switch_native_output_on_can_send_now(&target, cid), "credit event was ignored");
in_credit_event = false;
expect_bytes(last_frame(target), kSeed, "credit window did not submit current native state");
}
void test_held_state_coalescing() {
auto target = device();
attach_approved(target);
SwitchHapticsDecoder host;
submit(host.decode(kSeed));
flush();
const auto started = wire.size();
for (unsigned i = 0; i < 10; ++i) {
advance_ms(8);
submit(host.decode(kSeed));
flush();
}
require(diagnostics().received_commands == 11 &&
diagnostics().completed_commands == 1 &&
diagnostics().coalesced_commands == 10 &&
diagnostics().dropped_commands == 0,
"held state was lost or incorrectly counted as new radio submissions");
require(wire.size() <= started + 2, "identical reports caused redundant radio traffic");
for (size_t i = started; i < wire.size(); ++i)
expect_bytes(wire[i], kSeed, "refresh changed a held native effect");
advance_ms(49);
require(!is_neutral(last_frame(target)), "coalescing failed to extend the host watchdog");
advance_ms(2);
require(is_neutral(last_frame(target)), "held-state watchdog did not expire");
}
void test_pending_hold_preserves_initial_latency() {
auto target = device();
attach_approved(target);
writable = false;
SwitchHapticsDecoder host;
const uint64_t first = now_us;
for (unsigned i = 0; i < 3; ++i) {
submit(host.decode(kSeed));
flush();
advance_ms(8);
}
writable = true;
advance_ms(1);
expect_bytes(last_frame(target), kSeed, "pending coalescence changed the current effect");
require(diagnostics().completed_commands == 1 &&
diagnostics().coalesced_commands == 2 &&
diagnostics().dropped_commands == 0 &&
diagnostics().max_latency_us == last_frame(target).submitted_us - first,
"coalescing hid the initial wait or counted redundant commands as loss");
}
} // namespace
uint64_t time_us_64() { return now_us; }
uint32_t time_us_32() { return static_cast<uint32_t>(now_us); }
void btstack_run_loop_set_data_source_handler(
btstack_data_source_t* source,
void (*handler)(btstack_data_source_t*, btstack_data_source_callback_type_t)) {
source->handler = handler;
}
void btstack_run_loop_enable_data_source_callbacks(btstack_data_source_t* source,
uint16_t callbacks) {
source->callbacks |= callbacks;
}
void btstack_run_loop_add_data_source(btstack_data_source_t* source) {
require(std::find(sources.begin(), sources.end(), source) == sources.end(),
"data source was registered twice");
sources.push_back(source);
}
void btstack_run_loop_poll_data_sources_from_irq() { poll_requested = true; }
void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer,
void (*handler)(btstack_timer_source_t*)) {
timer->handler = handler;
}
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms) {
// Pico runloop adds a tick; deadlines use its millisecond clock, not a busy
// callback loop at the current instant when the owner requests timeout zero.
timer->due_us = (now_us / 1000 + uint64_t{timeout_ms} + 1) * 1000;
}
void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
require(std::find(timers.begin(), timers.end(), timer) == timers.end(),
"timer added while already scheduled");
timers.push_back(timer);
}
bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer) {
const auto found = std::find(timers.begin(), timers.end(), timer);
if (found == timers.end()) return false;
timers.erase(found);
return true;
}
uint8_t l2cap_request_can_send_now_event(uint16_t cid) {
if (writable && !credit_event_only)
switch_native_output_on_can_send_now(radio_devices[cid], cid);
else if (std::find(permission_requests.begin(), permission_requests.end(), cid) ==
permission_requests.end())
permission_requests.push_back(cid);
return 0;
}
bool uni_hid_parser_switch_native_info(uni_hid_device_t* target, uint8_t* type,
uint8_t* firmware_hi, uint8_t* firmware_lo) {
if (!target || !target->info_ready) return false;
if (!target->conn.interrupt_cid) target->conn.interrupt_cid = next_cid++;
radio_devices[target->conn.interrupt_cid] = target;
if (type) *type = target->controller_type;
if (firmware_hi) *firmware_hi = 5;
if (firmware_lo) *firmware_lo = 1;
return true;
}
bool uni_hid_parser_switch_native_acquire(uni_hid_device_t* target) {
if (!target->connected || !target->info_ready || !target->acquire_allowed) return false;
require(!target->native_owned, "parser acquired twice without release");
target->native_owned = true;
++target->acquisitions;
return true;
}
bool uni_hid_parser_switch_native_send(uni_hid_device_t* target, const uint8_t rumble[8]) {
require(target && target->connected && target->native_owned,
"native send reached disconnected or unowned parser");
require(!credit_event_only || in_credit_event,
"native sender polled outside the notified credit window");
bool accepted = writable;
if (!send_results.empty()) {
accepted = send_results.front();
send_results.pop_front();
}
if (!accepted) return false;
WireFrame frame{target, now_us, {}, physical[target].decode(rumble)};
std::memcpy(frame.bytes.data(), rumble, frame.bytes.size());
wire.push_back(frame);
return true;
}
void uni_hid_parser_switch_native_release(uni_hid_device_t* target) {
require(target && target->connected && target->native_owned,
"parser released while disconnected or already unowned");
target->native_owned = false;
++target->releases;
}
int main(int argc, char** argv) {
require(argc == 2, "one regression scenario is required");
scenario = argv[1];
switch_native_output_prepare();
if (std::strcmp(scenario, "approval") == 0) test_approval();
else if (std::strcmp(scenario, "model-gate") == 0) test_model_gate();
else if (std::strcmp(scenario, "revocation") == 0) test_revocation(false);
else if (std::strcmp(scenario, "revocation-expired") == 0) test_revocation(true);
else if (std::strcmp(scenario, "generation") == 0) test_generation();
else if (std::strcmp(scenario, "overflow") == 0) test_overflow();
else if (std::strcmp(scenario, "retry") == 0) test_retry();
else if (std::strcmp(scenario, "partial-replacement") == 0) test_partial_replacement();
else if (std::strcmp(scenario, "stalled") == 0) test_stalled_schedule(false);
else if (std::strcmp(scenario, "stalled-partial") == 0) test_stalled_schedule(true);
else if (std::strcmp(scenario, "feedback-resume") == 0) test_feedback_resume();
else if (std::strcmp(scenario, "feedback-outlives-host") == 0) test_feedback_outlives_host();
else if (std::strcmp(scenario, "feedback-congestion") == 0) test_feedback_congestion();
else if (std::strcmp(scenario, "stateful") == 0) test_stateful();
else if (std::strcmp(scenario, "credit-driven") == 0) test_credit_driven_delivery();
else if (std::strcmp(scenario, "held-state") == 0) test_held_state_coalescing();
else if (std::strcmp(scenario, "pending-hold") == 0) test_pending_hold_preserves_initial_latency();
else require(false, "unknown regression scenario");
return 0;
}

View file

@ -0,0 +1,40 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
typedef uint8_t bd_addr_t[6];
typedef uint16_t hci_con_handle_t;
typedef enum { GAP_CONNECTION_INVALID, GAP_CONNECTION_ACL, GAP_CONNECTION_SCO, GAP_CONNECTION_LE } gap_connection_type_t;
// Scheduling metadata lives in the test scheduler, not in parser memory.
// Keeping only the callback/context also avoids host pointer inflation of the
// firmware's fixed 256-byte parser allocation. Production ABI is built by CI.
typedef struct btstack_timer_source {
void (*process)(struct btstack_timer_source* timer);
void* context;
} btstack_timer_source_t;
#define ERROR_CODE_SUCCESS 0
#define ERROR_CODE_COMMAND_DISALLOWED 0x0c
#define BTSTACK_ACL_BUFFERS_FULL 0x57
#define HID_MESSAGE_TYPE_DATA 0x0a
#define HID_REPORT_TYPE_OUTPUT 0x02
#define btstack_min(a, b) ((a) < (b) ? (a) : (b))
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t ms);
void btstack_run_loop_add_timer(btstack_timer_source_t* timer);
bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer);
void btstack_run_loop_set_timer_context(btstack_timer_source_t* timer, void* context);
void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*));
void* btstack_run_loop_get_timer_context(btstack_timer_source_t* timer);
int l2cap_send(uint16_t cid, uint8_t* data, uint16_t len);
int l2cap_can_send_packet_now(uint16_t cid);
uint8_t l2cap_request_can_send_now_event(uint16_t cid);
gap_connection_type_t gap_get_connection_type(hci_con_handle_t handle);
void printf_hexdump(const void* data, int len);
const char* bd_addr_to_str(const bd_addr_t addr);
static inline int bd_addr_cmp(const bd_addr_t a, const bd_addr_t b) { return memcmp(a, b, 6); }
static inline void bd_addr_copy(bd_addr_t dst, const bd_addr_t src) { memcpy(dst, src, 6); }

View file

@ -0,0 +1,378 @@
#include <assert.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "bt/uni_bt_service.h"
#include "parser/uni_hid_parser_switch.h"
#include "platform/uni_platform.h"
#include "uni_hid_device.h"
// Link the actual parser, generic send queue, connection and circular buffer.
// Only radio, platform notifications and run-loop scheduling are substituted.
static const uint8_t neutral[8] = {0, 1, 0x40, 0x40, 0, 1, 0x40, 0x40};
static const uint8_t first_word[8] = {0, 0x81, 0x40, 0x60, 0, 1, 0x40, 0x40};
// Three compressed substeps, distinct from the absolute baseline above.
static const uint8_t compressed[8] = {0x18, 0x63, 0x8c, 0xf1, 0, 1, 0x40, 0x40};
static bool credit = true;
static bool fail_submission;
static unsigned sent_count;
static unsigned requests;
static struct { uint16_t cid, len; uint8_t bytes[128]; } sent[256];
static uint32_t now_ms;
static struct { btstack_timer_source_t* timer; uint32_t deadline; bool active; } timers[32];
static unsigned timer_index(btstack_timer_source_t* timer) {
for (unsigned i = 0; i < 32; ++i) {
if (timers[i].timer == timer) return i;
if (!timers[i].timer) { timers[i].timer = timer; return i; }
}
assert(!"timer capacity exceeded");
return 0;
}
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t ms) {
timers[timer_index(timer)].deadline = now_ms + ms;
}
void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
timers[timer_index(timer)].active = true;
}
bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer) {
for (unsigned i = 0; i < 32; ++i) {
if (timers[i].timer == timer) {
bool active = timers[i].active;
timers[i].active = false;
return active;
}
}
return false;
}
void btstack_run_loop_set_timer_context(btstack_timer_source_t* timer, void* context) { timer->context = context; }
void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)) { timer->process = handler; }
void* btstack_run_loop_get_timer_context(btstack_timer_source_t* timer) { return timer->context; }
static void advance(uint32_t ms) {
const uint32_t end = now_ms + ms;
for (unsigned callbacks = 0; callbacks < 256; ++callbacks) {
unsigned next = 32;
for (unsigned i = 0; i < 32; ++i)
if (timers[i].active && timers[i].deadline <= end &&
(next == 32 || timers[i].deadline < timers[next].deadline)) next = i;
if (next == 32) { now_ms = end; return; }
now_ms = timers[next].deadline;
timers[next].active = false;
timers[next].timer->process(timers[next].timer);
}
assert(!"unbounded timer callback loop");
}
int l2cap_can_send_packet_now(uint16_t cid) { (void)cid; return credit; }
int l2cap_send(uint16_t cid, uint8_t* data, uint16_t len) {
if (!credit || fail_submission) return BTSTACK_ACL_BUFFERS_FULL;
assert(sent_count < 256 && len <= 128);
sent[sent_count].cid = cid;
sent[sent_count].len = len;
memcpy(sent[sent_count++].bytes, data, len);
return ERROR_CODE_SUCCESS;
}
uint8_t l2cap_request_can_send_now_event(uint16_t cid) { (void)cid; ++requests; return 0; }
gap_connection_type_t gap_get_connection_type(hci_con_handle_t handle) { (void)handle; return GAP_CONNECTION_ACL; }
void printf_hexdump(const void* data, int len) { (void)data; (void)len; }
const char* bd_addr_to_str(const bd_addr_t addr) { (void)addr; return "native-test"; }
void uni_log(const char* fmt, ...) { (void)fmt; }
void uni_bt_bredr_disconnect(uni_hid_device_t* d) { (void)d; }
void uni_bt_le_disconnect(uni_hid_device_t* d) { (void)d; }
void uni_bt_service_on_device_ready(const uni_hid_device_t* d) { (void)d; }
void uni_bt_service_on_device_connected(const uni_hid_device_t* d) { (void)d; }
void uni_bt_service_on_device_disconnected(const uni_hid_device_t* d) { (void)d; }
uint8_t uni_hid_parser_hat_to_dpad(uint8_t hat) { (void)hat; return 0; }
static uni_error_t ready(uni_hid_device_t* d) { (void)d; return UNI_ERROR_SUCCESS; }
static void connected(uni_hid_device_t* d) { (void)d; }
static struct uni_platform platform = {
.on_device_ready = ready,
.on_device_connected = connected,
.on_device_disconnected = connected,
};
struct uni_platform* uni_get_platform(void) { return &platform; }
static void reset(void) {
memset(timers, 0, sizeof(timers));
sent_count = requests = now_ms = 0;
credit = true;
fail_submission = false;
}
static void reply(uni_hid_device_t* d, uint8_t cmd, uint8_t type, uint8_t ack, uint16_t len) {
uint8_t report[49] = {0x21};
report[13] = ack;
report[14] = cmd;
if (cmd == 2) {
report[15] = 5;
report[16] = 7;
report[17] = type;
}
// Calibration replies intentionally have zero length, leaving the parser's
// normal fallback calibration intact; these tests exercise only output.
uni_hid_parser_switch_parse_input_report(d, report, len);
}
static void begin_device(uni_hid_device_t* d, uint16_t cid) {
uni_hid_device_init(d);
d->conn.connected = true;
d->conn.interrupt_cid = cid;
d->conn.handle = cid;
d->report_parser.setup = uni_hid_parser_switch_setup;
uni_hid_parser_switch_setup(d);
}
static void finish_device(uni_hid_device_t* d, uint8_t type, uint8_t ack, uint16_t info_len) {
reply(d, 2, type, ack, info_len);
for (unsigned step = 0; step < 10 && d->conn.state != UNI_BT_CONN_STATE_DEVICE_READY; ++step) {
assert(sent_count && sent[sent_count - 1].len >= 12);
reply(d, sent[sent_count - 1].bytes[11], type, 0x80, 49);
}
assert(d->conn.state == UNI_BT_CONN_STATE_DEVICE_READY);
}
static void init_device(uni_hid_device_t* d, uint16_t cid) {
begin_device(d, cid);
finish_device(d, 3, 0x80, 18);
}
static void expect_rumble(unsigned index, uint16_t cid, const uint8_t word[8]) {
assert(index < sent_count && sent[index].cid == cid);
assert(sent[index].len == 11);
assert(sent[index].bytes[0] == 0xa2 && sent[index].bytes[1] == 0x10);
assert(memcmp(&sent[index].bytes[3], word, 8) == 0);
}
static void expect_led(unsigned index, uint8_t leds, const uint8_t word[8]) {
assert(index < sent_count && sent[index].len == 13);
assert(sent[index].bytes[0] == 0xa2 && sent[index].bytes[1] == 1);
assert(sent[index].bytes[11] == 0x30 && sent[index].bytes[12] == leds);
assert(memcmp(&sent[index].bytes[3], word, 8) == 0);
}
static void identity_and_per_device_counter(void) {
reset();
uni_hid_device_t a, b;
init_device(&a, 0x40);
unsigned b_first = sent_count;
init_device(&b, 0x41);
assert(sent[0].bytes[2] == 0 && sent[b_first].bytes[2] == 0);
uint8_t type = 0, hi = 0, lo = 0;
assert(uni_hid_parser_switch_native_info(&a, &type, &hi, &lo));
assert(type == 3 && hi == 5 && lo == 7);
assert(uni_hid_parser_switch_native_acquire(&a));
assert(uni_hid_parser_switch_native_acquire(&b));
for (unsigned i = 0; i < 20; ++i) {
unsigned index = sent_count;
assert(uni_hid_parser_switch_native_send(&a, first_word));
expect_rumble(index, 0x40, first_word);
if (i == 3) uni_hid_parser_switch_set_player_leds(&a, 4);
assert(uni_hid_parser_switch_native_send(&b, neutral));
}
unsigned led_index = sent_count;
uni_hid_parser_switch_set_player_leds(&a, 2);
uni_hid_parser_switch_set_player_leds(&b, 8);
expect_led(led_index, 2, first_word);
expect_led(led_index + 1, 8, neutral);
unsigned expected[2] = {0, 0};
for (unsigned i = 0; i < sent_count; ++i) {
unsigned device = sent[i].cid - 0x40;
assert(device < 2 && sent[i].bytes[2] == (expected[device]++ & 15));
}
uni_hid_device_disconnect(&a);
assert(!uni_hid_parser_switch_native_info(&a, NULL, NULL, NULL));
assert(!uni_hid_parser_switch_native_send(&a, neutral));
}
static void congestion_and_queued_leds(void) {
reset();
uni_hid_device_t d;
init_device(&d, 0x40);
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, first_word));
unsigned baseline = sent_count;
credit = false;
assert(!uni_hid_parser_switch_native_send(&d, compressed));
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
fail_submission = true;
assert(!uni_hid_parser_switch_native_send(&d, compressed));
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
fail_submission = false;
uni_hid_parser_switch_set_player_leds(&d, 1);
expect_led(baseline, 1, first_word);
assert(sent[baseline].bytes[2] == ((sent[baseline - 1].bytes[2] + 1) & 15));
credit = false;
uni_hid_parser_switch_set_player_leds(&d, 2);
uni_hid_parser_switch_set_player_leds(&d, 8);
assert(!uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
assert(uni_hid_parser_switch_native_send(&d, compressed));
baseline = sent_count;
uni_hid_device_send_queued_reports(&d);
uni_hid_device_send_queued_reports(&d);
expect_led(baseline, 8, compressed);
expect_led(baseline + 1, 8, compressed);
assert(sent[baseline].bytes[2] == ((sent[baseline - 1].bytes[2] + 1) & 15));
assert(sent[baseline + 1].bytes[2] == ((sent[baseline].bytes[2] + 1) & 15));
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
}
static void compatibility_and_ownership_timers(void) {
reset();
uni_hid_device_t d;
init_device(&d, 0x40);
unsigned baseline = sent_count;
uni_hid_parser_switch_play_dual_rumble(&d, 0, 125, 160, 200);
uint8_t conventional[8];
memcpy(conventional, &sent[baseline].bytes[3], 8);
advance(39);
assert(sent_count == baseline + 1);
advance(1);
expect_rumble(baseline + 1, 0x40, conventional);
advance(40);
expect_rumble(baseline + 2, 0x40, conventional);
// Acquire retires both refresh and duration; they cannot stop native audio.
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, first_word));
baseline = sent_count;
uni_hid_parser_switch_play_dual_rumble(&d, 0, 1, 255, 255);
uni_hid_parser_switch_play_dual_rumble(&d, 1, 1, 255, 255);
uni_hid_parser_switch_play_dual_rumble(&d, 0, 0, 0, 0);
uint8_t competing[11] = {0xa2, 0x10, 0};
memcpy(&competing[3], neutral, 8);
uni_hid_device_send_intr_report(&d, competing, sizeof(competing));
credit = false;
uni_hid_device_send_intr_report(&d, competing, sizeof(competing));
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
advance(500);
assert(sent_count == baseline);
uni_hid_parser_switch_native_release(&d);
expect_rumble(baseline, 0x40, neutral);
assert(!uni_hid_parser_switch_native_send(&d, first_word));
uni_hid_parser_switch_play_dual_rumble(&d, 100, 100, 33, 44);
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, compressed));
baseline = sent_count;
advance(300);
assert(sent_count == baseline);
uni_hid_parser_switch_native_release(&d);
uni_hid_parser_switch_play_dual_rumble(&d, 0, 20, 33, 44);
baseline = sent_count;
advance(20);
expect_rumble(baseline, 0x40, neutral);
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, first_word));
credit = false;
uni_hid_parser_switch_native_release(&d);
assert(!uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
baseline = sent_count;
uni_hid_device_send_queued_reports(&d);
expect_rumble(baseline, 0x40, neutral);
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, first_word));
credit = false;
uni_hid_parser_switch_native_release(&d);
// Reacquiring must retire the delayed neutral, not stop the new owner.
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
assert(uni_hid_parser_switch_native_send(&d, compressed));
assert(uni_hid_parser_switch_native_send(&d, neutral));
baseline = sent_count;
credit = false;
uni_hid_parser_switch_native_release(&d);
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
credit = true;
uni_hid_parser_switch_play_dual_rumble(&d, 0, 100, 33, 44);
assert(sent_count == baseline + 1);
}
static void queue_retirement_disconnect_and_reuse(void) {
reset();
uni_hid_device_t d;
init_device(&d, 0x40);
// Walk the ring near its end before interleaving stale rumble and LEDs.
for (unsigned i = 0; i < 30; ++i) {
credit = false;
uni_hid_parser_switch_set_player_leds(&d, 1);
credit = true;
uni_hid_device_send_queued_reports(&d);
}
credit = false;
uni_hid_parser_switch_play_dual_rumble(&d, 0, 200, 200, 200);
uni_hid_parser_switch_set_player_leds(&d, 2);
uni_hid_parser_switch_play_dual_rumble(&d, 0, 0, 0, 0);
uni_hid_parser_switch_set_player_leds(&d, 4);
assert(uni_hid_parser_switch_native_acquire(&d));
credit = true;
assert(uni_hid_parser_switch_native_send(&d, compressed));
unsigned baseline = sent_count;
uni_hid_device_send_queued_reports(&d);
uni_hid_device_send_queued_reports(&d);
assert(uni_circular_buffer_is_empty(&d.outgoing_buffer));
expect_led(baseline, 4, compressed);
expect_led(baseline + 1, 4, compressed);
uni_hid_parser_switch_native_release(&d);
uni_hid_parser_switch_play_dual_rumble(&d, 100, 100, 200, 200);
btstack_timer_source_t stale[32];
unsigned stale_count = 0;
for (unsigned i = 0; i < 32; ++i)
if (timers[i].active) stale[stale_count++] = *timers[i].timer;
uni_hid_device_disconnect(&d);
uni_hid_device_delete(&d);
init_device(&d, 0x42);
assert(uni_hid_parser_switch_native_acquire(&d));
assert(uni_hid_parser_switch_native_send(&d, first_word));
baseline = sent_count;
for (unsigned i = 0; i < stale_count; ++i) stale[i].process(&stale[i]);
advance(300);
assert(sent_count == baseline);
// The other timers also retire on delete even without a preceding disconnect.
uni_hid_parser_switch_native_release(&d);
uni_hid_parser_switch_play_dual_rumble(&d, 0, 100, 30, 40);
uni_hid_device_delete(&d);
baseline = sent_count;
advance(200);
assert(sent_count == baseline);
}
static void identity_requires_real_reply(void) {
reset();
uni_hid_device_t d;
begin_device(&d, 0x40);
assert(!uni_hid_parser_switch_native_info(&d, NULL, NULL, NULL));
finish_device(&d, 3, 0x80, 17); // Truncated firmware/type tuple.
assert(!uni_hid_parser_switch_native_acquire(&d));
uni_hid_device_delete(&d);
begin_device(&d, 0x40);
finish_device(&d, 3, 0, 18); // Negative acknowledgement is not evidence.
assert(!uni_hid_parser_switch_native_info(&d, NULL, NULL, NULL));
uni_hid_device_delete(&d);
begin_device(&d, 0x40);
finish_device(&d, 0x0b, 0x80, 18);
uint8_t type;
assert(uni_hid_parser_switch_native_info(&d, &type, NULL, NULL) && type == 0x0b);
assert(!uni_hid_parser_switch_native_acquire(&d));
uni_hid_device_delete(&d);
for (uint8_t original_type = 1; original_type <= 2; ++original_type) {
begin_device(&d, 0x40);
finish_device(&d, original_type, 0x80, 18);
assert(uni_hid_parser_switch_native_info(&d, &type, NULL, NULL));
assert(type == original_type && uni_hid_parser_switch_native_acquire(&d));
uni_hid_device_delete(&d);
}
assert(!uni_hid_parser_switch_native_acquire(NULL));
}
int main(void) {
identity_and_per_device_counter();
congestion_and_queued_leds();
compatibility_and_ownership_timers();
queue_retirement_disconnect_and_reuse();
identity_requires_real_reply();
puts("Switch parser native wire/queue/LED/ownership/timer checks passed");
return 0;
}

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import json
import struct
import zlib
from dataclasses import replace
from pathlib import Path
import pytest
@ -45,7 +46,11 @@ class FakeDevice:
self.configuration = struct.pack(
"<HB5x", 60, config_manager.REQUESTED_MODE_AUTO
)
self.configuration_schema = 2
self.configuration_generation = 3
self.native_rumble_diagnostics = b"".join(
struct.pack("<4B19I", slot, 0, 0, 0, *([0] * 19)) for slot in range(4)
)
self.active_mode = config_manager.ACTIVE_MODE_SWITCH_PROBE
self.capabilities = (
config_manager.CAPABILITY_INPUT
@ -306,11 +311,17 @@ class FakeDevice:
request,
struct.pack("<7I4B", 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1),
)
if request == config_manager.OP_NATIVE_SWITCH_RUMBLE:
return make_response(
request,
self.native_rumble_diagnostics,
schema=config_manager.NATIVE_SWITCH_RUMBLE_SCHEMA_VERSION,
)
if request == config_manager.OP_CONFIGURATION_READ:
return make_response(
request,
self.configuration,
schema=config_manager.CONFIGURATION_SCHEMA_VERSION,
schema=self.configuration_schema,
generation=self.configuration_generation,
)
if request == config_manager.OP_TRANSACTION_STATUS:
@ -327,13 +338,10 @@ class FakeDevice:
else config_manager.STATUS_OK
)
if self.transaction_status == config_manager.STATUS_OK:
pairing_window = struct.unpack_from(
"<H", self.configuration
)[0]
self.configuration = struct.pack(
"<HB5x",
pairing_window,
self.pending_requested_mode,
self.configuration = (
self.configuration[:2]
+ bytes((self.pending_requested_mode,))
+ self.configuration[3:]
)
self.configuration_generation += 1
self.pending_requested_mode = None
@ -419,7 +427,7 @@ class FakeDevice:
if request == config_manager.OP_CONFIGURATION_BEGIN:
(
self.transaction_id,
_schema,
self.configuration_schema,
self.transaction_expected_size,
self.transaction_expected_crc,
) = struct.unpack("<IHHI", payload)
@ -446,6 +454,8 @@ class FakeDevice:
self.configuration = struct.pack(
"<HB5x", 60, config_manager.REQUESTED_MODE_AUTO
)
if self.configuration_schema == config_manager.CONFIGURATION_SCHEMA_VERSION:
self.configuration += bytes(config_manager.CONFIGURATION_SIZE - 8)
self.configuration_generation += 1
self.transaction_payload = bytearray(self.configuration)
self.transaction_expected_size = len(self.configuration)
@ -685,6 +695,284 @@ def test_response_validation() -> None:
config_manager.parse_response(response, config_manager.OP_INFO)
def native_rumble_identity(
address: bytes = bytes.fromhex("102030405060"), product_id: int = 0x2009
) -> config_manager.ControllerIdentity:
return config_manager.ControllerIdentity(
True, config_manager.TRANSPORT_CLASSIC, 0, address, 0x057E, product_id
)
def native_rumble_configuration(
identities: tuple[config_manager.ControllerIdentity, ...] = (),
) -> bytes:
return (
struct.pack("<HBB4x", 90, config_manager.REQUESTED_MODE_XINPUT, len(identities))
+ b"".join(identity.to_bytes() for identity in identities)
+ bytes((16 - len(identities)) * 14)
)
@pytest.mark.parametrize(
("schema", "payload", "mode"),
(
(1, struct.pack("<H2x", 75), config_manager.REQUESTED_MODE_AUTO),
(2, struct.pack("<HB5x", 75, 3), config_manager.REQUESTED_MODE_DINPUT),
),
)
def test_legacy_configuration_has_no_native_rumble_approval(
schema: int, payload: bytes, mode: int
) -> None:
device = FakeDevice()
device.configuration_schema = schema
device.configuration = payload
configuration = config_manager.read_configuration(device)
assert configuration.pairing_window_seconds == 75
assert configuration.requested_mode == mode
assert configuration.native_switch_controllers == ()
with pytest.raises(config_manager.ConfigManagerError):
config_manager.set_native_switch_rumble_approval(
device, native_rumble_identity(), True, 1.0
)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.write_configuration(
device,
replace(
configuration, native_switch_controllers=(native_rumble_identity(),)
),
1.0,
)
assert not device.out_requests
def test_native_rumble_configuration_canonical_wire_round_trip() -> None:
device = FakeDevice()
identities = tuple(
native_rumble_identity(
bytes((index, 2, 3, 4, 5, 6)), (0x2009, 0x2006, 0x2007)[index % 3]
)
for index in range(16)
)
config_manager.write_configuration(
device,
config_manager.AdapterConfiguration(
90, 0, 0, config_manager.REQUESTED_MODE_XINPUT, tuple(reversed(identities))
),
1.0,
)
assert device.configuration_schema == 3
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
@pytest.mark.parametrize(
("offset", "value"),
(
(3, 17), # Capacity overflow.
(4, 1), # Header reserved byte.
(8, 0), # Unstable non-global identity.
(9, config_manager.TRANSPORT_BLE),
(11, 1), # Identity reserved byte.
(18, 0), # Different vendor.
(20, 0), # Unqualified product.
(22, 1), # Unused identity slot.
),
)
def test_native_rumble_configuration_rejects_malformed_approvals(
offset: int, value: int
) -> None:
device = FakeDevice()
device.configuration_schema = 3
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("malformation", ("duplicate", "unsorted", "global", "short"))
def test_native_rumble_configuration_rejects_invalid_lists(malformation: str) -> None:
device = FakeDevice()
device.configuration_schema = 3
first = native_rumble_identity()
second = native_rumble_identity(bytes.fromhex("A1A2A3A4A5A6"))
identities = {
"duplicate": (first, first),
"unsorted": (second, first),
"global": (config_manager.ControllerIdentity.global_fallback(),),
"short": (first,),
}[malformation]
device.configuration = native_rumble_configuration(identities)
if malformation == "short":
device.configuration = device.configuration[:-1]
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_configuration(device)
@pytest.mark.parametrize(
"malformation", ("duplicate", "overflow", "global", "ble", "vendor", "product")
)
def test_native_rumble_write_rejects_invalid_approvals_before_transaction(
malformation: str,
) -> None:
device = FakeDevice()
identity = native_rumble_identity()
identities = {
"duplicate": (identity, identity),
"overflow": tuple(
native_rumble_identity(bytes((index, 2, 3, 4, 5, 6))) for index in range(17)
),
"global": (config_manager.ControllerIdentity.global_fallback(),),
"ble": (replace(identity, transport=config_manager.TRANSPORT_BLE),),
"vendor": (replace(identity, vendor_id=0x045E),),
"product": (replace(identity, product_id=0x2019),),
}[malformation]
with pytest.raises(config_manager.ConfigManagerError):
config_manager.write_configuration(
device,
config_manager.AdapterConfiguration(
90, 0, 0, native_switch_controllers=identities
),
1.0,
)
assert not device.out_requests
def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
device = FakeDevice()
device.configuration_schema = 3
device.configuration = native_rumble_configuration()
first = native_rumble_identity()
second = native_rumble_identity(bytes.fromhex("A1A2A3A4A5A6"))
for identity in (first, second):
device.profile_identities.append(identity)
device.active_profiles[identity.to_bytes()] = 3
previous_profiles = dict(device.profiles)
previous_active = dict(device.active_profiles)
previous_pairings = list(device.records)
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
assert config_manager.main(["config", "native-rumble", "list"]) == 0
assert first.address_text in capsys.readouterr().out
assert config_manager.read_configuration(device).native_switch_controllers == ()
assert (
config_manager.main(["config", "native-rumble", "approve", "--identity", "2"])
== 2
)
assert not device.out_requests
capsys.readouterr()
assert (
config_manager.main(
["config", "native-rumble", "approve", "--identity", "2", "--yes"]
)
== 0
)
capsys.readouterr()
approved = config_manager.read_configuration(device)
assert approved.native_switch_controllers == (first,)
assert approved.pairing_window_seconds == 90
assert approved.requested_mode == config_manager.REQUESTED_MODE_XINPUT
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
)
preserved = config_manager.read_configuration(device)
assert preserved.pairing_window_seconds == 120
assert preserved.requested_mode == config_manager.REQUESTED_MODE_XINPUT
assert preserved.native_switch_controllers == (first,)
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 (
config_manager.main(["config", "native-rumble", "revoke", "--identity", "2"])
== 0
)
revoked = config_manager.read_configuration(device)
assert revoked.native_switch_controllers == ()
assert revoked.requested_mode == config_manager.REQUESTED_MODE_DINPUT
assert revoked.pairing_window_seconds == 120
@pytest.mark.parametrize("identity_index", ("0", "1", "99"))
def test_native_rumble_cli_rejects_unqualified_or_missing_identity(
monkeypatch: pytest.MonkeyPatch, identity_index: str
) -> None:
device = FakeDevice()
device.configuration_schema = 3
device.configuration = native_rumble_configuration()
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
assert (
config_manager.main(
[
"config",
"native-rumble",
"approve",
"--identity",
identity_index,
"--yes",
]
)
== 1
)
assert not device.out_requests
def test_native_rumble_cli_can_revoke_forgotten_identity(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
first = native_rumble_identity()
second = native_rumble_identity(bytes.fromhex("A1A2A3A4A5A6"))
device.configuration_schema = 3
device.configuration = native_rumble_configuration((first, second))
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
assert (
config_manager.main(["config", "native-rumble", "revoke", "--approval", "2"])
== 1
)
assert not device.out_requests
assert (
config_manager.main(["config", "native-rumble", "revoke", "--approval", "0"])
== 0
)
assert config_manager.read_configuration(device).native_switch_controllers == (
second,
)
assert config_manager.OP_PROFILE_LIST not in device.requests
@pytest.mark.parametrize(("offset", "value"), ((0, 1), (4, 32)))
def test_native_rumble_diagnostics_rejects_malformed_rows(
offset: int, value: int
) -> None:
device = FakeDevice()
payload = bytearray(device.native_rumble_diagnostics)
payload[offset] = value
device.native_rumble_diagnostics = bytes(payload)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_native_switch_rumble(device)
def test_native_rumble_diagnostics_rejects_truncated_snapshot() -> None:
device = FakeDevice()
device.native_rumble_diagnostics = device.native_rumble_diagnostics[:-1]
with pytest.raises(config_manager.ConfigManagerError):
config_manager.read_native_switch_rumble(device)
def test_configuration_transaction_and_reset() -> None:
device = FakeDevice()
before = config_manager.read_configuration(device)
@ -704,14 +992,12 @@ 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 device.configuration == struct.pack(
"<HB5x", 90, config_manager.REQUESTED_MODE_XINPUT
)
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 == ()
def test_configuration_transaction_ids_stay_in_host_range(

View file

@ -20,17 +20,45 @@ def test_configuration_service_native(tmp_path: Path) -> None:
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "configuration_service_test.cpp"),
str(root / "src" / "firmware" / "configuration" / "adapter_configuration.cpp"),
str(root / "src" / "firmware" / "configuration" / "configuration_service.cpp"),
str(root / "src" / "firmware" / "configuration" / "configuration_storage.cpp"),
str(root / "src" / "firmware" / "configuration" / "configuration_transaction.cpp"),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "adapter_configuration.cpp"
),
str(root / "src" / "firmware" / "core" / "controller_identity.cpp"),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "configuration_service.cpp"
),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "configuration_storage.cpp"
),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "configuration_transaction.cpp"
),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)
subprocess.run(
[str(executable), "abandoned-receive"], check=True, cwd=root
)
for scenario in (
"lifecycle",
"v2-migration",
"native-approvals",
"abandoned-receive",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)

View file

@ -19,9 +19,28 @@ def test_configuration_storage_native(tmp_path: Path) -> None:
"-pedantic",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "configuration_storage_test.cpp"),
str(root / "src" / "firmware" / "configuration" / "adapter_configuration.cpp"),
str(root / "src" / "firmware" / "configuration" / "configuration_storage.cpp"),
str(root / "src" / "firmware" / "configuration" / "configuration_transaction.cpp"),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "adapter_configuration.cpp"
),
str(root / "src" / "firmware" / "core" / "controller_identity.cpp"),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "configuration_storage.cpp"
),
str(
root
/ "src"
/ "firmware"
/ "configuration"
/ "configuration_transaction.cpp"
),
"-o",
str(executable),
],

View file

@ -29,3 +29,33 @@ def test_switch_haptics_native(tmp_path: Path) -> None:
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)
def test_switch_native_haptics_encoder(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
firmware = root / "src" / "firmware"
executable = tmp_path / "switch_native_haptics_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{firmware}",
str(firmware / "usb" / "switch" / "switch_haptics.cpp"),
str(firmware / "usb" / "switch" / "switch_native_haptics.cpp"),
str(firmware / "core" / "controller_identity.cpp"),
str(firmware / "profile" / "controller_profile.cpp"),
str(firmware / "profile" / "controller_profile_transform.cpp"),
str(root / "tests" / "switch_native_haptics_test.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -0,0 +1,58 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def test_switch_native_output_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
firmware = root / "src" / "firmware"
executable = tmp_path / "switch_native_output_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root / 'tests' / 'switch_native_output_native_stubs'}",
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
f"-I{firmware}",
str(firmware / "input" / "switch_native_output.cpp"),
str(firmware / "usb" / "switch" / "switch_native_haptics.cpp"),
str(firmware / "usb" / "switch" / "switch_haptics.cpp"),
str(firmware / "configuration" / "adapter_configuration.cpp"),
str(firmware / "core" / "controller_identity.cpp"),
str(root / "tests" / "switch_native_output_test.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
# A fresh process isolates firmware owner globals without exposing test-only
# production reset APIs. The fake runloop drives the real registered handlers.
for scenario in (
"approval",
"model-gate",
"revocation",
"revocation-expired",
"generation",
"overflow",
"retry",
"partial-replacement",
"stalled",
"stalled-partial",
"feedback-resume",
"feedback-outlives-host",
"feedback-congestion",
"stateful",
"credit-driven",
"held-state",
"pending-hold",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)

View file

@ -0,0 +1,51 @@
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
from prepare_bluepad32 import prepare_bluepad32
def test_switch_parser_native_wire_and_lifecycle(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("cc") or shutil.which("gcc")
assert compiler is not None, "a host C compiler is required"
prepared = prepare_bluepad32(
root / "external" / "bluepad32",
root / "patches" / "bluepad32-sdl3-imu.patch",
tmp_path / "bluepad32-src",
)
component = prepared / "src" / "components" / "bluepad32"
executable = tmp_path / "switch_parser_native_test"
subprocess.run(
[
compiler,
"-std=gnu11",
"-O1",
"-Wall",
"-Wextra",
"-ffunction-sections",
"-fdata-sections",
"-DENABLE_BLE",
"-DENABLE_CLASSIC",
f"-I{root / 'tests' / 'switch_parser_native_stubs'}",
f"-I{root / 'bluepad32_config'}",
f"-I{component / 'include'}",
str(root / "tests" / "switch_parser_native_test.c"),
str(component / "parser" / "uni_hid_parser_switch.c"),
str(component / "uni_hid_device.c"),
str(component / "uni_circular_buffer.c"),
str(component / "bt" / "uni_bt_conn.c"),
str(component / "controller" / "uni_gamepad.c"),
"-Wl,--gc-sections",
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -244,11 +244,20 @@ void test_input_report_mapping() {
void test_rumble_report() {
const uint8_t packet[8] = {0x00, 0x08, 0x00, 0xa5, 0x5a, 0x00, 0x00, 0x00};
ControllerRumbleOutput output{};
output.raw_valid = true;
output.raw_unmodified = true;
output.raw[0] = 0x80;
output.hd.actuators[0].sample_count = 1;
output.hd.actuators[0].samples[0].low_amplitude_q15 = 1000;
expect(XInput::parse_rumble_report(packet, sizeof(packet), &output),
"valid rumble report rejected");
expect(output.low_frequency_magnitude == 0xa5 &&
output.high_frequency_magnitude == 0x5a,
"rumble magnitudes mapped incorrectly");
expect(!output.raw_valid && !output.raw_unmodified &&
output.hd.actuators[0].sample_count == 0 &&
output.hd.actuators[1].sample_count == 0,
"XInput inherited Nintendo commands from reused output storage");
expect(!XInput::parse_rumble_report(packet, 4, &output),
"truncated rumble report accepted");
uint8_t wrong_type[8]{};