Add Nunchuk and combined motion gestures with button or macro actions

This commit is contained in:
Joey Yakimowich-Payne 2026-09-10 09:27:52 -06:00
commit 485ad39709
26 changed files with 1969 additions and 204 deletions

View file

@ -32,6 +32,23 @@ uni_platform* installed_platform = nullptr;
bool observed_status_led_on = false;
int observed_status_led_writes = 0;
uint32_t now_ms = 0;
struct WiiAccelFixture {
uni_hid_device_t* device = nullptr;
int32_t acceleration[3]{};
uint32_t sequence = 0;
bool valid = false;
};
WiiAccelFixture remote_accel_fixture;
WiiAccelFixture nunchuk_accel_fixture;
bool wii_accel_fixture_snapshot(const WiiAccelFixture& fixture,
uni_hid_device_t* controller,
int32_t acceleration[3], uint32_t* sequence) {
if (!fixture.valid || fixture.device != controller) return false;
memcpy(acceleration, fixture.acceleration, sizeof(fixture.acceleration));
*sequence = fixture.sequence;
return true;
}
bool bondable = true;
bool ssp_auto_accept = true;
uint8_t accepted_stk_methods = 0xff;
@ -722,6 +739,16 @@ extern "C" void uni_hid_parser_wii_set_mode(
"Wii mode reconfiguration must retain the ready connection");
}
extern "C" bool uni_hid_parser_wii_accel_snapshot(
uni_hid_device_t* controller, int32_t acceleration[3], uint32_t* sequence) {
return wii_accel_fixture_snapshot(remote_accel_fixture, controller, acceleration, sequence);
}
extern "C" bool uni_hid_parser_wii_nunchuk_accel_snapshot(
uni_hid_device_t* controller, int32_t acceleration[3], uint32_t* sequence) {
return wii_accel_fixture_snapshot(nunchuk_accel_fixture, controller, acceleration, sequence);
}
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
namespace {
std::vector<btstack_timer_source_t*> native_timers;
@ -5496,6 +5523,81 @@ uni_hid_device_t wii_device(int index) {
return result;
}
void test_wii_accelerometer_streams() {
start_pairing_backend();
auto remote = wii_device(0);
remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL;
require(platform_on_device_ready(&remote) == UNI_ERROR_SUCCESS, "Wii must become ready");
const auto generation = slot_snapshot(0).connection_generation;
if (kDefaultMotionEnabled) {
require(bluepad32_input_backend_toggle_motion(0, generation),
"Wii console motion must be disabled for independent gesture streams");
}
remote_accel_fixture = {&remote, {8192, 0, 0}, 7, true};
nunchuk_accel_fixture = {&remote, {0, 8192, 0}, 9, false};
uni_controller_t input{};
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
input.gamepad.accel[0] = 8192;
input.gamepad.gyro[2] = 1024;
now_ms = 100;
platform_on_controller_data(&remote, &input);
auto snapshot = slot_snapshot(0);
require(snapshot.state.motion_sample_count == 0 &&
snapshot.accelerometer.valid && !snapshot.nunchuk_accelerometer.valid &&
snapshot.accelerometer.y == -4096 && snapshot.accelerometer.timestamp_ms == 100,
"Remote gesture acceleration must remain calibrated with console motion off");
now_ms = 125;
nunchuk_accel_fixture.valid = true;
platform_on_controller_data(&remote, &input);
snapshot = slot_snapshot(0);
require(snapshot.state.motion_sample_count == 0 &&
snapshot.nunchuk_accelerometer.valid && snapshot.nunchuk_accelerometer.z == 4096 &&
snapshot.nunchuk_accelerometer.timestamp_ms == 125 &&
snapshot.accelerometer.timestamp_ms == 100,
"Nunchuk interleave must publish its own sample without refreshing the Remote");
now_ms = 150;
++remote_accel_fixture.sequence; // Same physical values, genuinely new report.
platform_on_controller_data(&remote, &input);
now_ms = 200; // Status/ack callback exposes the same parser snapshots.
platform_on_controller_data(&remote, &input);
snapshot = slot_snapshot(0);
bluepad32_input_backend_report_sent(0);
snapshot = slot_snapshot(0);
require(snapshot.accelerometer.timestamp_ms == 150 &&
snapshot.nunchuk_accelerometer.timestamp_ms == 125 &&
snapshot.accelerometer.valid && snapshot.nunchuk_accelerometer.valid,
"cached parser callbacks and USB consumption must not freshen either sensor");
now_ms = 225;
nunchuk_accel_fixture.valid = false;
platform_on_controller_data(&remote, &input);
snapshot = slot_snapshot(0);
require(!snapshot.nunchuk_accelerometer.valid &&
snapshot.accelerometer.valid && snapshot.accelerometer.timestamp_ms == 150,
"Nunchuk detach/calibration failure must immediately invalidate only its own sensor");
now_ms = 250;
nunchuk_accel_fixture.valid = true;
++nunchuk_accel_fixture.sequence;
platform_on_controller_data(&remote, &input);
snapshot = slot_snapshot(0);
require(snapshot.nunchuk_accelerometer.valid &&
snapshot.nunchuk_accelerometer.timestamp_ms == 250 &&
snapshot.accelerometer.timestamp_ms == 150,
"replacement Nunchuk must resume with its own fresh timestamp");
platform_on_device_disconnected(&remote);
snapshot = slot_snapshot(0);
require(!snapshot.accelerometer.valid && !snapshot.nunchuk_accelerometer.valid,
"Remote disconnect must invalidate both accelerometer streams");
auto replacement = wii_device(0);
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS, "replacement Wii must become ready");
snapshot = slot_snapshot(0);
require(!snapshot.accelerometer.valid && !snapshot.nunchuk_accelerometer.valid,
"reused backend slot must not inherit either sensor sample");
}
void dispatch_wii_requests() {
wii_dispatch_active = true;
now_ms += g_rumble_timer.timeout_ms;
@ -5945,6 +6047,10 @@ int main(int argc, char** argv) {
test_wii_orientation_races();
return 0;
}
if (scenario == "wii-accelerometers") {
test_wii_accelerometer_streams();
return 0;
}
if (scenario == "switch2-hd-pro") {
test_switch2_hd_pro();
} else if (scenario == "switch2-gesture-timing") {

View file

@ -13,10 +13,10 @@ typedef enum wii_flags {
} wii_mode_t;
void uni_hid_parser_wii_set_mode(uni_hid_device_t* device, wii_mode_t mode);
static inline bool uni_hid_parser_wii_accel_snapshot(
uni_hid_device_t*, int32_t[3], uint32_t*) {
return false;
}
bool uni_hid_parser_wii_accel_snapshot(
uni_hid_device_t*, int32_t[3], uint32_t*);
bool uni_hid_parser_wii_nunchuk_accel_snapshot(
uni_hid_device_t*, int32_t[3], uint32_t*);
#ifdef __cplusplus
}

View file

@ -1363,6 +1363,226 @@ void test_early_rebound_cannot_become_a_delayed_swing() {
require(presses == 2, "a new stroke after cooldown and release must trigger");
}
void test_combined_swings_have_priority_at_window_boundary() {
for (bool nunchuk_first : {false, true}) {
for (unsigned delay : {0u, 100u, 110u}) {
WiiSwingGestures gestures;
uint32_t now = UINT32_MAX - 100u;
uint32_t sequence = 0;
unsigned counts[3]{};
auto feed = [&](int16_t first, int16_t second) {
now += 10;
++sequence;
WiiAccelerometerSample remote{
nunchuk_first ? second : first, 0, 4096, sequence, now, true};
WiiAccelerometerSample nunchuk{
nunchuk_first ? first : second, 0, 4096, sequence, now, true};
auto result = gestures.update(remote, nunchuk, now, 1, 1, 7, 100);
for (unsigned i = 0; i < 3; ++i)
if (result.started & (1u << i)) ++counts[i];
return result;
};
for (unsigned i = 0; i < 20; ++i) feed(0, 0);
for (unsigned step = 0; step < 40; ++step) {
auto result = feed(10000, step * 10 >= delay ? 10000 : 0);
if (delay <= 100)
require((result.active & 3u) == 0, "combined gesture leaked individual buttons");
}
if (delay <= 100)
require(counts[2] == 1 && counts[0] == 0 && counts[1] == 0,
"simultaneous/in-window strokes must start only the combined action");
else
require(counts[2] == 0 && counts[0] == 1 && counts[1] == 1,
"out-of-window strokes must remain two individual actions");
}
}
}
void test_combined_pending_cancels_on_detach_and_modifier_release() {
for (bool detach : {false, true}) {
WiiSwingGestures gestures;
uint32_t now = 0, sequence = 0;
auto feed = [&](int16_t force, uint8_t allowed, bool nunchuk_valid) {
now += 10;
++sequence;
WiiAccelerometerSample remote{force, 0, 4096, sequence, now, true};
WiiAccelerometerSample nunchuk{0, 0, 4096, sequence, now, nunchuk_valid};
return gestures.update(remote, nunchuk, now, 1, 1, allowed, 100);
};
for (unsigned i = 0; i < 20; ++i) feed(0, 7, true);
feed(10000, 7, true);
require(feed(10000, 7, true).started == 0, "single must wait while combined is eligible");
for (unsigned i = 0; i < 20; ++i)
require(feed(10000, detach ? 7 : 3, !detach).started == 0,
"detaching or releasing combined modifier replayed a pending action");
}
WiiSwingGestures remote_only;
WiiAccelerometerSample missing{};
WiiAccelerometerSample sample{0, 0, 4096, 0, 0, true};
for (unsigned i = 0; i < 20; ++i) {
sample.timestamp_ms += 10;
++sample.sequence;
remote_only.update(sample, missing, sample.timestamp_ms, 1, 1, 7, 100);
}
sample.x = 10000;
++sample.sequence;
sample.timestamp_ms += 10;
remote_only.update(sample, missing, sample.timestamp_ms, 1, 1, 7, 100);
++sample.sequence;
sample.timestamp_ms += 10;
require(remote_only.update(sample, missing, sample.timestamp_ms, 1, 1, 7, 100).started == 1,
"absent Nunchuk must not delay the Remote gesture");
}
void test_nunchuk_button_and_combined_macro_runtime() {
prepare_profiles();
auto& profile = rows[0].profiles[0];
profile.swing.button = 2;
profile.nunchuk_swing.button = 1;
profile.combined_swing.macro = 0;
profile.macros[0].step_count = 1;
profile.macros[0].mode = ControllerProfileMacroMode::kWhileHeld;
profile.macros[0].trigger_mask = 1; // Not held during the combined gesture.
profile.macro_step_count = 1;
profile.macro_steps[0].override_flags = kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 100;
profile.macro_steps[0].output_button_mask = 8;
for (unsigned i = 1; i < CONTROLLER_PROFILE_MACRO_COUNT; ++i)
profile.macros[i].first_step = 1;
auto snapshot = make_snapshot(0);
uint32_t now = 0, sequence = 0;
auto feed = [&](int16_t remote, int16_t nunchuk) {
now += 10;
++sequence;
snapshot.accelerometer = {remote, 0, 4096, sequence, now, true};
snapshot.nunchuk_accelerometer = {nunchuk, 0, 4096, sequence, now, true};
return runtime_transform(0, snapshot, now);
};
for (unsigned i = 0; i < 20; ++i) feed(0, 0);
feed(10000, 10000);
auto result = feed(10000, 10000);
require(result.state.button_north && !result.state.button_west && !result.state.button_east,
"combined gesture must start its macro instead of both individual buttons");
for (unsigned i = 0; i < 8; ++i)
require(feed(0, 0).state.button_north, "gesture macro stopped when physical trigger was absent");
feed(0, 0);
require(!feed(0, 0).state.button_north, "gesture macro must finish exactly one cycle");
for (unsigned i = 0; i < 30; ++i) feed(0, 0);
feed(0, 10000);
require(!feed(0, 10000).state.button_east, "Nunchuk alone must wait for combination window");
for (unsigned i = 0; i < 10; ++i) feed(0, 10000);
result = feed(0, 10000);
require(result.state.button_east && !result.state.button_west && !result.state.button_north,
"Nunchuk-only gesture must produce its configured button after the window");
}
void test_gesture_macros_rearm_during_continuous_swings() {
prepare_profiles();
auto& profile = rows[0].profiles[0];
profile.swing.macro = 0;
profile.macros[0].step_count = 1;
profile.macros[0].mode = ControllerProfileMacroMode::kRepeat;
profile.macros[0].repeat_count = 5;
profile.macro_step_count = 1;
profile.macro_steps[0].override_flags = kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 100;
profile.macro_steps[0].output_button_mask = 8;
auto snapshot = make_snapshot(0);
uint32_t now = 0;
unsigned starts = 0;
bool held = false;
auto feed = [&](int16_t force) {
now += 10;
snapshot.accelerometer = {force, 0, 4096, snapshot.accelerometer.sequence + 1, now, true};
const bool pressed = runtime_transform(0, snapshot, now).state.button_north;
if (pressed && !held) ++starts;
held = pressed;
};
for (unsigned i = 0; i < 20; ++i) feed(0);
for (unsigned stroke = 0; stroke < 4; ++stroke) {
const int direction = stroke % 2 ? -1 : 1;
for (unsigned i = 0; i < 24; ++i) feed(direction * 10000);
for (unsigned i = 0; i < 8; ++i) feed(direction * 2000);
require(starts == stroke + 1 && !held,
"gesture macro must play once per stroke without requiring a full stop");
}
}
struct CombinedConfirmationRig {
WiiSwingGestures gestures;
uint32_t now = 1000, sequence = 0;
unsigned counts[3]{};
CombinedConfirmationRig() {
for (unsigned i = 0; i < 20; ++i) feed(0, 0);
}
WiiSwingGestureResult feed(int16_t remote_force, int16_t nunchuk_force,
uint8_t allowed = 7) {
now += 10;
++sequence;
WiiAccelerometerSample remote{remote_force, 0, 4096, sequence, now, true};
WiiAccelerometerSample nunchuk{nunchuk_force, 0, 4096, sequence, now, true};
const auto result = gestures.update(remote, nunchuk, now, 1, 1, allowed, 100);
for (unsigned i = 0; i < 3; ++i)
if (result.started & (1u << i)) ++counts[i];
return result;
}
};
void test_combined_accepts_smaller_motion_before_or_after_full_swing() {
for (bool weak_nunchuk : {false, true}) {
for (int delay : {-60, 0, 60}) {
CombinedConfirmationRig rig;
for (int step = 0; step < 40; ++step) {
const int strong_start = delay < 0 ? -delay : 0;
const int weak_start = delay > 0 ? delay : 0;
const int16_t strong = step * 10 >= strong_start ? 10000 : 0;
const int16_t weak = step * 10 >= weak_start ? 4500 : 0;
const auto result = rig.feed(weak_nunchuk ? strong : weak,
weak_nunchuk ? weak : strong);
require((result.active & 3) == 0, "confirmed combined motion leaked a single action");
}
require(rig.counts[2] == 1 && rig.counts[0] == 0 && rig.counts[1] == 0,
"one full swing plus smaller companion motion must combine in either order");
}
}
}
void test_confirmation_does_not_weaken_individuals_or_accept_noise() {
for (uint8_t allowed : {uint8_t{3}, uint8_t{7}}) {
CombinedConfirmationRig rig;
for (unsigned i = 0; i < 40; ++i) rig.feed(4500, 4500, allowed);
require(rig.counts[0] == 0 && rig.counts[1] == 0 && rig.counts[2] == 0,
"two subthreshold movements must not generate any action");
}
for (bool spike : {false, true}) {
CombinedConfirmationRig rig;
for (unsigned i = 0; i < 40; ++i)
rig.feed(10000, spike && i == 2 ? 4500 : 0);
require(rig.counts[0] == 1 && rig.counts[1] == 0 && rig.counts[2] == 0,
"a stationary companion or one noisy sample must not confirm a combined swing");
}
}
void test_consumed_confirmation_suppresses_late_full_swing() {
CombinedConfirmationRig rig;
for (unsigned i = 0; i < 45; ++i)
rig.feed(10000, i < 8 ? 4500 : 10000);
require(rig.counts[2] == 1 && rig.counts[0] == 0 && rig.counts[1] == 0,
"a confirmation growing into a full swing must not leak a later individual action");
}
void test_expired_or_discarded_confirmation_cannot_be_reused() {
for (bool discard : {false, true}) {
CombinedConfirmationRig rig;
for (unsigned i = 0; i < 3; ++i) rig.feed(0, 4500);
if (discard) rig.gestures.discard_actions();
else for (unsigned i = 0; i < 12; ++i) rig.feed(0, 0);
for (unsigned i = 0; i < 40; ++i) rig.feed(10000, 0);
require(rig.counts[0] == 1 && rig.counts[1] == 0 && rig.counts[2] == 0,
"expired or macro-discarded evidence must not confirm a later swing");
}
}
} // namespace
bool bluepad32_input_backend_toggle_motion(
@ -1441,5 +1661,13 @@ int main() {
test_swing_modifier_release_cancels_and_requires_fresh_settle();
test_back_and_forth_swings_do_not_require_full_stops();
test_early_rebound_cannot_become_a_delayed_swing();
test_combined_swings_have_priority_at_window_boundary();
test_combined_pending_cancels_on_detach_and_modifier_release();
test_nunchuk_button_and_combined_macro_runtime();
test_gesture_macros_rearm_during_continuous_swings();
test_combined_accepts_smaller_motion_before_or_after_full_swing();
test_confirmation_does_not_weaken_individuals_or_accept_noise();
test_consumed_confirmation_suppresses_late_full_swing();
test_expired_or_discarded_confirmation_cannot_be_reused();
return 0;
}

View file

@ -39,6 +39,19 @@ bool read_encoded_database(void*, size_t offset, uint8_t* output,
return true;
}
void require_no_new_swing_actions(const ControllerProfile& profile) {
require(profile.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
profile.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
profile.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
profile.nunchuk_swing.sensitivity == 1 &&
profile.nunchuk_swing.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
profile.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
profile.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
profile.combined_swing.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
profile.combination_window_ms == 100,
"legacy profile enabled a new gesture action or lost its defaults");
}
void test_pair_identity_wire_and_member_validation() {
ControllerIdentity left = identity(1);
left.transport = ControllerTransport::kBle;
@ -123,9 +136,9 @@ void test_profile_wire_schema() {
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
"default profile did not encode");
require(encoded[0] == 8 && encoded[1] == 0 &&
require(encoded[0] == 9 && encoded[1] == 0 &&
encoded[2] == 0x80 && encoded[3] == 1,
"profile header is not little-endian v8/384");
"profile header is not little-endian v9/384");
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
require(encoded[4 + index] == index,
@ -280,6 +293,7 @@ void test_legacy_profile_migration() {
migrated.swing.sensitivity == 1 &&
migrated.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"legacy padding enabled a swing gesture");
require_no_new_swing_actions(migrated);
require(migrated.triggers[0].digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
migrated.triggers[1].digital_threshold ==
@ -405,6 +419,9 @@ void test_legacy_profile_migration() {
previous_encoded[0] = version;
previous_encoded[75] = version == 4 ? 0x21 : 0;
migrated.swing = {2, 2, 24};
migrated.nunchuk_swing = {0, 0, 0};
migrated.combined_swing = {15, CONTROLLER_PROFILE_NO_BUTTON, 24};
migrated.combination_window_ms = 30;
require(controller_profile_decode(
previous_encoded, sizeof(previous_encoded), &migrated) &&
migrated.triggers[0].output ==
@ -423,6 +440,7 @@ void test_legacy_profile_migration() {
migrated.swing.sensitivity == 1 &&
migrated.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"legacy control mapping or action migration lost settings");
require_no_new_swing_actions(migrated);
}
ControllerProfile current =
@ -554,6 +572,7 @@ void test_set_b_sparse_extension_and_migration() {
decoded.swing.sensitivity == 1 &&
decoded.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"schema5 full136-byte macro stream did not migrate");
require_no_new_swing_actions(decoded);
require(controller_profile_encode(decoded, encoded, sizeof(encoded)) &&
memcmp(&legacy[4], &encoded[4], sizeof(legacy) - 4) == 0,
"schema5 migration changed existing profile data");
@ -637,6 +656,7 @@ void test_schema6_migration_preserves_every_setting() {
profile.swing.sensitivity == 1 &&
profile.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"schema6 padding became a swing gesture");
require_no_new_swing_actions(profile);
for (uint8_t index = 0; index < CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT; ++index) {
require(profile.extra_button_map[index] == CONTROLLER_PROFILE_NO_BUTTON &&
profile.shift.extra_button_map[index] == CONTROLLER_PROFILE_NO_BUTTON,
@ -750,6 +770,7 @@ void test_schema7_migration_preserves_extra_controls_and_macros() {
migrated.swing.sensitivity == 1 &&
migrated.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"schema7 migration lost existing settings or enabled swing");
require_no_new_swing_actions(migrated);
for (size_t offset = 364; offset < sizeof(legacy); ++offset) {
legacy[offset] = 1;
require(!controller_profile_decode(legacy, sizeof(legacy), &migrated),
@ -758,39 +779,124 @@ void test_schema7_migration_preserves_extra_controls_and_macros() {
}
}
void test_schema8_migration_preserves_remote_swing() {
uint8_t legacy[sizeof(kLegacySchema6Profile)]{};
memcpy(legacy, kLegacySchema6Profile, sizeof(legacy));
legacy[0] = 8;
legacy[344] = 16;
legacy[351] = 15;
legacy[358] = 0x40;
legacy[362] = 0x55;
legacy[363] = 0x2a;
legacy[364] = 15;
legacy[365] = 2;
legacy[366] = 24;
ControllerProfile migrated{};
migrated.swing.macro = 0;
migrated.nunchuk_swing = {0, 0, 0};
migrated.combined_swing = {1, CONTROLLER_PROFILE_NO_BUTTON, 16};
migrated.combination_window_ms = 200;
uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_decode(legacy, sizeof(legacy), &migrated) &&
controller_profile_encode(migrated, upgraded, sizeof(upgraded)) &&
memcmp(legacy + 2, upgraded + 2, 365) == 0 &&
migrated.swing.button == 15 &&
migrated.swing.sensitivity == 2 &&
migrated.swing.modifier == 24,
"schema8 migration changed Remote, extras, or macro settings");
require_no_new_swing_actions(migrated);
for (size_t offset = 367; offset < sizeof(legacy); ++offset) {
legacy[offset] = 1;
require(!controller_profile_decode(legacy, sizeof(legacy), &migrated),
"schema8 interpreted reserved padding as new swing actions");
legacy[offset] = 0;
}
}
void test_swing_wire_settings_and_rejection() {
ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
profile.swing = {15, 2, 24};
profile.nunchuk_swing = {0, 0, 0};
profile.combined_swing = {4, CONTROLLER_PROFILE_NO_BUTTON, 16};
profile.combination_window_ms = 30;
profile.macros[0].step_count = 1;
profile.macro_step_count = 1;
profile.macro_steps[0].duration_ms = 1;
for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) {
profile.macros[index].first_step = 1;
}
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
ControllerProfile decoded{};
const uint8_t button_actions[] = {
15, 2, 24, 255, 0, 0, 0, 255, 4, 255, 16, 30};
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
encoded[364] == 15 && encoded[365] == 2 && encoded[366] == 24 &&
memcmp(encoded + 364, button_actions, sizeof(button_actions)) == 0 &&
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
decoded.swing.button == 15 && decoded.swing.sensitivity == 2 &&
decoded.swing.modifier == 24,
"swing wire settings did not round-trip");
decoded.swing.modifier == 24 &&
decoded.nunchuk_swing.button == 0 &&
decoded.nunchuk_swing.sensitivity == 0 &&
decoded.nunchuk_swing.modifier == 0 &&
decoded.combined_swing.button == 4 &&
decoded.combined_swing.modifier == 16 &&
decoded.combination_window_ms == 30,
"independent swing button actions did not use the schema9 layout");
const ControllerProfileSwingConfiguration invalid_settings[] = {
{16, 2, 24}, {254, 2, 24}, {15, 3, 24}, {15, 255, 24},
{15, 2, 25}, {15, 2, 254}, {255, 3, 255}, {255, 1, 25}};
for (const auto& invalid : invalid_settings) {
profile.swing = invalid;
require(!controller_profile_validate(profile) &&
!controller_profile_encode(profile, encoded, sizeof(encoded)),
"invalid swing settings were accepted for encoding");
encoded[364] = invalid.button;
encoded[365] = invalid.sensitivity;
encoded[366] = invalid.modifier;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"invalid swing wire settings were accepted");
{15, 2, 25}, {15, 2, 254}, {255, 3, 255}, {255, 1, 25},
{255, 1, 24, 4}, {255, 1, 24, 254}, {0, 1, 24, 0}};
for (uint8_t sensor = 0; sensor < 2; ++sensor) {
const size_t offset = 364 + sensor * 4;
for (const auto& settings : invalid_settings) {
ControllerProfile invalid = profile;
(sensor == 0 ? invalid.swing : invalid.nunchuk_swing) = settings;
uint8_t malformed[sizeof(encoded)]{};
require(!controller_profile_validate(invalid) &&
!controller_profile_encode(invalid, malformed,
sizeof(malformed)),
"invalid single-sensor swing action was accepted for encoding");
memcpy(malformed, encoded, sizeof(malformed));
malformed[offset] = settings.button;
malformed[offset + 1] = settings.sensitivity;
malformed[offset + 2] = settings.modifier;
malformed[offset + 3] = settings.macro;
require(!controller_profile_decode(malformed, sizeof(malformed), &decoded),
"invalid single-sensor swing wire action was accepted");
}
}
profile.swing = {0, 0, 0};
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
decoded.swing.button == 0 && decoded.swing.sensitivity == 0 &&
decoded.swing.modifier == 0,
"zero-valued swing settings were mistaken for disabled settings");
for (size_t offset = 367; offset < sizeof(encoded); ++offset) {
const ControllerProfileCombinedSwingConfiguration invalid_combined[] = {
{16, 255, 24}, {254, 255, 24}, {255, 4, 24},
{255, 254, 24}, {0, 0, 24}, {15, 255, 25}, {255, 255, 254}};
for (const auto& settings : invalid_combined) {
ControllerProfile invalid = profile;
invalid.combined_swing = settings;
uint8_t malformed[sizeof(encoded)]{};
require(!controller_profile_validate(invalid) &&
!controller_profile_encode(invalid, malformed, sizeof(malformed)),
"invalid combined swing action was accepted for encoding");
memcpy(malformed, encoded, sizeof(malformed));
malformed[372] = settings.button;
malformed[373] = settings.macro;
malformed[374] = settings.modifier;
require(!controller_profile_decode(malformed, sizeof(malformed), &decoded),
"invalid combined swing wire action was accepted");
}
const uint8_t invalid_windows[] = {29, 201};
for (uint8_t window : invalid_windows) {
ControllerProfile invalid = profile;
invalid.combination_window_ms = window;
uint8_t malformed[sizeof(encoded)]{};
require(!controller_profile_validate(invalid) &&
!controller_profile_encode(invalid, malformed, sizeof(malformed)),
"out-of-range combination window was accepted for encoding");
memcpy(malformed, encoded, sizeof(malformed));
malformed[375] = window;
require(!controller_profile_decode(malformed, sizeof(malformed), &decoded),
"out-of-range combination window was accepted from wire");
}
for (size_t offset = 376; offset < sizeof(encoded); ++offset) {
require(encoded[offset] == 0, "schema9 reserved tail was not zero");
encoded[offset] = 1;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"nonzero swing extension reservation was accepted");
@ -798,12 +904,85 @@ void test_swing_wire_settings_and_rejection() {
}
profile.swing = {CONTROLLER_PROFILE_NO_BUTTON, 0,
CONTROLLER_PROFILE_NO_BUTTON};
profile.nunchuk_swing = profile.swing;
profile.combined_swing = {};
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
decoded.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
decoded.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
decoded.swing.sensitivity == 0 &&
decoded.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"disabled swing did not preserve sensitivity and absent modifier");
decoded.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
decoded.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
decoded.nunchuk_swing.sensitivity == 0 &&
decoded.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
decoded.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON,
"disabled gestures became actions or lost their sensitivity");
}
void test_swing_macro_targets_require_playable_steps() {
ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
profile.macros[0].step_count = 2;
for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) {
profile.macros[index].first_step = 2;
}
profile.macros[3].step_count = 1;
profile.macro_step_count = 3;
profile.macro_steps[1].duration_ms = 1;
profile.macro_steps[2].duration_ms = 10000;
profile.swing = {255, 2, 24, 0};
profile.nunchuk_swing = {255, 0, 255, 3};
profile.combined_swing = {255, 0, 0};
profile.combination_window_ms = 200;
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
ControllerProfile decoded{};
const uint8_t macro_actions[] = {
255, 2, 24, 0, 255, 0, 255, 3, 255, 0, 0, 200};
for (uint8_t mode = 0; mode <= 3; ++mode) {
profile.macros[0].mode = static_cast<ControllerProfileMacroMode>(mode);
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
memcmp(encoded + 364, macro_actions, sizeof(macro_actions)) == 0 &&
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
decoded.swing.macro == 0 && decoded.nunchuk_swing.macro == 3 &&
decoded.combined_swing.macro == 0 &&
decoded.combination_window_ms == 200 &&
decoded.macros[0].mode == profile.macros[0].mode &&
decoded.macros[0].trigger_mask == 0 &&
decoded.macro_steps[0].duration_ms == 0 &&
decoded.macro_steps[1].duration_ms == 1 &&
decoded.macro_steps[2].duration_ms == 10000,
"gesture macro targets lost steps, playback mode, or index boundaries");
}
profile.swing = {};
profile.nunchuk_swing = {};
profile.combined_swing = {};
profile.macros[0].mode = ControllerProfileMacroMode::kOnce;
profile.macro_steps[1].duration_ms = 0;
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
"unbound zero-duration one-shot macro was rejected");
const size_t macro_offsets[] = {367, 371, 373};
for (uint8_t gesture = 0; gesture < 3; ++gesture) {
for (uint8_t target = 0; target < 2; ++target) {
ControllerProfile invalid = profile;
uint8_t* bindings[] = {&invalid.swing.macro,
&invalid.nunchuk_swing.macro,
&invalid.combined_swing.macro};
*bindings[gesture] = target;
uint8_t malformed[sizeof(encoded)]{};
require(!controller_profile_validate(invalid) &&
!controller_profile_encode(invalid, malformed, sizeof(malformed)),
"gesture accepted a zero-duration or empty macro target");
memcpy(malformed, encoded, sizeof(malformed));
malformed[macro_offsets[gesture]] = target;
require(!controller_profile_decode(malformed, sizeof(malformed), &decoded),
"wire gesture accepted a zero-duration or empty macro target");
}
}
profile.macro_steps[1].duration_ms = 1;
profile.macro_steps[2].duration_ms = 0;
profile.nunchuk_swing.macro = 3;
require(!controller_profile_validate(profile),
"gesture used another macro's duration to validate its target");
}
} // namespace
@ -817,6 +996,8 @@ int main() {
test_schema6_migration_preserves_every_setting();
test_extra_control_schema_round_trip_and_output_limits();
test_schema7_migration_preserves_extra_controls_and_macros();
test_schema8_migration_preserves_remote_swing();
test_swing_wire_settings_and_rejection();
test_swing_macro_targets_require_playable_steps();
return 0;
}

View file

@ -813,6 +813,60 @@ void test_extra_shift_and_macro_sources_are_consumed() {
"while-held macro survived release of an extra trigger source");
}
void test_gesture_macros_play_once_and_preserve_trigger_inputs() {
for (auto mode : {ControllerProfileMacroMode::kWhileHeld,
ControllerProfileMacroMode::kToggle,
ControllerProfileMacroMode::kRepeat}) {
auto profile = profile_with_macro(ControllerProfileLogicalButton::kSouth);
profile.macro_step_count = 1;
profile.macros[0].step_count = 1;
profile.macros[0].mode = mode;
profile.macros[0].repeat_count = 5;
profile.macro_steps[0].duration_ms = 100;
profile.macro_steps[0].override_flags = kControllerProfileOverrideLeftStick;
profile.macro_steps[0].left_stick_x = 12345;
ControllerSyntheticInputContext context{};
ControllerState input{};
auto output = controller_synthetic_input_apply(&context, input, profile, 1000, 0, false, 0);
require(output.state.left_stick_x == 12345, "gesture must start macro immediately");
input.button_south = true;
output = controller_synthetic_input_apply(&context, input, profile, 1050);
require(output.state.left_stick_x == 12345 && output.state.button_south,
"gesture macro consumed its unrelated physical trigger button");
input.button_south = false;
output = controller_synthetic_input_apply(&context, input, profile, 1090);
require(output.state.left_stick_x == 12345, "gesture macro stopped on trigger release");
output = controller_synthetic_input_apply(&context, input, profile, 1100);
require(output.state.left_stick_x == 0 && !context.macro_active,
"gesture macro repeated according to physical playback mode");
output = controller_synthetic_input_apply(&context, input, profile, 1200, 0, false, 0);
require(output.state.left_stick_x == 12345, "later gesture could not restart completed macro");
input.button_capture = true;
output = controller_synthetic_input_apply(&context, input, profile, 1210, 0, false, 0);
require(!context.macro_active && output.state.left_stick_x == 0,
"explicit cancel must beat a simultaneous gesture request");
}
}
void test_physical_macro_precedes_simultaneous_gesture_request() {
auto profile = controller_profile_default(controller_identity_global(), 0);
profile.macro_step_count = 2;
for (unsigned i = 0; i < 2; ++i) {
profile.macros[i].first_step = i;
profile.macros[i].step_count = 1;
profile.macro_steps[i].override_flags = kControllerProfileOverrideButtons;
profile.macro_steps[i].duration_ms = 100;
profile.macro_steps[i].output_button_mask = i == 0 ? 4 : 8;
}
profile.macros[1].trigger_mask = 1;
ControllerState input{};
input.button_south = true;
ControllerSyntheticInputContext context{};
const auto output = controller_synthetic_input_apply(&context, input, profile, 100, 0, false, 0);
require(output.state.button_north && !output.state.button_west,
"gesture request stole a simultaneous physical macro trigger");
}
} // namespace
int main() {
@ -829,5 +883,7 @@ int main() {
test_shift_maps_consumption_and_physical_bindings();
test_macro_playback_modes_and_bounded_cycle_skips();
test_extra_shift_and_macro_sources_are_consumed();
test_gesture_macros_play_once_and_preserve_trigger_inputs();
test_physical_macro_precedes_simultaneous_gesture_request();
return 0;
}

View file

@ -309,6 +309,9 @@ void test_current_schema_validation_and_atomic_selection() {
updated.extra_button_map[6] = 16;
updated.shift.extra_button_map[0] = 3;
updated.swing = {2, 2, 24};
updated.nunchuk_swing = {255, 0, 18, 0};
updated.combined_swing = {15, 255, 16};
updated.combination_window_ms = 200;
updated.macros[0].trigger_mask = 1u << 24;
updated.macros[0].cancel_control = 19;
updated.macros[0].step_count = 1;
@ -322,7 +325,7 @@ void test_current_schema_validation_and_atomic_selection() {
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
"extended service profile did not encode");
require(profile_service_begin(
20, id, 6, CONTROLLER_PROFILE_EXTRA_CONTROL_SCHEMA_VERSION,
20, id, 6, CONTROLLER_PROFILE_SWING_SCHEMA_VERSION,
CONTROLLER_PROFILE_ENCODED_SIZE, 0) ==
ConfigurationTransactionStatus::kUnsupportedSchema &&
profile_service_begin(21, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION,
@ -352,24 +355,46 @@ void test_current_schema_validation_and_atomic_selection() {
old_selection.profile.turbo_defaults.rate_hz,
"rejected extension replaced the old selected snapshot");
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
"swing rejection baseline did not encode");
encoded[366] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
require(profile_service_begin(
25, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
profile_storage_crc32(encoded, sizeof(encoded))) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_append(25, 0, encoded, sizeof(encoded)) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_commit(25) ==
ConfigurationTransactionStatus::kMalformed,
"service admitted an invalid swing modifier with a valid CRC");
profile_service_selected_snapshot(&selected);
require(selected.metadata.generation == old_selection.metadata.generation &&
selected.profile.swing.button == old_selection.profile.swing.button &&
active_snapshot(id).profile.swing.button ==
old_selection.profile.swing.button,
"rejected swing replaced a selected or active profile");
const struct {
size_t offset;
uint8_t value;
} malformed_gestures[] = {
{366, 25}, {367, 0}, {368, 0}, {369, 3}, {370, 25},
{371, 1}, {371, 4}, {372, 16}, {373, 0}, {374, 25},
{375, 29}, {375, 201}, {376, 1}, {121, 0}};
uint8_t stored_before[sizeof(flash.bytes)];
memcpy(stored_before, flash.bytes, sizeof(stored_before));
uint32_t transaction_id = 25;
for (const auto& malformed : malformed_gestures) {
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
"gesture rejection baseline did not encode");
encoded[malformed.offset] = malformed.value;
require(profile_service_begin(
transaction_id, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION,
sizeof(encoded), profile_storage_crc32(encoded, sizeof(encoded))) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_append(transaction_id, 0, encoded, sizeof(encoded)) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_commit(transaction_id) ==
ConfigurationTransactionStatus::kMalformed,
"service admitted a malformed gesture or unplayable macro with a valid CRC");
profile_service_selected_snapshot(&selected);
const auto active = active_snapshot(id);
require(selected.metadata.generation == old_selection.metadata.generation &&
selected.profile.swing.button == old_selection.profile.swing.button &&
selected.profile.nunchuk_swing.macro ==
old_selection.profile.nunchuk_swing.macro &&
selected.profile.combined_swing.button ==
old_selection.profile.combined_swing.button &&
active.profile.swing.button == old_selection.profile.swing.button &&
active.profile.nunchuk_swing.macro ==
old_selection.profile.nunchuk_swing.macro &&
active.profile.combined_swing.button ==
old_selection.profile.combined_swing.button &&
memcmp(stored_before, flash.bytes, sizeof(stored_before)) == 0,
"rejected gesture replaced a selected, active, or stored profile");
++transaction_id;
}
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
"valid replacement did not encode");
@ -387,7 +412,11 @@ void test_current_schema_validation_and_atomic_selection() {
profile_service_selected_snapshot(&selected);
require(selected.metadata.generation == old_selection.metadata.generation &&
active_snapshot(id).profile.turbo_defaults.rate_hz ==
old_selection.profile.turbo_defaults.rate_hz,
old_selection.profile.turbo_defaults.rate_hz &&
selected.profile.nunchuk_swing.macro ==
old_selection.profile.nunchuk_swing.macro &&
active_snapshot(id).profile.combined_swing.button ==
old_selection.profile.combined_swing.button,
"pending write replaced a selected or active profile before commit");
profile_service_task_on_storage_core(8000);
profile_service_selected_snapshot(&selected);
@ -400,9 +429,21 @@ void test_current_schema_validation_and_atomic_selection() {
selected.profile.swing.button == 2 &&
selected.profile.swing.sensitivity == 2 &&
selected.profile.swing.modifier == 24 &&
selected.profile.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
selected.profile.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
selected.profile.nunchuk_swing.macro == 0 &&
selected.profile.nunchuk_swing.sensitivity == 0 &&
selected.profile.nunchuk_swing.modifier == 18 &&
selected.profile.combined_swing.button == 15 &&
selected.profile.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
selected.profile.combined_swing.modifier == 16 &&
selected.profile.combination_window_ms == 200 &&
active_snapshot(id).profile.swing.button == 2 &&
active_snapshot(id).profile.swing.sensitivity == 2 &&
active_snapshot(id).profile.swing.modifier == 24 &&
active_snapshot(id).profile.nunchuk_swing.macro == 0 &&
active_snapshot(id).profile.combined_swing.button == 15 &&
active_snapshot(id).profile.combination_window_ms == 200 &&
active_snapshot(id).profile.macros[0].trigger_mask == (1u << 24) &&
active_snapshot(id).profile.macros[0].cancel_control == 19 &&
active_snapshot(id).profile.turbo_defaults.rate_hz == 30,
@ -470,6 +511,16 @@ void test_catalog1_selected_and_active_snapshots_migrate() {
selected.profile.shortcuts.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
selected.profile.shift.mode == ControllerProfileShiftMode::kOff &&
selected.profile.macros[0].mode == ControllerProfileMacroMode::kOnce &&
selected.profile.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
selected.profile.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
selected.profile.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
selected.profile.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
selected.profile.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
active.profile.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
active.profile.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
active.profile.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
active.profile.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
active.profile.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
selected.metadata.generation > 42,
"old selection/activation snapshots lost migrated content or defaults");
}

View file

@ -523,12 +523,31 @@ void test_compaction_preserves_latest_records() {
profile.extra_button_map[0] = 16;
profile.shift.extra_button_map[6] = 15;
profile.swing = {2, 2, 24};
profile.nunchuk_swing = {255, 0, 18, 3};
profile.combined_swing = {4, 255, 16};
profile.combination_window_ms = 30;
profile.macros[3].step_count = 1;
profile.macros[3].mode = ControllerProfileMacroMode::kToggle;
profile.macro_step_count = 1;
profile.macro_steps[0].duration_ms = 50;
ControllerProfile other = controller_profile_default(global, 7);
other.swing = {15, 0, CONTROLLER_PROFILE_NO_BUTTON};
other.nunchuk_swing = {1, 2, 24};
other.combined_swing = {255, 0, 255};
other.combination_window_ms = 200;
other.macros[0].step_count = 1;
other.macro_step_count = 1;
other.macro_steps[0].duration_ms = 75;
for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) {
other.macros[index].first_step = 1;
}
const ControllerIdentity stable = identity(1);
require(storage.set(global, 7, other) == ProfileStorageResult::kOk,
"second swing profile did not append");
other.swing = {0, 1, 16};
other.swing = {255, 1, 16, 0};
other.nunchuk_swing = {3, 1, 17};
other.combined_swing = {14, 255, 24};
other.combination_window_ms = 120;
require(storage.set(stable, 0, other) == ProfileStorageResult::kOk,
"stable identity swing profile did not append");
for (uint16_t write = 1; write <= 260; ++write) {
@ -550,6 +569,17 @@ void test_compaction_preserves_latest_records() {
recovered.swing.button == 2 &&
recovered.swing.sensitivity == 2 &&
recovered.swing.modifier == 24 &&
recovered.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.nunchuk_swing.sensitivity == 0 &&
recovered.nunchuk_swing.modifier == 18 &&
recovered.nunchuk_swing.macro == 3 &&
recovered.combined_swing.button == 4 &&
recovered.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.combined_swing.modifier == 16 &&
recovered.combination_window_ms == 30 &&
recovered.macros[3].mode == ControllerProfileMacroMode::kToggle &&
recovered.macro_steps[0].duration_ms == 50 &&
reloaded.get_alias(global, metadata, sizeof(metadata)) ==
ProfileStorageResult::kOk &&
strcmp(metadata, "Fallback") == 0 &&
@ -558,16 +588,45 @@ void test_compaction_preserves_latest_records() {
ProfileStorageResult::kOk &&
strcmp(metadata, "Compacted") == 0,
"compaction did not preserve profiles and metadata");
const uint32_t generation = reloaded.snapshot().generation;
const int programs = flash.programs;
recovered.nunchuk_swing.button = 0;
require(reloaded.set(global, 0, recovered) ==
ProfileStorageResult::kInvalidArgument &&
reloaded.snapshot().generation == generation &&
flash.programs == programs &&
reloaded.get(global, 0, &recovered) == ProfileStorageResult::kOk &&
recovered.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.nunchuk_swing.macro == 3,
"rejected gesture action modified the stored profile");
require(reloaded.get(global, 7, &recovered) == ProfileStorageResult::kOk &&
recovered.swing.button == 15 &&
recovered.swing.sensitivity == 0 &&
recovered.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.nunchuk_swing.button == 1 &&
recovered.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.combined_swing.macro == 0 &&
recovered.combination_window_ms == 200 &&
recovered.macro_steps[0].duration_ms == 75 &&
reloaded.get(stable, 0, &recovered) == ProfileStorageResult::kOk &&
recovered.swing.button == 0 &&
recovered.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.swing.macro == 0 &&
recovered.swing.sensitivity == 1 &&
recovered.swing.modifier == 16 &&
recovered.nunchuk_swing.button == 3 &&
recovered.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.combined_swing.button == 14 &&
recovered.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.combination_window_ms == 120 &&
reloaded.get(global, 1, &recovered) == ProfileStorageResult::kOk &&
recovered.swing.button == CONTROLLER_PROFILE_NO_BUTTON,
recovered.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
recovered.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON,
"compacted swing settings bled across profiles or identities");
}
@ -823,6 +882,62 @@ void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() {
"editing a schema6 profile lost its active index, alias, or name");
}
void test_schema8_swing_migration_is_lazy_and_preserves_existing_data() {
erase_all();
install_populated_catalog(3);
const auto id = controller_identity_global();
ControllerProfile original = catalog_profile(0, 0, true);
original.extra_button_map[6] = 16;
original.shift.extra_button_map[0] = 15;
original.swing = {2, 0, 24};
uint8_t legacy[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(original, legacy, sizeof(legacy)),
"schema8 storage fixture did not encode");
write_u16(legacy, CONTROLLER_PROFILE_SWING_SCHEMA_VERSION);
memset(legacy + 367, 0, sizeof(legacy) - 367);
install_catalog_record(3, PROFILE_STORAGE_RECORDS_OFFSET, 1, id, 0, 1001,
legacy, sizeof(legacy));
ProfileStorage storage;
ControllerProfile migrated{};
require(storage.initialize(fake_io()) &&
storage.get(id, 0, &migrated) == ProfileStorageResult::kOk &&
migrated.swing.button == 2 && migrated.swing.sensitivity == 0 &&
migrated.swing.modifier == 24 &&
migrated.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
migrated.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
migrated.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
migrated.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
migrated.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
migrated.combination_window_ms == 100 &&
storage.set(id, 0, migrated) == ProfileStorageResult::kUnchanged &&
flash.programs == 0 && flash.erases == 0,
"schema8 read enabled new gestures or unnecessarily rewrote flash");
migrated.nunchuk_swing = {255, 2, 18, 1};
migrated.combined_swing = {255, 0, 16};
migrated.combination_window_ms = 200;
require(storage.set(id, 0, migrated) == ProfileStorageResult::kOk,
"schema8 profile could not add gesture macro actions");
ProfileStorage reloaded;
ControllerProfile recovered{};
uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(reloaded.initialize(fake_io()) &&
reloaded.get(id, 0, &recovered) == ProfileStorageResult::kOk &&
controller_profile_encode(recovered, upgraded, sizeof(upgraded)) &&
memcmp(legacy + 2, upgraded + 2, 365) == 0 &&
recovered.nunchuk_swing.macro == 1 &&
recovered.combined_swing.macro == 0 &&
recovered.combination_window_ms == 200 &&
reloaded.find(id)->active_profile == 0 &&
reloaded.get_alias(id, metadata, sizeof(metadata)) ==
ProfileStorageResult::kOk &&
strcmp(metadata, "A") == 0 &&
reloaded.get_profile_name(id, 0, metadata, sizeof(metadata)) ==
ProfileStorageResult::kOk &&
strcmp(metadata, "A0") == 0,
"editing migrated gestures lost Remote, macros, extras, or metadata");
}
void require_pair_bank(const ProfileStorage &storage,
const ControllerIdentity &owner, uint8_t base,
uint8_t active, char name_prefix) {
@ -1052,6 +1167,7 @@ int main() {
test_late_second_page_program_is_not_reused();
test_unreadable_legacy_data_is_not_erased();
test_schema6_read_migration_is_lazy_and_edit_preserves_metadata();
test_schema8_swing_migration_is_lazy_and_preserves_existing_data();
test_pair_seed_independence_reconnect_and_compaction();
test_pair_capacity_and_unseeded_mutations();
test_pair_seed_interruption_and_ambiguous_readback();

View file

@ -125,6 +125,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
for scenario in (
"wii-orientation",
"wii-orientation-races",
"wii-accelerometers",
"switch2-individual-core-start",
"switch2-individual-forward",
"switch2-individual-reverse",

View file

@ -1869,7 +1869,10 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
legacy_json_object = default_profile.to_json_object()
legacy_json_object["schema_version"] = config_manager.PROFILE_LEGACY_SCHEMA_VERSION
legacy_json_object["size"] = config_manager.PROFILE_LEGACY_SIZE
for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map", "swing"):
for field in (
"shortcuts", "shift", "turbo_settings", "extra_button_map", "swing",
"nunchuk_swing", "combined_swing", "combination_window_ms",
):
del legacy_json_object[field]
del legacy_json_object["motion_toggle_chord"]
del legacy_json_object["triggers"]["left"]["output"]
@ -1932,7 +1935,10 @@ def test_schema5_full_macro_stream_migrates_bytes_and_json(monkeypatch) -> None:
obj["schema_version"] = 5
obj["size"] = 256
for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map", "swing"):
for field in (
"shortcuts", "shift", "turbo_settings", "extra_button_map", "swing",
"nunchuk_swing", "combined_swing", "combination_window_ms",
):
del obj[field]
for macro in obj["macros"]:
del macro["playback"]
@ -1973,7 +1979,6 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
assert encoded[336:344] == bytes((3, 255, 0, 1, 1, 1, 2, 1))
assert encoded[344:358] == bytes([255]) * 14
assert encoded[358:364] == bytes(6)
assert encoded[367:] == bytes(17)
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile
@ -1984,7 +1989,8 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
legacy_json["schema_version"] = 6
del legacy_json["extra_button_map"]
del legacy_json["shift"]["extra_button_map"]
del legacy_json["swing"]
for field in ("swing", "nunchuk_swing", "combined_swing", "combination_window_ms"):
del legacy_json[field]
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
device = FakeDevice()
@ -1998,7 +2004,7 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
assert old_listing[1] == config_manager.ProfileListEntry(device.stable_identity, 1, "Custom controller")
@pytest.mark.parametrize("version", [7, 8])
@pytest.mark.parametrize("version", [7, 8, 9])
def test_schema7_extra_controls_keep_output_channels_and_wire_layout(version: int) -> None:
obj = custom_profile().to_json_object()
obj["extra_button_map"] = dict(zip(
@ -2021,6 +2027,11 @@ def test_schema7_extra_controls_keep_output_channels_and_wire_layout(version: in
obj["schema_version"] = version
if version == 7:
del obj["swing"]
elif version == 8:
del obj["swing"]["macro"]
if version < 9:
for field in ("nunchuk_swing", "combined_swing", "combination_window_ms"):
del obj[field]
profile = config_manager.ControllerProfile.from_json_object(obj)
encoded = profile.to_bytes()
assert encoded[:4] == struct.pack("<HH", config_manager.PROFILE_SCHEMA_VERSION, 384)
@ -2035,6 +2046,8 @@ def test_schema7_extra_controls_keep_output_channels_and_wire_layout(version: in
struct.pack_into("<H", legacy_wire, 0, version)
if version == 7:
legacy_wire[364:] = bytes(20)
elif version == 8:
legacy_wire[367:] = bytes(17)
device = FakeDevice()
device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire)
assert config_manager.read_profile(device, device.stable_identity, 1) == profile
@ -2115,7 +2128,10 @@ def test_legacy_control_profiles_preserve_custom_actions(version: int) -> None:
macro.pop("repeat_count")
macro["steps"].append(config_manager.MacroStep.end().to_json_object())
obj["macro"] = macro
for key in ("shortcuts", "shift", "turbo_settings", "extra_button_map", "swing"):
for key in (
"shortcuts", "shift", "turbo_settings", "extra_button_map", "swing",
"nunchuk_swing", "combined_swing", "combination_window_ms",
):
del obj[key]
assert config_manager.ControllerProfile.from_json_object(obj) == profile
assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()) == profile
@ -2134,6 +2150,7 @@ def test_swing_profile_round_trip_preserves_other_settings(
obj = before.to_json_object()
obj["swing"] = {
"button": button, "sensitivity": sensitivity, "modifier": modifier,
"macro": None,
}
profile = config_manager.ControllerProfile.from_json_object(obj)
assert replace(profile, swing=before.swing) == before
@ -2154,6 +2171,7 @@ def test_swing_profile_round_trip_preserves_other_settings(
("sensitivity", 1),
("modifier", "unknown"),
("modifier", False),
("macro", 0), ("macro", 5), ("macro", True), ("macro", "1"),
])
def test_swing_rejects_invalid_json_settings(field: str, value: object) -> None:
obj = config_manager.ControllerProfile.default().to_json_object()
@ -2166,6 +2184,7 @@ def test_swing_rejects_invalid_json_settings(field: str, value: object) -> None:
("button", -1), ("button", 16), ("button", True),
("sensitivity", -1), ("sensitivity", 3), ("sensitivity", False),
("modifier", -1), ("modifier", 25), ("modifier", 256),
("macro", -1), ("macro", 4), ("macro", False),
])
def test_swing_rejects_invalid_in_memory_settings(field: str, value: object) -> None:
with pytest.raises(config_manager.ConfigManagerError):
@ -2173,7 +2192,9 @@ def test_swing_rejects_invalid_in_memory_settings(field: str, value: object) ->
@pytest.mark.parametrize(("offset", "value"), [
(364, 16), (365, 3), (366, 25), (367, 1),
(364, 16), (365, 3), (366, 25), (367, 4),
(368, 16), (369, 3), (370, 25), (371, 4),
(372, 16), (373, 4), (374, 25), (375, 29), (375, 201), (376, 1),
])
def test_swing_rejects_corrupt_wire_settings(offset: int, value: int) -> None:
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
@ -2184,6 +2205,7 @@ def test_swing_rejects_corrupt_wire_settings(offset: int, value: int) -> None:
@pytest.mark.parametrize("mutation", [
"missing", "unknown", "missing_button", "missing_sensitivity", "missing_modifier",
"missing_macro",
"legacy_field",
])
def test_swing_json_fields_are_strict(mutation: str) -> None:
@ -2195,11 +2217,117 @@ def test_swing_json_fields_are_strict(mutation: str) -> None:
elif mutation.startswith("missing_"):
del obj["swing"][mutation.removeprefix("missing_")]
else:
for field in ("nunchuk_swing", "combined_swing", "combination_window_ms"):
del obj[field]
obj["schema_version"] = 7
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
def test_schema8_swing_migration_preserves_remote_binding() -> None:
profile = replace(custom_profile(), swing=config_manager.ProfileSwing(2, 2, 24))
legacy_wire = bytearray(profile.to_bytes())
struct.pack_into("<H", legacy_wire, 0, 8)
legacy_wire[367:] = bytes(17)
legacy_json = profile.to_json_object()
legacy_json["schema_version"] = 8
del legacy_json["swing"]["macro"]
for field in ("nunchuk_swing", "combined_swing", "combination_window_ms"):
del legacy_json[field]
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()) == profile
legacy_wire[367] = 1
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_bytes(legacy_wire)
@pytest.mark.parametrize("macro_number", [1, 4])
def test_gesture_macro_json_indices_and_wire_layout(macro_number: int) -> None:
obj = custom_profile().to_json_object()
obj["macros"][macro_number - 1] = {
**obj["macros"][0], "trigger": [], "playback": "toggle",
}
obj["swing"].update(macro=macro_number, sensitivity="high", modifier="right_sr")
obj["nunchuk_swing"].update(button="dpad_right", sensitivity="low", modifier="c")
obj["combined_swing"].update(macro=macro_number, modifier="left_trigger")
obj["combination_window_ms"] = 200
profile = config_manager.ControllerProfile.from_json_object(obj)
assert profile.swing.macro == profile.combined_swing.macro == macro_number - 1
encoded = profile.to_bytes()
assert encoded[364:376] == bytes((
255, 2, 24, macro_number - 1, 15, 0, 18, 255,
255, macro_number - 1, 16, 200,
))
assert encoded[376:] == bytes(8)
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
assert profile.to_json_object() == obj
@pytest.mark.parametrize("gesture", ["swing", "nunchuk_swing", "combined_swing"])
def test_gesture_actions_are_exclusive(gesture: str) -> None:
obj = custom_profile().to_json_object()
obj[gesture].update(button="south", macro=1)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
action_type = (
config_manager.ProfileCombinedSwing if gesture == "combined_swing"
else config_manager.ProfileSwing
)
with pytest.raises(config_manager.ConfigManagerError):
action_type(button=0, macro=0)
payload = bytearray(custom_profile().to_bytes())
button_offset, macro_offset = {
"swing": (364, 367), "nunchuk_swing": (368, 371), "combined_swing": (372, 373),
}[gesture]
payload[button_offset] = payload[macro_offset] = 0
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_bytes(payload)
@pytest.mark.parametrize("gesture", ["swing", "nunchuk_swing", "combined_swing"])
@pytest.mark.parametrize("empty", [False, True])
def test_gesture_macro_requires_configured_positive_duration_target(
gesture: str, empty: bool,
) -> None:
obj = custom_profile().to_json_object()
target = obj["macros"][0]
target["trigger"] = []
if empty:
target["steps"] = []
else:
for step in target["steps"]:
step["duration_ms"] = 0
unbound = config_manager.ControllerProfile.from_json_object(obj)
obj[gesture]["macro"] = 1
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
with pytest.raises(config_manager.ConfigManagerError):
replace(unbound, **{gesture: replace(getattr(unbound, gesture), macro=0)})
payload = bytearray(unbound.to_bytes())
payload[{"swing": 367, "nunchuk_swing": 371, "combined_swing": 373}[gesture]] = 0
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_bytes(payload)
@pytest.mark.parametrize("window", [30, 200])
def test_combination_window_inclusive_bounds_round_trip(window: int) -> None:
obj = config_manager.ControllerProfile.default().to_json_object()
obj["combination_window_ms"] = window
profile = config_manager.ControllerProfile.from_json_object(obj)
assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()).combination_window_ms == window
@pytest.mark.parametrize("window", [29, 201, True, 100.0, None])
def test_combination_window_rejects_out_of_range_or_noninteger_values(window: object) -> None:
obj = config_manager.ControllerProfile.default().to_json_object()
obj["combination_window_ms"] = window
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
with pytest.raises(config_manager.ConfigManagerError):
replace(config_manager.ControllerProfile.default(), combination_window_ms=window)
@pytest.mark.parametrize(
("path", "value"),
[

View file

@ -107,6 +107,7 @@ def test_editor_serves_assets_and_complete_schema(
assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES)
assert schema["turbo_modes"] == list(config_manager.TURBO_MODES)
assert schema["swing_sensitivities"] == ["low", "medium", "high"]
assert schema["combination_window_bounds"] == {"min": 30, "max": 200}
assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES)
assert schema["profile_capacity"] == 8
assert (
@ -142,19 +143,23 @@ def test_switch2_input_choices_are_never_output_targets(
assert schema["output_controls"] == list(config_manager.OUTPUT_CONTROLS)
@pytest.mark.parametrize("version", [6, 7])
@pytest.mark.parametrize("version", [6, 7, 8])
def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
monkeypatch: pytest.MonkeyPatch, version: int,
) -> None:
device = FakeDevice()
key = (device.stable_identity.to_bytes(), 1)
profile = custom_profile()
if version == 8:
profile = replace(profile, swing=config_manager.ProfileSwing(3, 0, 24))
legacy_wire = bytearray(profile.to_bytes())
legacy_wire[:2] = version.to_bytes(2, "little")
if version == 6:
legacy_wire[344:] = bytes(40)
else:
elif version == 7:
legacy_wire[364:] = bytes(20)
else:
legacy_wire[367:] = bytes(17)
device.profiles[key] = bytes(legacy_wire)
device.profile_aliases[key[0]] = "Living room"
device.profile_names[key] = "Racing"
@ -163,7 +168,7 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
status, migrated = request_json(f"{base_url}/api/profiles/1/2")
assert status == 200
assert config_manager.ControllerProfile.from_json_object(migrated["profile"]) == profile
assert migrated["profile"]["swing"]["button"] is None
assert migrated["profile"]["swing"] == profile.swing.to_json_object()
draft = migrated["profile"]
draft["extra_button_map"] = dict(zip(config_manager.EXTRA_BUTTONS, config_manager.LOGICAL_BUTTONS[:7]))
draft["shift"]["mode"] = "hold"
@ -175,7 +180,11 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
draft["motion_toggle_chord"] = ["right_sl", "c"]
draft["swing"] = {
"button": "west", "sensitivity": "high", "modifier": "right_trigger",
"macro": None,
}
draft["nunchuk_swing"].update(macro=1, sensitivity="low", modifier="c")
draft["combined_swing"].update(macro=2, modifier="left_trigger")
draft["combination_window_ms"] = 30
status, validated = request_json(
f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token,
)
@ -199,6 +208,27 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
assert sample["buttons"] == ["south", "dpad_up", "dpad_right"]
@pytest.mark.parametrize("gesture", ["swing", "nunchuk_swing", "combined_swing"])
def test_editor_rejects_unconfigured_gesture_macro_without_saving(
monkeypatch: pytest.MonkeyPatch, gesture: str,
) -> None:
device = FakeDevice()
key = (device.stable_identity.to_bytes(), 1)
original = device.profiles[key]
draft = config_manager.ControllerProfile.default().to_json_object()
draft[gesture]["macro"] = 4
with running_server(monkeypatch, device) as (base_url, token):
for endpoint, method in (
("/api/profiles/validate", "POST"), ("/api/profiles/1/2", "PUT"),
):
status, result = request_json(
f"{base_url}{endpoint}", method=method, value=draft, token=token,
)
assert status == 400
assert "error" in result
assert device.profiles[key] == original
def test_editor_identifies_connected_controller_artwork(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -99,8 +99,7 @@ static void update_mp_checksum(void) {
put_be16(&f.mp_calibration[30], crc);
}
static void set_nunchuk_stick_calibration(const uint8_t values[6]) {
memcpy(f.nunchuk_calibration + 8, values, 6);
static void update_nunchuk_checksum(void) {
uint8_t checksum = 0x55;
for (unsigned i = 0; i < 14; ++i)
checksum += f.nunchuk_calibration[i];
@ -108,6 +107,17 @@ static void set_nunchuk_stick_calibration(const uint8_t values[6]) {
f.nunchuk_calibration[15] = checksum + 0x55;
}
static void set_nunchuk_stick_calibration(const uint8_t values[6]) {
memcpy(f.nunchuk_calibration + 8, values, 6);
update_nunchuk_checksum();
}
static void set_nunchuk_accel_calibration(const uint16_t zero[3], const uint16_t one_g[3]) {
pack_accel_calibration(f.nunchuk_calibration, zero);
pack_accel_calibration(f.nunchuk_calibration + 4, one_g);
update_nunchuk_checksum();
}
static void reset_fixture(uint16_t product_id, bool motionplus, extension_t extension) {
memset(&f, 0, sizeof(f));
f.device.vendor_id = 0x057e;
@ -122,6 +132,10 @@ static void reset_fixture(uint16_t product_id, bool motionplus, extension_t exte
const uint8_t nunchuk_identity[6] = {0, 0, 0xa4, 0x20, 0, 0};
memcpy(f.extension_identity, nunchuk_identity, sizeof(nunchuk_identity));
set_nunchuk_stick_calibration((const uint8_t[]){224, 32, 128, 224, 32, 128});
// Dolphin Nunchuk::CalibrationData uses the same packed 10-bit points as
// the Remote, but its own centers and sensitivities.
set_nunchuk_accel_calibration((const uint16_t[]){501, 510, 519},
(const uint16_t[]){693, 766, 647});
// Non-default centers, unequal spans, and nonzero packed low bits catch
// nominal 0x200/100-count calibration and high-byte-only decoding.
@ -417,6 +431,21 @@ static void send_nunchuk_controls(bool passthrough) {
expect_accel();
}
static void send_nunchuk_sample(const uint8_t extension[6]) {
// Extension-only input proves its sequence does not need Remote accel.
uint8_t report[11] = {0x32};
memcpy(report + 3, extension, 6);
feed(report, sizeof(report));
}
static uint32_t expect_nunchuk_accel(int32_t x, int32_t y, int32_t z) {
int32_t acceleration[3];
uint32_t sequence;
assert(uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
expect_vector(acceleration, x, y, z);
return sequence;
}
static void integrated_motionplus_calibration_and_slow_bits(void) {
reset_fixture(0x0330, true, EXT_NONE);
connect_device();
@ -909,6 +938,172 @@ static void unavailable_nunchuk_calibration_keeps_safe_nominal_stick(void) {
expect_nunchuk_stick(224, 32, 511, 511);
}
static void plain_nunchuk_accel_uses_own_packed_factory_points(void) {
reset_fixture(0x0306, false, EXT_NUNCHUK);
connect_device();
int32_t acceleration[3];
uint32_t remote_sequence;
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &remote_sequence));
assert(!uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &remote_sequence));
// Native (693,382,551) = (+1g,-0.5g,+0.25g). Each axis has a different
// packed low-bit value (1,2,3), distinct calibration, and a non-default zero.
const uint8_t extension[6] = {160, 192, 173, 95, 137, 0xe5};
send_nunchuk_sample(extension);
uint32_t first = expect_nunchuk_accel(-8192, 2048, -4096);
assert(f.device.controller.gamepad.buttons == BUTTON_X);
assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == -341);
assert(!uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &remote_sequence));
expect_vector(f.device.controller.gamepad.accel, 0, 0, 0);
uint8_t remote[6] = {0x31};
put_accel_report(remote, 614, 442, 534);
feed(remote, sizeof(remote));
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &remote_sequence));
assert(expect_nunchuk_accel(-8192, 2048, -4096) == first);
send_nunchuk_sample(extension);
assert(expect_nunchuk_accel(-8192, 2048, -4096) != first);
uint32_t sequence;
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
assert(sequence == remote_sequence);
expect_accel(); // Nunchuk does not overwrite the Remote's console IMU.
}
static void passthrough_nunchuk_accel_bitpacking_and_freshness(void) {
reset_fixture(0x0330, true, EXT_NUNCHUK);
set_nunchuk_accel_calibration((const uint16_t[]){502, 510, 518},
(const uint16_t[]){694, 766, 646});
connect_device();
// MP relocates AZ bit2 to byte5 bit7, not the connected bit in byte4.
// Bits 4/5/6/7 here reconstruct (694,382,550); C held, Z released.
uint8_t extension[6] = {160, 192, 173, 95, 137, 0xf4};
send_nunchuk_sample(extension);
uint32_t first = expect_nunchuk_accel(-8192, 2048, -4096);
assert(f.device.controller.gamepad.buttons == BUTTON_X);
send_motion(7760, 8200, 7560, false, true, false);
assert(expect_nunchuk_accel(-8192, 2048, -4096) == first);
int32_t acceleration[3];
uint32_t remote_sequence;
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &remote_sequence));
send_ack(0x16, 0);
send_status(false); // MP synthetic detach: the mapped ID still exists.
finish_setup();
uint8_t short_report[8] = {0x32};
feed(short_report, sizeof(short_report));
send_nunchuk_sample((const uint8_t[]){255, 255, 255, 255, 255, 255});
assert(expect_nunchuk_accel(-8192, 2048, -4096) == first);
uint32_t sequence;
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
assert(sequence == remote_sequence);
expect_accel();
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
// Different low bits distinguish the X/Y selectors and both relocated Z
// bits; clear bit0 is the missing precision, never a synthesized bit.
extension[5] = 0x94; // (694,380,548).
send_nunchuk_sample(extension);
assert(expect_nunchuk_accel(-8192, 1920, -4160) != first);
extension[5] = 0x64; // (692,382,546).
send_nunchuk_sample(extension);
first = expect_nunchuk_accel(-8106, 1792, -4096);
send_nunchuk_sample(extension);
assert(expect_nunchuk_accel(-8106, 1792, -4096) != first);
}
static void nunchuk_accel_calibration_is_independent_of_stick_and_remote(void) {
reset_fixture(0x0306, false, EXT_NUNCHUK);
f.fail_accel_reads = true;
set_nunchuk_stick_calibration((const uint8_t[]){128, 32, 128, 224, 32, 128});
connect_device();
send_nunchuk_sample((const uint8_t[]){160, 192, 173, 95, 137, 0xe5});
expect_nunchuk_accel(-8192, 2048, -4096);
assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == -341);
int32_t acceleration[3];
uint32_t sequence;
assert(!uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
// Each axis must have a real span, not merely a valid checksum or X span.
for (unsigned axis = 0; axis < 3; ++axis) {
reset_fixture(0x0330, true, EXT_NUNCHUK);
uint16_t one_g[3] = {693, 766, 647};
const uint16_t zero[3] = {501, 510, 519};
one_g[axis] = zero[axis];
set_nunchuk_accel_calibration(zero, one_g);
set_nunchuk_stick_calibration((const uint8_t[]){220, 40, 124, 210, 30, 126});
connect_device();
expect_nunchuk_stick(124, 126, 0, 0);
expect_nunchuk_stick(220, 30, 511, 511);
send_motion(7760, 8200, 7560, false, true, false);
expect_accel();
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
}
// Either checksum byte or an unreadable block disables only Nunchuk motion.
for (unsigned failure = 0; failure < 3; ++failure) {
reset_fixture(0x0306, false, EXT_NUNCHUK);
if (failure < 2)
f.nunchuk_calibration[14 + failure] ^= 1;
else
f.fail_read_address = 0xa40020;
connect_device();
send_nunchuk_controls(false);
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
}
}
static void nunchuk_accel_detach_and_replacement_require_fresh_calibration(void) {
for (unsigned passthrough = 0; passthrough < 2; ++passthrough) {
reset_fixture(passthrough ? 0x0330 : 0x0306, passthrough, EXT_NUNCHUK);
connect_device();
send_nunchuk_controls(passthrough);
int32_t acceleration[3];
uint32_t original_sequence;
assert(uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &original_sequence));
f.extension = EXT_NONE;
if (passthrough)
send_motion(7760, 8200, 7560, false, true, false);
else
send_status(false);
uint32_t sequence;
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
finish_setup();
// In-flight extension bytes must not resurrect the detached stream.
send_nunchuk_sample((const uint8_t[]){160, 192, 128, 128, 128, 0});
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
set_nunchuk_accel_calibration((const uint16_t[]){512, 512, 512},
(const uint16_t[]){712, 752, 672});
f.extension = EXT_NUNCHUK;
if (passthrough)
send_motion(7760, 8200, 7560, false, true, false);
else
send_status(true);
finish_setup();
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
send_nunchuk_controls(passthrough);
assert(expect_nunchuk_accel(0, 0, 0) != original_sequence);
expect_accel();
// Replacement with unreadable calibration must not inherit old points.
f.extension = EXT_NONE;
if (passthrough)
send_motion(7760, 8200, 7560, false, true, false);
else
send_status(false);
finish_setup();
f.fail_read_address = 0xa40020;
f.extension = EXT_NUNCHUK;
if (passthrough)
send_motion(7760, 8200, 7560, false, true, false);
else
send_status(true);
finish_setup();
send_nunchuk_controls(passthrough);
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
expect_accel();
}
}
static void run_case(const char* name, void (*test)(void)) {
printf("Wii parser: %s\n", name);
fflush(stdout);
@ -917,6 +1112,10 @@ static void run_case(const char* name, void (*test)(void)) {
int main(void) {
run_case("fresh calibrated accelerometer snapshots without MotionPlus", accelerometer_snapshot_requires_fresh_calibrated_reports);
run_case("plain Nunchuk independent packed acceleration", plain_nunchuk_accel_uses_own_packed_factory_points);
run_case("MotionPlus Nunchuk acceleration bitpacking and freshness", passthrough_nunchuk_accel_bitpacking_and_freshness);
run_case("Nunchuk accelerometer calibration independent of stick and Remote", nunchuk_accel_calibration_is_independent_of_stick_and_remote);
run_case("Nunchuk acceleration detach and replacement", nunchuk_accel_detach_and_replacement_require_fresh_calibration);
run_case("calibrated Nunchuk left-stick endpoints and replacement", calibrated_nunchuk_left_stick_endpoints_and_replacement);
run_case("unavailable Nunchuk calibration uses safe nominal travel", unavailable_nunchuk_calibration_keeps_safe_nominal_stick);
run_case("integrated MotionPlus calibration and per-axis slow bits", integrated_motionplus_calibration_and_slow_bits);