diff --git a/CMakeLists.txt b/CMakeLists.txt index 42594be..6c0cf01 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -316,6 +316,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") ${SWITCH_PICO_SOURCE_DIR}/platform/pico/pico_profile_storage.cpp ${SWITCH_PICO_SOURCE_DIR}/profile/controller_profile.cpp ${SWITCH_PICO_SOURCE_DIR}/profile/controller_profile_runtime.cpp + ${SWITCH_PICO_SOURCE_DIR}/input/wii_swing.cpp ${SWITCH_PICO_SOURCE_DIR}/profile/controller_profile_transform.cpp ${SWITCH_PICO_SOURCE_DIR}/profile/controller_synthetic_input.cpp ${SWITCH_PICO_SOURCE_DIR}/profile/profile_service.cpp diff --git a/README.md b/README.md index b493c07..4cb0174 100644 --- a/README.md +++ b/README.md @@ -367,7 +367,7 @@ Pair keys contain both complete Bluetooth addresses and address types, in canoni The read-only playtest endpoint (`0x39`) uses schema 5, 56 bytes: byte 55 identifies unspecified (0), Joy-Con 2 left solo (1), right solo (2), pair (3), legacy Wii Remote with unknown orientation (4), Wii Remote + Nunchuk (5), Wii horizontal (6), or Wii vertical (7). Host tools still read schema 2/54-byte, schema 3/55-byte and schema 4/56-byte payloads. Orientation requests (`0x3d`) contain the 14-byte controller identity, four-byte little-endian connection generation, and orientation byte (0 horizontal, 1 vertical); stale/replaced/non-Wii/extension targets are rejected before Bluetooth-core dispatch. These operations do not change profile records or persistent configuration. Profile names and controller aliases are stored as independently checksummed -catalog metadata. Runtime profiles use schema 7 and unchanged 384-byte records; schemas 1–6 migrate with extra inputs unmapped and existing settings preserved. Names remain separate. The +catalog metadata. Runtime profiles use schema 8 and unchanged 384-byte records; schemas 1–7 migrate with swing gestures disabled and existing settings preserved. Names remain separate. The editor can rename and copy profiles across controllers and slots, import or export JSON backups, and reset one section without discarding the rest of the draft. Its response-curve cards provide named presets, exact Q8.8 fine @@ -469,6 +469,7 @@ The AIO firmware enables motion automatically for original Wii Remotes with an e - **Nunchuk stick travel:** the parser reads the 16-byte extension calibration at `A40020` before activating MotionPlus. It validates both checksum bytes and the X/Y min/center/max ordering, then scales each side of each axis independently into the full normalized range, clamps overshoot, and inverts Y. It preserves the notched gate rather than expanding diagonals into square corners. Unreadable/invalid calibration uses documented nominal travel (center 128, ±96), not the full 0–255 byte range. Replacement Nunchuks reload their own calibration. Existing left-stick profile tuning applies; saved right-stick tuning is not copied or overwritten. - **Motion:** ordinary remotes use continuous `0x31`; MotionPlus and Nunchuk use combined `0x35` reports. The parser checks primary/backup accelerometer factory calibration and both fast/slow gyro calibration blocks, including checksums. Invalid calibration disables only the affected sensor rather than inventing readings. MotionPlus selects sensitivity independently for each axis; factory-only zero correction can retain temperature-dependent gyro drift. - **Nunchuk passthrough:** MotionPlus and Nunchuk samples alternate. Held Nunchuk controls persist across gyro packets, moved C/Z bits are decoded, and the remote's accelerometer remains the motion source. Attaching or removing a Nunchuk triggers serialized extension discovery, updates Studio's layout, and clears detached stick/C/Z state. Extension identifiers accept the upstream-compatible type suffix rather than requiring a vendor-specific prefix; initializing extensions get bounded reply-paced retries. Periodic discovery of an inactive external MotionPlus attached alone and Classic Controller MotionPlus passthrough remain unimplemented. Existing ordinary Classic Controller, Wii U Pro, Balance Board and uDraw paths remain separate. +- **Accelerometer swing → button:** in Studio's Macro section, **Wii Remote swing** selects a final output button, Low/Medium/High sensitivity, and an optional held modifier. Disabled by default in every profile. This works on original remotes without MotionPlus or a sensor bar, with either aiming source and even when gyro output is disabled. A fresh, sustained acceleration burst generates one 80 ms press; ordinary gravity-only tilt is rejected. The detector requires 120 ms of settled samples and at least 250 ms after the previous swing to rearm. High sensitivity needs less force but is more susceptible to accidental movement. Modifier release, stale samples, disconnects, profile changes, reserved profile hotkeys and active macros cancel gesture output; physical button holds remain intact. For Zelda on Switch, choose **Y** (logical `west`; use the Switch preview for Nintendo button labels). - **Experimental IR aiming:** `SWITCH_PICO_WII_IR_GYRO=ON` enables camera-derived aiming instead of the mutually exclusive IR mouse experiment. Hold **1 + 2 for two seconds** to switch sources: one rumble pulse selects physical gyro, two select IR. Physical gyro is the connection default. With a Nunchuk, hold **C + 1** to reposition without sending aim motion; 1 alone remains usable. IR horizontal motion uses Nintendo **gyro Z**, matching a captured physical MotionPlus yaw reference; gyro X remains neutral. Vertical output uses gyro Y with **1.5x pitch gain**, conservatively tuned against a physical-gyro capture without changing horizontal gain, camera geometry or filtering. The real accelerometer is retained. Tracking loss stops IR aiming rather than silently switching to physical gyro. Horizontal aiming is user-confirmed in-game; the increased vertical gain still requires in-game qualification. - **IR tracking:** gyro mode follows relative camera-frame movement of one to four persistent spots, without requiring both ends of the bar or expiring a continuously visible single spot after 80 ms. Mutual nearest matches tolerate camera-slot changes; conflicting motion, large jumps, stale reports and complete visibility loss rebaseline rather than replay missing movement. The IR mouse experiment retains its two-end bar model. Relative single-spot tracking cannot distinguish remote roll from pointing motion, so keep the remote upright for aiming. - **Hardware verification:** Remote Plus `B8:AE:6E:21:8F:C2` reconnected after flashing and delivered 100 real USB `0x30` reports with changing accelerometer and gyro values on all axes. Identify dispatched rumble without losing the link. All 72 profile records, names/aliases, active selections, nine pairings and adapter configuration generation 21 / CRC `b58672ac` were unchanged. The subsequent Studio fix detects the attached Nunchuk with live stick and gyro data and renders the matching layout and vertical lock. Native tests cover integrated/external detection, calibration/error boundaries, hotplug ordering, interleaved controls, orientation changes and stale-request rejection. External MotionPlus combinations still need physical qualification. @@ -656,7 +657,7 @@ Use the physical controller's row from `profiles list`, not the global fallback. can remove an approval after its profile-catalog entry has been forgotten. Approvals persist in adapter configuration schema 4 (232 bytes), alongside the Joy-Con default mode. Schema 1/2 migration starts with no approvals; schema 3 -migration preserves its approval list. Profile schema 7/catalog 3 are separate. +migration preserves its approval list. Profile schema 8/catalog 3 are separate. The native encoder preserves safe unity bytes when synchronized, otherwise encodes independent actuator/band/substep state with documented quantization. @@ -1119,7 +1120,7 @@ records are compacted into its peer and the new superblock is published last. Interrupted or corrupt appends leave the previous valid record available. Catalogs 1/2 and retired four-profile banks migrate through the alternate arena; the old published data is retained until all copies and the new superblock -verify. Schema 1–6 profiles retain their meaning when decoded as schema 7. +verify. Schema 1–7 profiles retain their meaning when decoded as schema 8. Keep a profile export before downgrading: older firmware cannot read the new catalog/profile format. diff --git a/patches/bluepad32-sdl3-imu.patch b/patches/bluepad32-sdl3-imu.patch index e02775e..780a94f 100644 --- a/patches/bluepad32-sdl3-imu.patch +++ b/patches/bluepad32-sdl3-imu.patch @@ -1256,6 +1256,21 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_switch.c b/src/compo logi("Switch: setup timer timeout, failed state: 0x%02x\n", ins->state); process_fsm(d); } +diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_wii.h b/src/components/bluepad32/include/parser/uni_hid_parser_wii.h +--- a/src/components/bluepad32/include/parser/uni_hid_parser_wii.h ++++ b/src/components/bluepad32/include/parser/uni_hid_parser_wii.h +@@ -8,1 +8,2 @@ ++#include + #include +@@ -46,3 +47,8 @@ + void uni_hid_parser_wii_request_report_type(struct uni_hid_device_s* d, wii_report_type_t report_type); ++ ++// Calibrated physical acceleration, independent of gyro/IR output selection. ++// Sequence advances only on a fresh accelerometer report, never status/acks. ++bool uni_hid_parser_wii_accel_snapshot(struct uni_hid_device_s* d, ++ int32_t accel[3], uint32_t* sequence); + + #endif // UNI_HID_PARSER_WII_H diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/components/bluepad32/parser/uni_hid_parser_wii.c --- a/src/components/bluepad32/parser/uni_hid_parser_wii.c +++ b/src/components/bluepad32/parser/uni_hid_parser_wii.c @@ -1328,7 +1343,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen wii_mode_t mode; /* horizontal, accel, vertical, rumble, etc.. */ enum wii_devtype dev_type; enum wii_exttype ext_type; -@@ -159,6 +167,29 @@ +@@ -159,6 +167,30 @@ uint16_t rumble_duration_ms; balance_board_calibration_t balance_board_calibration; @@ -1349,6 +1364,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen + // Two factory blocks, fast then slow; preserve all 16 bits of calibration. + uint8_t mp_calibration[32]; + int32_t accel[3]; ++ uint32_t accel_sequence; + int32_t gyro[3]; + int16_t nunchuk_x; + int16_t nunchuk_y; @@ -1358,7 +1374,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen // Debug only int debug_fd; // File descriptor where dump is saved -@@ -177,7 +208,6 @@ +@@ -177,7 +209,6 @@ static void process_drm_kae(uni_hid_device_t* d, const uint8_t* report, uint16_t len); static void process_drm_kee(uni_hid_device_t* d, const uint8_t* report, uint16_t len); static void process_drm_e(uni_hid_device_t* d, const uint8_t* report, uint16_t len); @@ -1366,7 +1382,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen static udraw_tablet_t process_udraw_tablet(const uint8_t* e, uint16_t len); static balance_board_t process_balance_board(uni_hid_device_t* d, const uint8_t* e, uint16_t len); -@@ -196,6 +226,43 @@ +@@ -196,6 +227,43 @@ static void on_wii_set_rumble_on(btstack_timer_source_t* ts); static void on_wii_set_rumble_off(btstack_timer_source_t* ts); static void wii_play_dual_rumble_now(struct uni_hid_device_s* d, uint16_t duration_ms); @@ -1410,7 +1426,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen // Constants static const char* wii_devtype_names[] = { -@@ -217,124 +284,436 @@ +@@ -217,124 +285,436 @@ // process_ functions @@ -1957,7 +1973,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen } static void process_req_data_read_calibration_data(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { -@@ -444,17 +823,83 @@ +@@ -444,17 +824,83 @@ // Defined here: http://wiibrew.org/wiki/Wiimote#0x21:_Read_Memory_Data static void process_req_data(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { @@ -2048,7 +2064,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen case WII_FSM_BALANCE_BOARD_DID_READ_CALIBRATION: process_req_data_read_calibration_data(d, report, len); break; -@@ -465,7 +910,6 @@ +@@ -465,7 +911,6 @@ process_req_data_dump_eeprom(d, report, len); break; default: @@ -2056,7 +2072,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen break; } } -@@ -473,29 +917,44 @@ +@@ -473,29 +918,44 @@ // Defined here: // http://wiibrew.org/wiki/Wiimote#0x22:_Acknowledge_output_report.2C_return_function_result static void process_req_return(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { @@ -2124,7 +2140,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen } } -@@ -514,15 +973,26 @@ +@@ -514,15 +974,26 @@ const uint8_t* data = &report[1]; wii_instance_t* ins = get_wii_instance(d); @@ -2160,7 +2176,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen } // Process misc buttons ctl->gamepad.misc_buttons |= (data[1] & 0x80) ? MISC_BUTTON_SYSTEM : 0; // Button "home" -@@ -563,46 +1033,101 @@ +@@ -563,46 +1034,116 @@ // Used for WiiMote in Accelerometer Mode. Defined here: // http://wiibrew.org/wiki/Wiimote#0x31:_Core_Buttons_and_Accelerometer static void process_drm_ka(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { @@ -2217,10 +2233,25 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen + ins->accel[0] = uni_imu_scale(ins->accel_zero[0] - x, ins->accel_span[0], UNI_IMU_ACCEL_RES_PER_G); + ins->accel[1] = uni_imu_scale(z - ins->accel_zero[2], ins->accel_span[2], UNI_IMU_ACCEL_RES_PER_G); + ins->accel[2] = uni_imu_scale(y - ins->accel_zero[1], ins->accel_span[1], UNI_IMU_ACCEL_RES_PER_G); ++ if (++ins->accel_sequence == 0) ++ ++ins->accel_sequence; + for (int i = 0; i < 3; i++) + d->controller.gamepad.accel[i] = ins->accel[i]; +} + ++bool uni_hid_parser_wii_accel_snapshot(uni_hid_device_t* d, ++ int32_t accel[3], uint32_t* sequence) { ++ if (!d || !accel || !sequence || d->report_parser.setup != uni_hid_parser_wii_setup) ++ return false; ++ wii_instance_t* ins = get_wii_instance(d); ++ if (!ins->accel_calibrated || ins->accel_sequence == 0) ++ return false; ++ for (int i = 0; i < 3; ++i) ++ accel[i] = ins->accel[i]; ++ *sequence = ins->accel_sequence; ++ return true; ++} ++ +static int16_t wii_nunchuk_axis(uint8_t raw, const uint8_t* calibration, bool invert) { + int center = calibration[2]; + int delta = (int)raw - center; @@ -2302,7 +2333,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen } // Used in WiiMote + Nunchuk Mode -@@ -617,31 +1142,17 @@ +@@ -617,31 +1158,17 @@ } wii_instance_t* ins = get_wii_instance(d); @@ -2342,7 +2373,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen // Better if uDraw reports its own "type", but for the moment // it gets reported and the 2nd half of a gamepad. -@@ -668,59 +1179,59 @@ +@@ -668,59 +1195,59 @@ ctl->gamepad.buttons |= n.button_lower ? BUTTON_Y : 0; } @@ -2450,7 +2481,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen static udraw_tablet_t process_udraw_tablet(const uint8_t* e, uint16_t len) { // uDraw Tablet format here: -@@ -1002,52 +1513,18 @@ +@@ -1002,52 +1529,18 @@ logi("fsm: req_status\n"); wii_instance_t* ins = get_wii_instance(d); ins->state = WII_FSM_DID_REQ_STATUS; @@ -2508,7 +2539,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen } static void wii_fsm_ext_read_register(uni_hid_device_t* d) { -@@ -1055,8 +1532,7 @@ +@@ -1055,8 +1548,7 @@ wii_instance_t* ins = get_wii_instance(d); ins->state = WII_FSM_EXT_DID_READ_REGISTER; @@ -2518,7 +2549,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen uint16_t bytes_to_read = 6; wii_read_mem(d, WII_READ_FROM_REGISTERS, offset, bytes_to_read); } -@@ -1066,8 +1542,7 @@ +@@ -1066,8 +1558,7 @@ wii_instance_t* ins = get_wii_instance(d); ins->state = WII_FSM_BALANCE_BOARD_DID_READ_CALIBRATION; @@ -2528,7 +2559,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen uint16_t bytes_to_read = 16; wii_read_mem(d, WII_READ_FROM_REGISTERS, offset, bytes_to_read); } -@@ -1077,8 +1552,7 @@ +@@ -1077,8 +1568,7 @@ wii_instance_t* ins = get_wii_instance(d); ins->state = WII_FSM_BALANCE_BOARD_DID_READ_CALIBRATION2; @@ -2538,7 +2569,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen uint16_t bytes_to_read = 8; wii_read_mem(d, WII_READ_FROM_REGISTERS, offset, bytes_to_read); } -@@ -1086,6 +1560,10 @@ +@@ -1086,6 +1576,10 @@ static void wii_fsm_assign_device(uni_hid_device_t* d) { logi("fsm: assign_device\n"); wii_instance_t* ins = get_wii_instance(d); @@ -2549,7 +2580,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen uint8_t dev = ins->dev_type; switch (dev) { case WII_DEVTYPE_UNK: -@@ -1100,18 +1578,8 @@ +@@ -1100,18 +1594,8 @@ } uint8_t report_type = 0xff; if (ins->ext_type == WII_EXT_NUNCHUK) { @@ -2570,7 +2601,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen } else if (ins->ext_type == WII_EXT_CLASSIC_CONTROLLER) { logi("Wii: requesting E (Classic Controller)\n"); d->controller_subtype = CONTROLLER_SUBTYPE_WII_CLASSIC; -@@ -1125,21 +1593,17 @@ +@@ -1125,21 +1609,17 @@ d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_UDRAW_TABLET; report_type = WII_REPORT_TYPE_KE; } else { @@ -2602,7 +2633,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen uni_hid_parser_wii_request_report_type(d, report_type); break; } -@@ -1164,7 +1628,10 @@ +@@ -1164,7 +1644,10 @@ ins->state = WII_FSM_LED_UPDATED; wii_process_fsm(d); @@ -2614,7 +2645,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen } static void wii_fsm_dump_eeprom(struct uni_hid_device_s* d) { -@@ -1218,7 +1685,15 @@ +@@ -1218,7 +1701,15 @@ // Do nothing break; case WII_FSM_DEV_GUESSED: @@ -2631,7 +2662,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen break; case WII_FSM_BALANCE_BOARD_READ_CALIBRATION: wii_fsm_balance_board_read_calibration(d); -@@ -1234,6 +1709,19 @@ +@@ -1234,6 +1725,19 @@ wii_fsm_update_led(d); break; case WII_FSM_LED_UPDATED: @@ -2651,7 +2682,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen break; default: loge("Wii: wii_process_fsm() unexpected state: %d\n", ins->state); -@@ -1249,10 +1737,10 @@ +@@ -1249,10 +1753,10 @@ ins->mode = WII_MODE_HORIZONTAL; ins->state = WII_FSM_SETUP; @@ -2666,7 +2697,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen // Dump EEPROM #if ENABLE_EEPROM_DUMP -@@ -1267,13 +1755,18 @@ +@@ -1267,13 +1771,18 @@ } void uni_hid_parser_wii_init_report(uni_hid_device_t* d) { @@ -2687,7 +2718,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen return; switch (report[0]) { case WIIPROTO_REQ_STATUS: -@@ -1291,6 +1784,14 @@ +@@ -1291,6 +1800,14 @@ case WII_REPORT_TYPE_KAE: process_drm_kae(d, report, len); break; @@ -2702,7 +2733,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen case WII_REPORT_TYPE_KEE: process_drm_kee(d, report, len); break; -@@ -1307,6 +1808,9 @@ +@@ -1307,6 +1824,9 @@ logi("Wii parser: unknown report type: 0x%02x\n", report[0]); printf_hexdump(report, len); } @@ -2712,7 +2743,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen } void uni_hid_parser_wii_set_player_leds(uni_hid_device_t* d, uint8_t leds) { -@@ -1371,29 +1875,25 @@ +@@ -1371,29 +1891,25 @@ void uni_hid_parser_wii_set_mode(uni_hid_device_t* d, wii_mode_t mode) { wii_instance_t* ins = get_wii_instance(d); @@ -2757,7 +2788,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen uni_hid_device_send_intr_report(d, report, sizeof(report)); } -@@ -1480,10 +1980,13 @@ +@@ -1480,10 +1996,13 @@ static void wii_read_mem(uni_hid_device_t* d, wii_read_type_t t, uint32_t offset, uint16_t size) { logi("****** read_mem: offset=0x%04x, size=%d from=%d\n", offset, size, t); @@ -2772,7 +2803,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen (offset & 0xff0000) >> 16, (offset & 0xff00) >> 8, (offset & 0xff), // Offset (size & 0xff00) >> 8, (size & 0xff), // Size in bytes // clang-format on -@@ -1491,6 +1994,15 @@ +@@ -1491,6 +2010,15 @@ uni_hid_device_send_intr_report(d, report, sizeof(report)); } diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index ca81d7c..b43e02c 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -209,6 +209,7 @@ struct BleIdentityMapping { struct BackendSlot { ControllerState state; + WiiAccelerometerSample accelerometer{}; uint16_t pre_hotkey_button_mask; ControllerIdentity identity; // Non-null with active=false is a connected device still becoming ready. @@ -1140,6 +1141,7 @@ void publish_all_neutral() { clear_switch2_ingress(slot); reset_switch2_outputs(slot); slot.state = make_neutral_state(); + slot.accelerometer = {}; slot.pre_hotkey_button_mask = 0; slot.identity = controller_identity_global(); slot.device = nullptr; @@ -1672,6 +1674,7 @@ void reset_slot_hotkeys(BackendSlot& slot) { #endif slot.motion_enabled = kDefaultMotionEnabled; slot.pre_hotkey_button_mask = 0; + slot.accelerometer = {}; slot.feedback_pending = false; slot.feedback_until_ms = 0; slot.pending_feedback = {}; @@ -3298,6 +3301,19 @@ void platform_on_controller_data(uni_hid_device_t* device, } observe_joycon_gesture( device, controller->gamepad, btstack_run_loop_get_time_ms()); + if (device->controller_type == CONTROLLER_TYPE_WiiController) { + int32_t acceleration[3]; + uint32_t sequence; + if (!uni_hid_parser_wii_accel_snapshot(device, acceleration, &sequence)) { + slot.accelerometer = {}; + } else if (!slot.accelerometer.valid || sequence != slot.accelerometer.sequence) { + slot.accelerometer = { + convert_accel(-static_cast(acceleration[2])), + convert_accel(-static_cast(acceleration[0])), + convert_accel(acceleration[1]), sequence, + btstack_run_loop_get_time_ms(), true}; + } + } const uni_gamepad_t gamepad = logical_gamepad(slot); uni_hid_device_t* owner = slot.device; const bool fresh_motion = @@ -3680,6 +3696,7 @@ void bluepad32_input_backend_snapshot(uint8_t slot_index, out->pre_hotkey_button_mask = slot.pre_hotkey_button_mask; out->state = slot.state; + out->accelerometer = slot.accelerometer; const uint32_t state_generation = slot.state_generation; critical_section_exit(&g_state_lock); diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index da59cdc..6d5f962 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -7,6 +7,7 @@ #include "profile/controller_profile.h" #include "core/controller_state.h" #include "input/controller_macro_capture.h" +#include "input/wii_swing.h" #include "usb/switch/switch_haptics.h" constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 4; @@ -62,6 +63,7 @@ struct Bluepad32SlotSnapshot { // Valid only for this snapshot's connection generation. uint16_t pre_hotkey_button_mask; ControllerState state; + WiiAccelerometerSample accelerometer{}; }; enum class Bluepad32ControllerLayout : uint8_t { diff --git a/src/firmware/input/wii_swing.cpp b/src/firmware/input/wii_swing.cpp new file mode 100644 index 0000000..b14547d --- /dev/null +++ b/src/firmware/input/wii_swing.cpp @@ -0,0 +1,93 @@ +#include "input/wii_swing.h" + +namespace { +constexpr uint32_t kStaleMs = 150; +constexpr uint32_t kMaximumSampleGapMs = 50; +constexpr uint32_t kSettleMs = 120; +constexpr uint32_t kPulseMs = 80; +constexpr uint32_t kRearmMs = 250; +constexpr uint32_t kEvidenceMs = 10; +constexpr int32_t kGravity = 4096; +constexpr int64_t square(int32_t value) { + return static_cast(value) * value; +} +} + +void WiiSwingDetector::reset() { + *this = {}; +} + +bool WiiSwingDetector::update(const WiiAccelerometerSample& sample, + uint32_t now_ms, uint8_t sensitivity, bool allowed) { + if (!allowed || !sample.valid || sensitivity > 2 || + static_cast(now_ms - sample.timestamp_ms) > static_cast(kStaleMs)) { + reset(); + return false; + } + if (pulsing_ && now_ms - fired_ms_ >= kPulseMs) pulsing_ = false; + if (initialized_ && sample.sequence == sequence_) return pulsing_; + const uint32_t elapsed = sample.timestamp_ms - sample_ms_; + if (initialized_ && (elapsed == 0 || elapsed > kMaximumSampleGapMs)) { + reset(); + } + sequence_ = sample.sequence; + sample_ms_ = sample.timestamp_ms; + const int32_t axes[3] = {sample.x, sample.y, sample.z}; + if (!initialized_) { + for (unsigned axis = 0; axis < 3; ++axis) gravity_[axis] = axes[axis]; + initialized_ = true; + return false; + } + + int64_t magnitude_squared = 0; + int64_t dynamic_squared = 0; + for (unsigned axis = 0; axis < 3; ++axis) { + magnitude_squared += square(axes[axis]); + dynamic_squared += square(axes[axis] - gravity_[axis]); + } + constexpr int32_t thresholds[3] = {2 * kGravity, 3 * kGravity / 2, kGravity}; + // Gravity alone changes direction during aiming. Require a force-magnitude + // excursion as well as gravity-subtracted acceleration to reject mere tilt. + const bool force_excursion = magnitude_squared > square(kGravity * 135 / 100) || + magnitude_squared < square(kGravity * 65 / 100); + const bool energetic = dynamic_squared > square(thresholds[sensitivity]) && force_excursion; + const bool settled = dynamic_squared < square(kGravity / 4) && + magnitude_squared > square(kGravity * 3 / 4) && + magnitude_squared < square(kGravity * 5 / 4); + + if (settled) { + if (!quiet_) { + quiet_ = true; + quiet_since_ms_ = sample_ms_; + } + if (sample_ms_ - quiet_since_ms_ >= kSettleMs && + (!fired_ || sample_ms_ - fired_ms_ >= kRearmMs)) { + armed_ = true; + } + } else { + quiet_ = false; + } + if (armed_ && energetic) { + if (!candidate_) { + candidate_ = true; + candidate_since_ms_ = sample_ms_; + } else if (sample_ms_ - candidate_since_ms_ >= kEvidenceMs) { + pulsing_ = true; + fired_ = true; + fired_ms_ = now_ms; + armed_ = false; + candidate_ = false; + quiet_ = false; + } + } else { + candidate_ = false; + } + // Track slow orientation changes, but do not absorb a swing into gravity. + if (!energetic) { + for (unsigned axis = 0; axis < 3; ++axis) { + gravity_[axis] += static_cast( + static_cast(axes[axis] - gravity_[axis]) * elapsed / (100 + elapsed)); + } + } + return pulsing_; +} diff --git a/src/firmware/input/wii_swing.h b/src/firmware/input/wii_swing.h new file mode 100644 index 0000000..b84b121 --- /dev/null +++ b/src/firmware/input/wii_swing.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +// Calibrated Wii acceleration in Nintendo units (4096 counts/g), captured +// independently of whether physical/IR gyro output is enabled or consumed. +struct WiiAccelerometerSample { + int16_t x = 0; + int16_t y = 0; + int16_t z = 0; + uint32_t sequence = 0; + uint32_t timestamp_ms = 0; + bool valid = false; +}; + +class WiiSwingDetector { +public: + void reset(); + // Returns an 80ms button pulse. Fresh observations must settle before + // arming/rearming; repeated reads cannot extend a pulse or build evidence. + bool update(const WiiAccelerometerSample& sample, uint32_t now_ms, + uint8_t sensitivity, bool allowed); + +private: + int32_t gravity_[3]{}; + uint32_t sequence_ = 0; + uint32_t sample_ms_ = 0; + uint32_t quiet_since_ms_ = 0; + uint32_t candidate_since_ms_ = 0; + uint32_t fired_ms_ = 0; + bool initialized_ = false; + bool quiet_ = false; + bool candidate_ = false; + bool armed_ = false; + bool pulsing_ = false; + bool fired_ = false; +}; diff --git a/src/firmware/profile/controller_profile.cpp b/src/firmware/profile/controller_profile.cpp index 150edfc..d7f48fc 100644 --- a/src/firmware/profile/controller_profile.cpp +++ b/src/firmware/profile/controller_profile.cpp @@ -296,6 +296,9 @@ bool controller_profile_validate(const ControllerProfile& profile) { } } if (!valid_source_control(profile.shortcuts.modifier) || + !valid_button(profile.swing.button) || + profile.swing.sensitivity > 2 || + !valid_source_control(profile.swing.modifier) || !valid_source_control(profile.shift.modifier) || static_cast(profile.shift.mode) > static_cast(ControllerProfileShiftMode::kToggle) || @@ -551,6 +554,9 @@ bool controller_profile_encode(const ControllerProfile& profile, profile.switching_chord >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL); output[363] = static_cast( profile.motion_toggle_chord >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL); + output[364] = profile.swing.button; + output[365] = profile.swing.sensitivity; + output[366] = profile.swing.modifier; return stream_offset <= CONTROLLER_PROFILE_MACRO_STREAM_SIZE; } @@ -583,6 +589,8 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, const bool has_expanded_settings = schema_version >= CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION; const bool has_extra_controls = + schema_version >= CONTROLLER_PROFILE_EXTRA_CONTROL_SCHEMA_VERSION; + const bool has_swing = schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION; if ((has_control_mapping ? input[61] != 0 || input[71] != 0 @@ -841,7 +849,9 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, return false; } } - if (!profile_bytes_are_zero(&input[364], 20)) { + const size_t reserved_offset = has_swing ? 367 : 364; + if (!profile_bytes_are_zero( + &input[reserved_offset], expected_size - reserved_offset)) { return false; } for (uint8_t index = 0; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) { @@ -860,6 +870,9 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, } } } + if (has_swing) { + profile.swing = {input[364], input[365], input[366]}; + } if (!controller_profile_validate(profile)) { return false; } diff --git a/src/firmware/profile/controller_profile.h b/src/firmware/profile/controller_profile.h index 6e65f17..c880944 100644 --- a/src/firmware/profile/controller_profile.h +++ b/src/firmware/profile/controller_profile.h @@ -11,7 +11,8 @@ constexpr uint16_t CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3; constexpr uint16_t CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4; constexpr uint16_t CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5; constexpr uint16_t CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION = 6; -constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 7; +constexpr uint16_t CONTROLLER_PROFILE_EXTRA_CONTROL_SCHEMA_VERSION = 7; +constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 8; constexpr size_t CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE = 256; constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 384; constexpr uint8_t CONTROLLER_PROFILE_COUNT = 8; @@ -187,6 +188,15 @@ struct ControllerProfileMacro { uint8_t repeat_count = 1; }; +struct ControllerProfileSwingConfiguration { + // Logical output button; NO_BUTTON disables the accelerometer gesture. + uint8_t button = CONTROLLER_PROFILE_NO_BUTTON; + // 0 low, 1 medium, 2 high sensitivity. + uint8_t sensitivity = 1; + // Optional physical logical control required while swinging. + uint8_t modifier = CONTROLLER_PROFILE_NO_BUTTON; +}; + struct ControllerProfile { uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; uint8_t extra_button_map[CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT]{ @@ -211,6 +221,7 @@ struct ControllerProfile { uint16_t turbo_override_mask = 0; ControllerProfileTurboSettings turbo_overrides[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; + ControllerProfileSwingConfiguration swing{}; }; struct ControllerProfileDatabaseEntry { diff --git a/src/firmware/profile/controller_profile_runtime.cpp b/src/firmware/profile/controller_profile_runtime.cpp index 158aaac..aee6a00 100644 --- a/src/firmware/profile/controller_profile_runtime.cpp +++ b/src/firmware/profile/controller_profile_runtime.cpp @@ -16,6 +16,7 @@ struct ControllerProfileRuntimeContext { bool profile_snapshot_valid = false; ControllerProfile profile{}; ControllerSyntheticInputContext synthetic{}; + WiiSwingDetector swing{}; bool runtime_generations_initialized = false; AdapterUsbMode output_mode = AdapterUsbMode::kSwitchProbe; uint32_t configuration_reset_generation = 0; @@ -145,6 +146,7 @@ void refresh_profile(ControllerProfileRuntimeContext* context, input_snapshot.state, context->profile); controller_synthetic_input_cancel( &context->synthetic, current_input_control_mask); + context->swing.reset(); const uint32_t hotkey_control_mask = (current_input_control_mask & ~0xffffu) | input_snapshot.pre_hotkey_button_mask; @@ -339,6 +341,7 @@ ControllerProfileTransformResult controller_profile_runtime_transform( context->configuration_reset_generation != reset_generation)) { controller_synthetic_input_cancel( &context->synthetic, state_control_mask); + context->swing.reset(); context->previous_hotkey_control_mask = input_control_mask; context->activation_requested = true; context->activation_transaction_id = 0; @@ -349,9 +352,24 @@ ControllerProfileTransformResult controller_profile_runtime_transform( const uint32_t consumed_controls = process_hotkeys(context, slot, input_control_mask); context->previous_hotkey_control_mask = input_control_mask; - return controller_synthetic_input_apply( + const bool macro_was_active = context->synthetic.macro_active; + ControllerProfileTransformResult result = controller_synthetic_input_apply( &context->synthetic, snapshot.state, context->profile, now_ms, consumed_controls, consumed_controls != 0); + const ControllerProfileSwingConfiguration& swing = context->profile.swing; + const bool modifier_held = swing.modifier == CONTROLLER_PROFILE_NO_BUTTON || + (swing.modifier < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT && + (input_control_mask & (1u << swing.modifier)) != 0); + const bool allowed = swing.button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT && + modifier_held && consumed_controls == 0 && + !macro_was_active && !context->synthetic.macro_active; + if (context->swing.update(snapshot.accelerometer, now_ms, swing.sensitivity, allowed)) { + controller_profile_apply_button_mask( + controller_profile_extract_button_mask(result.state) | + static_cast(1u << swing.button), + &result.state); + } + return result; } bool controller_profile_runtime_take_initial_profile_indication( diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 234aec5..1f251f6 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -143,7 +143,8 @@ PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3 PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4 PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5 PROFILE_EXPANDED_SCHEMA_VERSION = 6 -PROFILE_SCHEMA_VERSION = 7 +PROFILE_EXTRA_CONTROL_SCHEMA_VERSION = 7 +PROFILE_SCHEMA_VERSION = 8 PROFILE_LEGACY_SIZE = 256 PROFILE_SIZE = 384 PROFILE_CAPACITY = 8 @@ -286,6 +287,7 @@ RUMBLE_POLICIES = ("none", "rumble", "led", "rumble_and_led") TURBO_MODES = ("off", "turbo", "auto_burst", "burst") SHIFT_MODES = ("off", "hold", "toggle") MACRO_PLAYBACK_MODES = ("once", "while_held", "toggle", "repeat") +SWING_SENSITIVITIES = ("low", "medium", "high") SHORTCUT_SELECTOR_BUTTONS = LOGICAL_BUTTONS[:4] + LOGICAL_BUTTONS[12:] MACRO_STEP_TYPES = ("state", "end") MACRO_OVERRIDE_NAMES = ( @@ -718,7 +720,7 @@ def _control_index( ) -> int: if value is None: return PROFILE_NONE_BUTTON - controls = LOGICAL_CONTROLS if schema_version >= PROFILE_SCHEMA_VERSION else OUTPUT_CONTROLS + controls = LOGICAL_CONTROLS if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION else OUTPUT_CONTROLS if type(value) is not str or value not in controls: choices = ", ".join(controls) raise ConfigManagerError(f"{name} must be null or one of: {choices}") @@ -1632,7 +1634,7 @@ class ProfileShift: cls, value: Any, *, schema_version: int = PROFILE_SCHEMA_VERSION ) -> ProfileShift: fields = ["mode", "modifier", "button_map"] - if schema_version >= PROFILE_SCHEMA_VERSION: + if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION: fields.append("extra_button_map") obj = _require_object(value, fields, "profile.shift") mappings = _require_object( @@ -1640,7 +1642,7 @@ class ProfileShift: ) extras = ( _require_object(obj["extra_button_map"], EXTRA_BUTTONS, "profile.shift.extra_button_map") - if schema_version >= PROFILE_SCHEMA_VERSION else dict.fromkeys(EXTRA_BUTTONS) + if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION else dict.fromkeys(EXTRA_BUTTONS) ) return cls( _require_enum(obj["mode"], SHIFT_MODES, "profile.shift.mode"), @@ -1776,6 +1778,48 @@ class ControllerMacro: ) +@dataclass(frozen=True) +class ProfileSwing: + button: int = PROFILE_NONE_BUTTON + sensitivity: int = 1 + modifier: int = PROFILE_NONE_BUTTON + + def __post_init__(self) -> None: + if type(self.button) is not int or ( + self.button != PROFILE_NONE_BUTTON + and not 0 <= self.button < len(LOGICAL_BUTTONS) + ): + raise ConfigManagerError("invalid swing output button") + _require_int( + self.sensitivity, "swing sensitivity", 0, len(SWING_SENSITIVITIES) - 1 + ) + if type(self.modifier) is not int or ( + self.modifier != PROFILE_NONE_BUTTON + and not 0 <= self.modifier < len(LOGICAL_CONTROLS) + ): + raise ConfigManagerError("invalid swing modifier") + + def to_json_object(self) -> dict[str, Any]: + return { + "button": _button_name(self.button), + "sensitivity": SWING_SENSITIVITIES[self.sensitivity], + "modifier": _control_name(self.modifier), + } + + @classmethod + def from_json_object(cls, value: Any) -> ProfileSwing: + obj = _require_object( + value, ("button", "sensitivity", "modifier"), "profile.swing" + ) + return cls( + _button_index(obj["button"], "profile.swing.button"), + _require_enum( + obj["sensitivity"], SWING_SENSITIVITIES, "profile.swing.sensitivity" + ), + _control_index(obj["modifier"], "profile.swing.modifier"), + ) + + @dataclass(frozen=True) class ControllerProfile: button_map: tuple[int, ...] @@ -1795,6 +1839,7 @@ class ControllerProfile: turbo_defaults: TurboSettings = TurboSettings() turbo_overrides: tuple[TurboSettings | None, ...] = (None,) * len(LOGICAL_BUTTONS) extra_button_map: tuple[int, ...] = (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS) + swing: ProfileSwing = ProfileSwing() def __post_init__(self) -> None: if type(self.button_map) is not tuple or len(self.button_map) != len( @@ -1877,6 +1922,8 @@ class ControllerProfile: raise ConfigManagerError("profile shortcuts must be ProfileShortcuts") if not isinstance(self.shift, ProfileShift): raise ConfigManagerError("profile Shift must be ProfileShift") + if not isinstance(self.swing, ProfileSwing): + raise ConfigManagerError("profile swing must be ProfileSwing") if not isinstance(self.turbo_defaults, TurboSettings): raise ConfigManagerError("Turbo defaults must be TurboSettings") if ( @@ -1942,7 +1989,8 @@ class ControllerProfile: has_control_mapping = version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION has_action_controls = version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION sparse_macros = version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION - has_extra_buttons = version >= PROFILE_SCHEMA_VERSION + has_extra_buttons = version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION + has_swing = version >= PROFILE_SCHEMA_VERSION control_count = len(LOGICAL_CONTROLS) if has_extra_buttons else len(OUTPUT_CONTROLS) if sparse_macros: if payload[75] & 0xCC: @@ -2111,7 +2159,7 @@ class ControllerProfile: settings_offset += 3 if payload[settings_offset:336] != bytes(336 - settings_offset): raise ConfigManagerError("nonzero Turbo override padding") - reserved_offset = 364 if has_extra_buttons else 344 + reserved_offset = 367 if has_swing else (364 if has_extra_buttons else 344) if any(payload[reserved_offset:]): raise ConfigManagerError("profile reserved fields must be zero") elif any(mode > 2 for mode in turbo_modes): @@ -2135,6 +2183,7 @@ class ControllerProfile: turbo_defaults=turbo_defaults, turbo_overrides=tuple(turbo_overrides), extra_button_map=tuple(payload[344:351]) if has_extra_buttons else (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS), + swing=ProfileSwing(*payload[364:367]) if has_swing else ProfileSwing(), ) def to_bytes(self) -> bytes: @@ -2214,6 +2263,9 @@ class ControllerProfile: payload[351:358] = bytes(self.shift.extra_button_map) payload[362] = (self.switching_chord >> 18) & 0x7F payload[363] = (self.motion_toggle_chord >> 18) & 0x7F + payload[364:367] = bytes( + (self.swing.button, self.swing.sensitivity, self.swing.modifier) + ) return bytes(payload) def to_json_object(self) -> dict[str, Any]: @@ -2250,6 +2302,7 @@ class ControllerProfile: }, "shortcuts": self.shortcuts.to_json_object(), "shift": self.shift.to_json_object(), + "swing": self.swing.to_json_object(), "turbo_settings": { "defaults": self.turbo_defaults.to_json_object(), "overrides": { @@ -2297,8 +2350,10 @@ class ControllerProfile: ) if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION: fields.extend(("shortcuts", "shift", "turbo_settings")) - if schema_version >= PROFILE_SCHEMA_VERSION: + if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION: fields.append("extra_button_map") + if schema_version >= PROFILE_SCHEMA_VERSION: + fields.append("swing") obj = _require_object(value, fields, "profile") expected_size = ( PROFILE_SIZE @@ -2312,7 +2367,7 @@ class ControllerProfile: ) extras = ( _require_object(obj["extra_button_map"], EXTRA_BUTTONS, "profile.extra_button_map") - if schema_version >= PROFILE_SCHEMA_VERSION else dict.fromkeys(EXTRA_BUTTONS) + if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION else dict.fromkeys(EXTRA_BUTTONS) ) sticks = _require_object(obj["sticks"], ("left", "right"), "profile.sticks") triggers = _require_object( @@ -2482,6 +2537,11 @@ class ControllerProfile: _control_index(extras[name], f"profile.extra_button_map.{name}") for name in EXTRA_BUTTONS ), + swing=( + ProfileSwing.from_json_object(obj["swing"]) + if schema_version >= PROFILE_SCHEMA_VERSION + else ProfileSwing() + ), ) @classmethod @@ -3605,6 +3665,7 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: PROFILE_ACTION_CONTROL_SCHEMA_VERSION, PROFILE_SPARSE_MACRO_SCHEMA_VERSION, PROFILE_EXPANDED_SCHEMA_VERSION, + PROFILE_EXTRA_CONTROL_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile-list schema") @@ -3683,6 +3744,7 @@ def read_selected_profile(device: UsbDevice) -> ControllerProfile: PROFILE_ACTION_CONTROL_SCHEMA_VERSION, PROFILE_SPARSE_MACRO_SCHEMA_VERSION, PROFILE_EXPANDED_SCHEMA_VERSION, + PROFILE_EXTRA_CONTROL_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile schema") diff --git a/src/switch_pico_bridge/profile_web.py b/src/switch_pico_bridge/profile_web.py index 0646d80..2eb2beb 100644 --- a/src/switch_pico_bridge/profile_web.py +++ b/src/switch_pico_bridge/profile_web.py @@ -314,6 +314,7 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): "rumble_policies": list(config_manager.RUMBLE_POLICIES), "turbo_modes": list(config_manager.TURBO_MODES), "shift_modes": list(config_manager.SHIFT_MODES), + "swing_sensitivities": list(config_manager.SWING_SENSITIVITIES), "shortcut_selectors": list( config_manager.SHORTCUT_SELECTOR_BUTTONS ), diff --git a/src/switch_pico_bridge/web/profile_editor.css b/src/switch_pico_bridge/web/profile_editor.css index 1557a04..c3f3dc9 100644 --- a/src/switch_pico_bridge/web/profile_editor.css +++ b/src/switch_pico_bridge/web/profile_editor.css @@ -495,6 +495,8 @@ input[type="range"] { width: 100%; accent-color: var(--blue); } .action-card-heading > span { display: block; margin-top: 8px; color: var(--muted); font-size: 0.73rem; line-height: 1.45; } .action-kind { color: var(--section-accent); font-size: 0.62rem; font-weight: 850; letter-spacing: 0.11em; text-transform: uppercase; } +.swing-controls { margin-bottom: 18px; } +.swing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: 14px; } .macro-controls { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(280px, 0.9fr); gap: 14px; margin-bottom: 20px; } .macro-picker { display: flex; grid-column: 1 / -1; align-items: stretch; justify-content: space-between; gap: 14px; padding: 13px; border: 1px solid var(--line); border-radius: 16px; background: #090f1d; } .macro-tabs { display: grid; grid-template-columns: repeat(4, minmax(90px, 1fr)); gap: 8px; flex: 1; } diff --git a/src/switch_pico_bridge/web/profile_editor.html b/src/switch_pico_bridge/web/profile_editor.html index 74ca4ba..63aaae9 100644 --- a/src/switch_pico_bridge/web/profile_editor.html +++ b/src/switch_pico_bridge/web/profile_editor.html @@ -315,6 +315,7 @@
+
diff --git a/src/switch_pico_bridge/web/profile_editor.js b/src/switch_pico_bridge/web/profile_editor.js index c1331ef..3e128e0 100644 --- a/src/switch_pico_bridge/web/profile_editor.js +++ b/src/switch_pico_bridge/web/profile_editor.js @@ -131,6 +131,7 @@ const elements = { analog: document.querySelector("#analogFields"), rumble: document.querySelector("#rumbleFields"), builtinActions: document.querySelector("#builtinActions"), + swing: document.querySelector("#swingFields"), turbo: document.querySelector("#turboFields"), turboDefaults: document.querySelector("#turboDefaultFields"), turboTimingNotice: document.querySelector("#turboTimingNotice"), @@ -967,10 +968,11 @@ function buttonOptions( selected, includeNone = true, choices = state.schema.output_controls, - style = currentControllerStyle() + style = currentControllerStyle(), + noneLabel = "None" ) { const none = includeNone - ? `` + ? `` : ""; return none + choices.map((button) => ( `` @@ -1238,6 +1240,7 @@ function refreshSourceControls() { const modifiers = [ [elements.shortcutModifier.querySelector("select"), shortcuts.modifier, shortcuts.profiles], [elements.shift.querySelector("#shift-modifier"), state.profile.shift.modifier, []], + [elements.swing?.querySelector("#swing-modifier"), state.profile.swing.modifier, []], [elements.macroControls.querySelector("#macro-cancel"), state.profile.macros[state.selectedMacro].cancel, []], ]; for (const [select, selected, excluded] of modifiers) { @@ -1272,6 +1275,16 @@ function refreshSourceControls() { document.querySelectorAll("[data-output-label]").forEach(node => { node.textContent = controlLabel(node.dataset.outputLabel); }); + const swingButtonSelect = elements.swing?.querySelector("#swing-button"); + if (swingButtonSelect && state.profile.swing) { + swingButtonSelect.innerHTML = buttonOptions( + state.profile.swing.button, + true, + state.schema.buttons, + currentControllerStyle(), + "Disabled" + ); + } } elements.form.addEventListener("focusout", event => { @@ -2118,6 +2131,37 @@ function renderMacro() { state.schema.default_motion_toggle_chord ), ].join(""); + const swing = state.profile.swing; + const swingSensitivities = state.schema.swing_sensitivities; + elements.swing.innerHTML = ` +
+
+ Motion gesture +

Wii Remote swing

+
+ Accelerometer only · no sensor bar or MotionPlus required +
+
+
+ + +
+
+ + +
+
+ + +
+
+

Triggers one ~80 ms press per deliberate swing and must settle before rearming. High sensitivity triggers more easily but is more susceptible to accidental shakes. The optional held modifier is not consumed by the gesture and remains active for normal mapping.

`; elements.macroControls.innerHTML = `
@@ -2424,6 +2468,12 @@ function handleFormChange(event) { }); } updateTurboTiming(); + } else if (kind === "swing-button") { + state.profile.swing.button = target.value || null; + } else if (kind === "swing-sensitivity") { + state.profile.swing.sensitivity = target.value; + } else if (kind === "swing-modifier") { + state.profile.swing.modifier = target.value || null; } else if (kind === "macro-selector") { state.profile.macros[state.selectedMacro][target.dataset.field] = target.value || null; } else if (kind === "macro-playback" || kind === "macro-repeat") { @@ -2947,6 +2997,7 @@ document.querySelectorAll("[data-reset-section]").forEach((button) => { } else if (section === "macro") { state.profile.switching_chord = clone(defaults.switching_chord); state.profile.motion_toggle_chord = clone(defaults.motion_toggle_chord); + state.profile.swing = clone(defaults.swing); state.profile.macros = clone(defaults.macros); } renderEditor(); diff --git a/tests/bluepad32_native_stubs/parser/uni_hid_parser_wii.h b/tests/bluepad32_native_stubs/parser/uni_hid_parser_wii.h index 78d6d47..181c559 100644 --- a/tests/bluepad32_native_stubs/parser/uni_hid_parser_wii.h +++ b/tests/bluepad32_native_stubs/parser/uni_hid_parser_wii.h @@ -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 } diff --git a/tests/controller_profile_runtime_test.cpp b/tests/controller_profile_runtime_test.cpp index ba3597d..91e48aa 100644 --- a/tests/controller_profile_runtime_test.cpp +++ b/tests/controller_profile_runtime_test.cpp @@ -6,6 +6,7 @@ #include #include #include +#include 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(4096 * std::sin(angle)), 0, + static_cast(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; } diff --git a/tests/controller_profile_test.cpp b/tests/controller_profile_test.cpp index 90897f6..03ce4ee 100644 --- a/tests/controller_profile_test.cpp +++ b/tests/controller_profile_test.cpp @@ -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( + (legacy[98 + index * 6] & 3u) | ((24 - index) << 2)); + legacy[358 + index] = static_cast(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; } diff --git a/tests/profile_service_test.cpp b/tests/profile_service_test.cpp index 4174fa0..ca1b94b 100644 --- a/tests/profile_service_test.cpp +++ b/tests/profile_service_test.cpp @@ -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"; diff --git a/tests/profile_storage_test.cpp b/tests/profile_storage_test.cpp index c8a6045..406cd04 100644 --- a/tests/profile_storage_test.cpp +++ b/tests/profile_storage_test.cpp @@ -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(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(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() { diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index f773b92..8eef392 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -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" diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index bd96608..d13f785 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -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(" None: payload = bytearray(config_manager.ControllerProfile.default().to_bytes()) + struct.pack_into(" 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"), [ diff --git a/tests/test_controller_profile_runtime_native.py b/tests/test_controller_profile_runtime_native.py index ee81c4d..9c3b0dc 100644 --- a/tests/test_controller_profile_runtime_native.py +++ b/tests/test_controller_profile_runtime_native.py @@ -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), ], diff --git a/tests/test_profile_web.py b/tests/test_profile_web.py index f411264..f879bc8 100644 --- a/tests/test_profile_web.py +++ b/tests/test_profile_web.py @@ -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, ) diff --git a/tests/wii_parser_native_test.c b/tests/wii_parser_native_test.c index c3a910e..572729e 100644 --- a/tests/wii_parser_native_test.c +++ b/tests/wii_parser_native_test.c @@ -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);