feat: add opt-in native Nintendo rumble backend
This commit is contained in:
parent
71fcf5bb94
commit
3fc28b1fa4
44 changed files with 4834 additions and 240 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
323
tests/switch_native_haptics_test.cpp
Normal file
323
tests/switch_native_haptics_test.cpp
Normal 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;
|
||||
}
|
||||
31
tests/switch_native_output_native_stubs/btstack.h
Normal file
31
tests/switch_native_output_native_stubs/btstack.h
Normal 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);
|
||||
|
|
@ -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
|
||||
27
tests/switch_native_output_native_stubs/uni.h
Normal file
27
tests/switch_native_output_native_stubs/uni.h
Normal 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;
|
||||
};
|
||||
|
||||
712
tests/switch_native_output_test.cpp
Normal file
712
tests/switch_native_output_test.cpp
Normal 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 == ⌖ });
|
||||
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 == ⌖ });
|
||||
}
|
||||
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;
|
||||
}
|
||||
40
tests/switch_parser_native_stubs/btstack.h
Normal file
40
tests/switch_parser_native_stubs/btstack.h
Normal 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); }
|
||||
378
tests/switch_parser_native_test.c
Normal file
378
tests/switch_parser_native_test.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
58
tests/test_switch_native_output_native.py
Normal file
58
tests/test_switch_native_output_native.py
Normal 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)
|
||||
51
tests/test_switch_parser_native.py
Normal file
51
tests/test_switch_parser_native.py
Normal 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)
|
||||
|
|
@ -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]{};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue