Add configurable accelerometer swing gestures for Wii Remotes

This commit is contained in:
Joey Yakimowich-Payne 2026-09-10 08:01:24 -06:00
commit 9927e439b7
25 changed files with 831 additions and 64 deletions

View file

@ -13,6 +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;
}
#ifdef __cplusplus
}

View file

@ -6,6 +6,7 @@
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <cmath>
namespace {
@ -1215,6 +1216,103 @@ void test_extra_hotkeys_consume_mappings_and_rearm() {
"released extra switching chord did not rearm");
}
void test_accelerometer_swing_requires_evidence_and_settle() {
WiiSwingDetector detector;
WiiAccelerometerSample sample{};
sample.valid = true;
uint32_t now = UINT32_MAX - 100u;
auto feed = [&](int16_t x, int16_t y, int16_t z) {
now += 10;
sample = {x, y, z, sample.sequence + 1, now, true};
return detector.update(sample, now, 1, true);
};
for (int i = 0; i < 20; ++i)
require(!feed(0, 0, 4096), "resting gravity armed a swing output");
for (int degrees = 0; degrees <= 90; degrees += 3) {
const double angle = degrees * 0.017453292519943;
require(!feed(static_cast<int16_t>(4096 * std::sin(angle)), 0,
static_cast<int16_t>(4096 * std::cos(angle))),
"ordinary rotation of gravity triggered a sword swing");
}
for (int i = 0; i < 40; ++i) feed(4096, 0, 0);
require(!feed(4096, 10000, 0), "a single acceleration spike triggered a swing");
for (int i = 0; i < 5; ++i) {
now += 2;
require(!detector.update(sample, now, 1, true),
"repeated reads of one sample accumulated swing evidence");
}
require(feed(4096, 10000, 0), "sustained acceleration did not produce a swing");
for (int i = 0; i < 50; ++i) {
const bool pressed = feed(4096, 10000, 0);
if (i >= 8) require(!pressed, "continuous shaking retriggered without settling");
}
for (int i = 0; i < 35; ++i)
require(!feed(4096, 0, 0), "settling generated a second button pulse");
require(!feed(4096, -10000, 0) && feed(4096, -10000, 0),
"settled detector did not accept an opposite-direction swing");
require(!detector.update(sample, now + 151, 1, true),
"stale acceleration retained a button press");
}
void test_swing_output_isolated_from_motion_remaps_and_profiles() {
prepare_profiles();
rows[0].profiles[0].swing.button = 2; // Final logical west / Switch Y.
rows[0].profiles[0].button_map[2] = 1;
rows[1].profiles[0].swing.button = 2;
auto snapshot = make_snapshot(0);
snapshot.state.button_south = true;
snapshot.pre_hotkey_button_mask = 1;
uint32_t now = 0;
auto feed = [&](int16_t x) {
now += 10;
snapshot.accelerometer = {x, 0, 4096, snapshot.accelerometer.sequence + 1, now, true};
return runtime_transform(0, snapshot, now);
};
for (int i = 0; i < 20; ++i) feed(0);
feed(10000);
auto output = feed(10000);
require(output.state.button_west && output.state.button_south &&
!output.state.button_east && output.state.motion_sample_count == 0,
"accelerometer swing required gyro output, remapped its target, or lost physical input");
require(!runtime_transform(1, make_snapshot(1), now).state.button_west,
"a swing leaked to another controller slot");
snapshot.state.button_west = true;
rows[0].profiles[0].button_map[2] = 2;
++database_generation;
output = runtime_transform(0, snapshot, now + 1);
require(output.state.button_west, "gesture cancellation released a physical target button");
snapshot.state.button_west = false;
rows[0].profiles[0].swing.button = 3;
++database_generation;
output = runtime_transform(0, snapshot, now + 2);
require(!output.state.button_west && !output.state.button_north,
"profile refresh replayed a swing onto old or new target");
}
void test_swing_modifier_release_cancels_and_requires_fresh_settle() {
prepare_profiles();
rows[0].profiles[0].swing = {2, 1, 0};
auto snapshot = make_snapshot(0);
uint32_t now = 0;
auto feed = [&](int16_t x, bool held) {
now += 10;
apply_button_mask(held ? 1 : 0, &snapshot);
snapshot.accelerometer = {x, 0, 4096, snapshot.accelerometer.sequence + 1, now, true};
return runtime_transform(0, snapshot, now);
};
for (int i = 0; i < 20; ++i) feed(0, true);
feed(10000, true);
require(feed(10000, true).state.button_west, "held modifier failed to allow a swing");
require(!feed(10000, false).state.button_west, "modifier release retained swing output");
require(!feed(10000, true).state.button_west, "repressing modifier during motion retriggered");
for (int i = 0; i < 60; ++i) feed(0, true);
feed(10000, true);
require(feed(10000, true).state.button_west, "settling after modifier release did not rearm");
snapshot.active = false;
require(!runtime_transform(0, snapshot, now + 1).state.button_west,
"disconnect retained gesture output");
}
} // namespace
bool bluepad32_input_backend_toggle_motion(
@ -1288,5 +1386,8 @@ int main() {
test_held_synthetic_sources_and_disconnect_rearming();
test_shortcut_selector_rollover_without_modifier_release();
test_extra_hotkeys_consume_mappings_and_rearm();
test_accelerometer_swing_requires_evidence_and_settle();
test_swing_output_isolated_from_motion_remaps_and_profiles();
test_swing_modifier_release_cancels_and_requires_fresh_settle();
return 0;
}

View file

@ -123,9 +123,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] == 7 && encoded[1] == 0 &&
require(encoded[0] == 8 && encoded[1] == 0 &&
encoded[2] == 0x80 && encoded[3] == 1,
"profile header is not little-endian v7/384");
"profile header is not little-endian v8/384");
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
require(encoded[4 + index] == index,
@ -276,6 +276,10 @@ void test_legacy_profile_migration() {
kLegacyDefaultProfile, sizeof(kLegacyDefaultProfile),
&migrated),
"legacy default profile did not decode");
require(migrated.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
migrated.swing.sensitivity == 1 &&
migrated.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"legacy padding enabled a swing gesture");
require(migrated.triggers[0].digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
migrated.triggers[1].digital_threshold ==
@ -390,6 +394,37 @@ void test_legacy_profile_migration() {
migrated.macro_steps[0].output_button_mask == 1,
"legacy nonempty macro did not migrate into shared pool");
previous_encoded[60] = CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
previous_encoded[70] = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
previous_encoded[76] = 3;
previous_encoded[78] = 1;
previous_encoded[79] = 0;
previous_encoded[81] = 11;
previous_encoded[98] = 4;
for (uint8_t version = 3; version <= 4; ++version) {
previous_encoded[0] = version;
previous_encoded[75] = version == 4 ? 0x21 : 0;
migrated.swing = {2, 2, 24};
require(controller_profile_decode(
previous_encoded, sizeof(previous_encoded), &migrated) &&
migrated.triggers[0].output ==
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
migrated.triggers[1].output ==
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
migrated.switching_chord ==
(3u | (version == 4 ? 1u << 16 : 0u)) &&
migrated.motion_toggle_chord ==
(4u | (version == 4 ? 1u << 17 : 0u)) &&
migrated.macros[0].trigger_mask == 1 &&
migrated.macros[0].cancel_control == 11 &&
migrated.macros[0].step_count == 1 &&
migrated.macro_steps[0].output_button_mask == 1 &&
migrated.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
migrated.swing.sensitivity == 1 &&
migrated.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"legacy control mapping or action migration lost settings");
}
ControllerProfile current =
controller_profile_default(controller_identity_global(), 0);
current.triggers[0].digital_threshold = 0x8000;
@ -514,7 +549,10 @@ void test_set_b_sparse_extension_and_migration() {
decoded.macro_steps[7].right_trigger == 0xabcd &&
decoded.macros[0].mode == ControllerProfileMacroMode::kOnce &&
decoded.shortcuts.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
decoded.turbo_override_mask == 0,
decoded.turbo_override_mask == 0 &&
decoded.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
decoded.swing.sensitivity == 1 &&
decoded.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"schema5 full136-byte macro stream did not migrate");
require(controller_profile_encode(decoded, encoded, sizeof(encoded)) &&
memcmp(&legacy[4], &encoded[4], sizeof(legacy) - 4) == 0,
@ -595,6 +633,10 @@ void test_schema6_migration_preserves_every_setting() {
"schema6 profile could not upgrade");
require(memcmp(upgraded + 2, kLegacySchema6Profile + 2, 342) == 0,
"schema6 migration changed an existing encoded setting");
require(profile.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
profile.swing.sensitivity == 1 &&
profile.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"schema6 padding became a swing gesture");
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,
@ -681,6 +723,89 @@ void test_extra_control_schema_round_trip_and_output_limits() {
"Shift extra mapping admitted an analog destination");
}
void test_schema7_migration_preserves_extra_controls_and_macros() {
uint8_t legacy[sizeof(kLegacySchema6Profile)]{};
memcpy(legacy, kLegacySchema6Profile, sizeof(legacy));
legacy[0] = 7;
const uint8_t extra_map[] = {0, 16, 17, 12, 13, 14, 15};
const uint8_t shifted_map[] = {15, 14, 13, 12, 3, 2, 0xff};
memcpy(legacy + 344, extra_map, sizeof(extra_map));
memcpy(legacy + 351, shifted_map, sizeof(shifted_map));
legacy[256] = 18;
legacy[266] = 24;
for (uint8_t index = 0; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) {
legacy[98 + index * 6] = static_cast<uint8_t>(
(legacy[98 + index * 6] & 3u) | ((24 - index) << 2));
legacy[358 + index] = static_cast<uint8_t>(1u << index);
}
legacy[362] = 0x55;
legacy[363] = 0x2a;
ControllerProfile migrated{};
migrated.swing = {2, 2, 24};
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, 362) == 0 &&
migrated.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
migrated.swing.sensitivity == 1 &&
migrated.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"schema7 migration lost existing settings or enabled swing");
for (size_t offset = 364; offset < sizeof(legacy); ++offset) {
legacy[offset] = 1;
require(!controller_profile_decode(legacy, sizeof(legacy), &migrated),
"schema7 interpreted reserved padding as swing settings");
legacy[offset] = 0;
}
}
void test_swing_wire_settings_and_rejection() {
ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
profile.swing = {15, 2, 24};
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
ControllerProfile decoded{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
encoded[364] == 15 && encoded[365] == 2 && encoded[366] == 24 &&
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");
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");
}
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) {
encoded[offset] = 1;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"nonzero swing extension reservation was accepted");
encoded[offset] = 0;
}
profile.swing = {CONTROLLER_PROFILE_NO_BUTTON, 0,
CONTROLLER_PROFILE_NO_BUTTON};
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
decoded.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
decoded.swing.sensitivity == 0 &&
decoded.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
"disabled swing did not preserve sensitivity and absent modifier");
}
} // namespace
int main() {
test_pair_identity_wire_and_member_validation();
@ -691,5 +816,7 @@ int main() {
test_legacy_database_strides();
test_schema6_migration_preserves_every_setting();
test_extra_control_schema_round_trip_and_output_limits();
test_schema7_migration_preserves_extra_controls_and_macros();
test_swing_wire_settings_and_rejection();
return 0;
}

View file

@ -295,7 +295,7 @@ void test_profile_bounds_and_transaction_namespace() {
"transaction namespaces were not enforced");
}
void test_schema7_validation_and_atomic_selection() {
void test_current_schema_validation_and_atomic_selection() {
const ControllerIdentity id = stable_identity();
require(profile_service_select(id, 6) ==
ConfigurationTransactionStatus::kCommitted,
@ -308,6 +308,7 @@ void test_schema7_validation_and_atomic_selection() {
updated.turbo_defaults = {30, 1, 255};
updated.extra_button_map[6] = 16;
updated.shift.extra_button_map[0] = 3;
updated.swing = {2, 2, 24};
updated.macros[0].trigger_mask = 1u << 24;
updated.macros[0].cancel_control = 19;
updated.macros[0].step_count = 1;
@ -320,11 +321,15 @@ void test_schema7_validation_and_atomic_selection() {
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
"extended service profile did not encode");
require(profile_service_begin(20, id, 6, 6, 384, 0) ==
require(profile_service_begin(
20, id, 6, CONTROLLER_PROFILE_EXTRA_CONTROL_SCHEMA_VERSION,
CONTROLLER_PROFILE_ENCODED_SIZE, 0) ==
ConfigurationTransactionStatus::kUnsupportedSchema &&
profile_service_begin(21, id, 6, 7, 256, 0) ==
profile_service_begin(21, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION,
256, 0) ==
ConfigurationTransactionStatus::kMalformed &&
profile_service_begin(22, id, 6, 7, 385, 0) ==
profile_service_begin(22, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION,
385, 0) ==
ConfigurationTransactionStatus::kTooLarge,
"service admitted old-schema or incorrectly-sized writes");
@ -347,6 +352,25 @@ void test_schema7_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");
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
"valid replacement did not encode");
require(profile_service_begin(
@ -373,10 +397,16 @@ void test_schema7_validation_and_atomic_selection() {
selected.profile.macros[0].repeat_count == 255 &&
selected.profile.extra_button_map[6] == 16 &&
selected.profile.shift.extra_button_map[0] == 3 &&
selected.profile.swing.button == 2 &&
selected.profile.swing.sensitivity == 2 &&
selected.profile.swing.modifier == 24 &&
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.macros[0].trigger_mask == (1u << 24) &&
active_snapshot(id).profile.macros[0].cancel_control == 19 &&
active_snapshot(id).profile.turbo_defaults.rate_hz == 30,
"committed schema7 profile did not atomically refresh snapshots");
"committed profile did not atomically refresh snapshots");
}
void test_catalog1_selected_and_active_snapshots_migrate() {
@ -568,7 +598,7 @@ ProfileStorageIo pico_profile_storage_io() { return fake_io(); }
int main() {
test_eight_profile_transactions_and_active_cache();
test_profile_bounds_and_transaction_namespace();
test_schema7_validation_and_atomic_selection();
test_current_schema_validation_and_atomic_selection();
test_catalog1_selected_and_active_snapshots_migrate();
test_pair_publication_failure_recovery_and_independence();
std::cout << "profile service tests passed\n";

View file

@ -522,6 +522,15 @@ void test_compaction_preserves_latest_records() {
"compaction metadata did not append");
profile.extra_button_map[0] = 16;
profile.shift.extra_button_map[6] = 15;
profile.swing = {2, 2, 24};
ControllerProfile other = controller_profile_default(global, 7);
other.swing = {15, 0, CONTROLLER_PROFILE_NO_BUTTON};
const ControllerIdentity stable = identity(1);
require(storage.set(global, 7, other) == ProfileStorageResult::kOk,
"second swing profile did not append");
other.swing = {0, 1, 16};
require(storage.set(stable, 0, other) == ProfileStorageResult::kOk,
"stable identity swing profile did not append");
for (uint16_t write = 1; write <= 260; ++write) {
profile.weak_rumble_scale = static_cast<uint8_t>(write);
require(storage.set(global, 0, profile) == ProfileStorageResult::kOk,
@ -538,6 +547,9 @@ void test_compaction_preserves_latest_records() {
recovered.weak_rumble_scale == static_cast<uint8_t>(260) &&
recovered.extra_button_map[0] == 16 &&
recovered.shift.extra_button_map[6] == 15 &&
recovered.swing.button == 2 &&
recovered.swing.sensitivity == 2 &&
recovered.swing.modifier == 24 &&
reloaded.get_alias(global, metadata, sizeof(metadata)) ==
ProfileStorageResult::kOk &&
strcmp(metadata, "Fallback") == 0 &&
@ -546,6 +558,17 @@ void test_compaction_preserves_latest_records() {
ProfileStorageResult::kOk &&
strcmp(metadata, "Compacted") == 0,
"compaction did not preserve profiles and metadata");
require(reloaded.get(global, 7, &recovered) == ProfileStorageResult::kOk &&
recovered.swing.button == 15 &&
recovered.swing.sensitivity == 0 &&
recovered.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
reloaded.get(stable, 0, &recovered) == ProfileStorageResult::kOk &&
recovered.swing.button == 0 &&
recovered.swing.sensitivity == 1 &&
recovered.swing.modifier == 16 &&
reloaded.get(global, 1, &recovered) == ProfileStorageResult::kOk &&
recovered.swing.button == CONTROLLER_PROFILE_NO_BUTTON,
"compacted swing settings bled across profiles or identities");
}
void test_legacy_migration_is_atomic_and_complete() {

View file

@ -102,6 +102,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
/ "controller_profile_runtime.cpp"
),
str(root / "src" / "firmware" / "profile" / "profile_storage.cpp"),
str(root / "src" / "firmware" / "input" / "wii_swing.cpp"),
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
str(
root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"

View file

@ -1823,6 +1823,7 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD,
)
migrated_default = config_manager.ControllerProfile.from_bytes(legacy_default_wire)
assert migrated_default.swing.button == config_manager.PROFILE_NONE_BUTTON
assert (
migrated_default.left_trigger.digital_threshold
== config_manager.PROFILE_DEFAULT_DIGITAL_THRESHOLD
@ -1857,6 +1858,7 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
macro_cancel=1,
)
migrated_previous = config_manager.ControllerProfile.from_bytes(previous_wire)
assert migrated_previous.swing.button == config_manager.PROFILE_NONE_BUTTON
assert migrated_previous.left_trigger.output == 16
assert migrated_previous.right_trigger.output == 17
assert migrated_previous.macros[0].trigger_mask == 1
@ -1867,7 +1869,7 @@ 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"):
for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map", "swing"):
del legacy_json_object[field]
del legacy_json_object["motion_toggle_chord"]
del legacy_json_object["triggers"]["left"]["output"]
@ -1930,7 +1932,7 @@ 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"):
for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map", "swing"):
del obj[field]
for macro in obj["macros"]:
del macro["playback"]
@ -1970,7 +1972,8 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
assert encoded[294:336] == bytes(42)
assert encoded[336:344] == bytes((3, 255, 0, 1, 1, 1, 2, 1))
assert encoded[344:358] == bytes([255]) * 14
assert encoded[358:] == bytes(26)
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
@ -1981,6 +1984,7 @@ 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"]
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
device = FakeDevice()
@ -1994,7 +1998,8 @@ 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")
def test_schema7_extra_controls_keep_output_channels_and_wire_layout() -> None:
@pytest.mark.parametrize("version", [7, 8])
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(
config_manager.EXTRA_BUTTONS,
@ -2013,16 +2018,32 @@ def test_schema7_extra_controls_keep_output_channels_and_wire_layout() -> None:
for index, names in enumerate((["c", "gl"], ["gr"], ["left_sl", "left_sr"], ["right_sl", "right_sr"])):
obj["macros"][index]["trigger"] = names
obj["macros"][index]["cancel"] = config_manager.EXTRA_BUTTONS[index + 3]
obj["schema_version"] = version
if version == 7:
del obj["swing"]
profile = config_manager.ControllerProfile.from_json_object(obj)
encoded = profile.to_bytes()
assert encoded[:4] == struct.pack("<HH", 7, 384)
assert encoded[:4] == struct.pack("<HH", config_manager.PROFILE_SCHEMA_VERSION, 384)
assert encoded[344:351] == bytes((0, 17, 16, 255, 14, 7, 9))
assert encoded[351:358] == bytes((1, 255, 2, 3, 8, 12, 15))
assert encoded[358:364] == bytes((3, 4, 24, 96, 65, 10))
assert encoded[364:] == bytes(20)
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile
assert len(profile.button_map) == len(profile.shift.button_map) == len(profile.turbo_modes) == 16
assert profile.swing.button == config_manager.PROFILE_NONE_BUTTON
legacy_wire = bytearray(encoded)
struct.pack_into("<H", legacy_wire, 0, version)
if version == 7:
legacy_wire[364:] = bytes(20)
device = FakeDevice()
device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire)
assert config_manager.read_profile(device, device.stable_identity, 1) == profile
listing = config_manager.parse_profile_list(config_manager.parse_response(
make_response(config_manager.OP_PROFILE_LIST, device._profile_list_payload(), schema=version),
config_manager.OP_PROFILE_LIST,
))
assert listing[1].identity == device.stable_identity
assert listing[1].active_profile_index == 1
@pytest.mark.parametrize("path", [
@ -2058,6 +2079,8 @@ def test_schema6_rejects_schema7_controls_in_old_fields(offset: int, value: int)
])
def test_schema7_rejects_extra_map_and_mask_overflow(offset: int, value: int) -> None:
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
struct.pack_into("<H", payload, 0, 7)
payload[364:] = bytes(20)
payload[offset] = value
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_bytes(payload)
@ -2083,6 +2106,7 @@ def test_legacy_control_profiles_preserve_custom_actions(version: int) -> None:
assert profile.macros[0].trigger_mask == 3
assert profile.macros[0].cancel_control == (17 if version == 4 else 2)
assert profile.extra_button_map == (255,) * 7
assert profile.swing.button == config_manager.PROFILE_NONE_BUTTON
obj = profile.to_json_object()
obj["schema_version"] = version
obj["size"] = 256
@ -2091,12 +2115,91 @@ 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"):
for key in ("shortcuts", "shift", "turbo_settings", "extra_button_map", "swing"):
del obj[key]
assert config_manager.ControllerProfile.from_json_object(obj) == profile
assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()) == profile
@pytest.mark.parametrize(("button", "sensitivity", "modifier"), [
("south", "low", "right_sr"),
("dpad_right", "high", "south"),
("west", "medium", "right_trigger"),
(None, "high", None),
])
def test_swing_profile_round_trip_preserves_other_settings(
button: str | None, sensitivity: str, modifier: str | None,
) -> None:
before = custom_profile()
obj = before.to_json_object()
obj["swing"] = {
"button": button, "sensitivity": sensitivity, "modifier": modifier,
}
profile = config_manager.ControllerProfile.from_json_object(obj)
assert replace(profile, swing=before.swing) == before
assert profile.to_json_object()["swing"] == obj["swing"]
assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile
assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()) == profile
device = FakeDevice()
config_manager.write_profile(device, device.stable_identity, 2, profile, 1.0)
assert config_manager.read_profile(device, device.stable_identity, 2) == profile
@pytest.mark.parametrize(("field", "value"), [
("button", "left_trigger"),
("button", "right_sr"),
("button", 0),
("sensitivity", "extreme"),
("sensitivity", None),
("sensitivity", 1),
("modifier", "unknown"),
("modifier", False),
])
def test_swing_rejects_invalid_json_settings(field: str, value: object) -> None:
obj = config_manager.ControllerProfile.default().to_json_object()
obj["swing"][field] = value
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
@pytest.mark.parametrize(("field", "value"), [
("button", -1), ("button", 16), ("button", True),
("sensitivity", -1), ("sensitivity", 3), ("sensitivity", False),
("modifier", -1), ("modifier", 25), ("modifier", 256),
])
def test_swing_rejects_invalid_in_memory_settings(field: str, value: object) -> None:
with pytest.raises(config_manager.ConfigManagerError):
replace(config_manager.ProfileSwing(), **{field: value})
@pytest.mark.parametrize(("offset", "value"), [
(364, 16), (365, 3), (366, 25), (367, 1),
])
def test_swing_rejects_corrupt_wire_settings(offset: int, value: int) -> None:
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
payload[offset] = value
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_bytes(payload)
@pytest.mark.parametrize("mutation", [
"missing", "unknown", "missing_button", "missing_sensitivity", "missing_modifier",
"legacy_field",
])
def test_swing_json_fields_are_strict(mutation: str) -> None:
obj = config_manager.ControllerProfile.default().to_json_object()
if mutation == "missing":
del obj["swing"]
elif mutation == "unknown":
obj["swing"]["enabled"] = False
elif mutation.startswith("missing_"):
del obj["swing"][mutation.removeprefix("missing_")]
else:
obj["schema_version"] = 7
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_json_object(obj)
@pytest.mark.parametrize(
("path", "value"),
[

View file

@ -24,6 +24,7 @@ def test_controller_profile_runtime_native(tmp_path: Path) -> None:
str(root / "src" / "firmware" / "profile" / "controller_profile_transform.cpp"),
str(root / "src" / "firmware" / "profile" / "controller_synthetic_input.cpp"),
str(root / "src" / "firmware" / "profile" / "controller_profile_runtime.cpp"),
str(root / "src" / "firmware" / "input" / "wii_swing.cpp"),
"-o",
str(executable),
],

View file

@ -106,6 +106,7 @@ def test_editor_serves_assets_and_complete_schema(
assert schema["controls"] == list(config_manager.LOGICAL_CONTROLS)
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["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES)
assert schema["profile_capacity"] == 8
assert (
@ -141,15 +142,19 @@ def test_switch2_input_choices_are_never_output_targets(
assert schema["output_controls"] == list(config_manager.OUTPUT_CONTROLS)
def test_editor_migrates_schema6_and_saves_extra_mappings_without_metadata_loss(
monkeypatch: pytest.MonkeyPatch,
@pytest.mark.parametrize("version", [6, 7])
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()
legacy_wire = bytearray(profile.to_bytes())
legacy_wire[:2] = b"\x06\x00"
legacy_wire[344:] = bytes(40)
legacy_wire[:2] = version.to_bytes(2, "little")
if version == 6:
legacy_wire[344:] = bytes(40)
else:
legacy_wire[364:] = bytes(20)
device.profiles[key] = bytes(legacy_wire)
device.profile_aliases[key[0]] = "Living room"
device.profile_names[key] = "Racing"
@ -158,6 +163,7 @@ def test_editor_migrates_schema6_and_saves_extra_mappings_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
draft = migrated["profile"]
draft["extra_button_map"] = dict(zip(config_manager.EXTRA_BUTTONS, config_manager.LOGICAL_BUTTONS[:7]))
draft["shift"]["mode"] = "hold"
@ -167,6 +173,9 @@ def test_editor_migrates_schema6_and_saves_extra_mappings_without_metadata_loss(
draft["macros"][0]["cancel"] = "gr"
draft["switching_chord"] = ["left_sl", "left_sr"]
draft["motion_toggle_chord"] = ["right_sl", "c"]
draft["swing"] = {
"button": "west", "sensitivity": "high", "modifier": "right_trigger",
}
status, validated = request_json(
f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token,
)

View file

@ -112,6 +112,7 @@ static void reset_fixture(uint16_t product_id, bool motionplus, extension_t exte
memset(&f, 0, sizeof(f));
f.device.vendor_id = 0x057e;
f.device.product_id = product_id;
f.device.report_parser.setup = uni_hid_parser_wii_setup;
f.device.conn.connected = true;
f.device.conn.interrupt_cid = 0x40;
f.extension = extension;
@ -454,6 +455,31 @@ static void absent_motionplus_keeps_calibrated_remote(void) {
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
}
static void accelerometer_snapshot_requires_fresh_calibrated_reports(void) {
reset_fixture(0x0306, false, EXT_NONE);
connect_device();
int32_t acceleration[3];
uint32_t sequence;
assert(!uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
send_core_and_accel();
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
expect_vector(acceleration, -8192, 2048, -4096);
const uint32_t first = sequence;
send_ack(0x16, 0);
const uint8_t short_report[5] = {0x31};
feed(short_report, sizeof(short_report));
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
assert(sequence == first);
send_core_and_accel();
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
assert(sequence != first); // Identical readings can still be fresh.
reset_fixture(0x0306, false, EXT_NONE);
f.fail_accel_reads = true;
connect_device();
send_core_and_accel();
assert(!uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
}
static void setup_read_and_write_errors_leave_buttons_ready(void) {
reset_fixture(0x0330, true, EXT_NONE);
f.fail_read_address = 0xa60030;
@ -890,6 +916,7 @@ 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("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);