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

@ -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

View file

@ -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.

View file

@ -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 <stdbool.h>
#include <stdint.h>
@@ -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));
}

View file

@ -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<int64_t>(acceleration[2])),
convert_accel(-static_cast<int64_t>(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);

View file

@ -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 {

View file

@ -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<int64_t>(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<int32_t>(now_ms - sample.timestamp_ms) > static_cast<int32_t>(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<int32_t>(
static_cast<int64_t>(axes[axis] - gravity_[axis]) * elapsed / (100 + elapsed));
}
}
return pulsing_;
}

View file

@ -0,0 +1,37 @@
#pragma once
#include <stdint.h>
// 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;
};

View file

@ -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<uint8_t>(profile.shift.mode) >
static_cast<uint8_t>(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<uint8_t>(
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;
}

View file

@ -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 {

View file

@ -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<uint16_t>(1u << swing.button),
&result.state);
}
return result;
}
bool controller_profile_runtime_take_initial_profile_indication(

View file

@ -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")

View file

@ -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
),

View file

@ -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; }

View file

@ -315,6 +315,7 @@
<div class="panel-heading-actions"><button class="section-reset" type="button" data-reset-section="macro">Reset section</button></div>
</div>
<div class="builtin-actions" id="builtinActions"></div>
<div class="subpanel swing-controls" id="swingFields"></div>
<div class="macro-controls" id="macroControls"></div>
<section class="macro-capture" id="macroCapture" aria-labelledby="macroCaptureTitle" data-state="idle">
<div class="macro-preview-heading">

View file

@ -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
? `<option value=""${selected === null ? " selected" : ""}>None</option>`
? `<option value=""${selected === null ? " selected" : ""}>${escapeHtml(noneLabel)}</option>`
: "";
return none + choices.map((button) => (
`<option value="${button}"${selected === button ? " selected" : ""}>${escapeHtml(controlLabel(button, style))}</option>`
@ -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 = `
<div class="subpanel-heading">
<div>
<span class="action-kind">Motion gesture</span>
<h4>Wii Remote swing</h4>
</div>
<span>Accelerometer only · no sensor bar or MotionPlus required</span>
</div>
<div class="swing-grid">
<div class="control-card">
<label for="swing-button">Output button</label>
<select class="select" id="swing-button" data-kind="swing-button" data-output-select="swing">
${buttonOptions(swing.button, true, state.schema.buttons, controllerStyle, "Disabled")}
</select>
</div>
<div class="control-card">
<label for="swing-sensitivity">Sensitivity</label>
<select class="select" id="swing-sensitivity" data-kind="swing-sensitivity">
${modeOptions(swingSensitivities, swing.sensitivity)}
</select>
</div>
<div class="control-card">
<label for="swing-modifier">Held modifier (optional)</label>
<select class="select" id="swing-modifier" data-kind="swing-modifier">
${modifierOptions(swing.modifier)}
</select>
</div>
</div>
<p class="field-help">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.</p>`;
elements.macroControls.innerHTML = `
<div class="macro-picker">
<div class="macro-tabs" role="group" aria-label="Choose a macro draft">
@ -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();

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);