Add Nunchuk and combined motion gestures with button or macro actions
This commit is contained in:
parent
4cbe8348ee
commit
485ad39709
26 changed files with 1969 additions and 204 deletions
11
README.md
11
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 8 and unchanged 384-byte records; schemas 1–7 migrate with swing gestures disabled and existing settings preserved. Names remain separate. The
|
||||
catalog metadata. Runtime profiles use schema 9 and unchanged 384-byte records. Schemas 1–8 retain existing settings, including schema-8 Remote swing mappings; new Nunchuk and combined actions default to disabled. 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,7 +469,10 @@ 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. Initially, settle the remote for 120 ms to arm it. Subsequent strokes require only a 20 ms lower-force gap and at least 200 ms between presses, so back-and-forth swings do not require full stops. Early rebounds are discarded rather than replayed when the cooldown expires. 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).
|
||||
- **Motion gesture bindings:** Studio's Macro section provides **Wii Remote swing**, **Nunchuk swing**, and **Both together**. Each can select a final output button or one of the four configured macros, plus an optional held modifier. Remote and Nunchuk have independent Low/Medium/High sensitivity. All new bindings default to disabled; existing Remote bindings are preserved. No sensor bar or MotionPlus is required, and gestures remain available when gyro output is disabled. For Zelda on Switch, choose **Y** (logical `west`; use the Switch preview for Nintendo button labels).
|
||||
- **Combined priority:** when Both together is enabled and both calibrated sensors are fresh, individual gestures wait for the combination window (30–200 ms, default 100 ms). One full swing can pair with smaller sustained movement from the other sensor, before or after it within the inclusive window. Confirmation uses half that sensor's normal acceleration threshold while retaining the force/tilt guard and fresh-sample evidence requirement; individual thresholds are unchanged. Two small movements alone do not trigger an action. Confirmation is consumed once, including the confirming source's rearm/cooldown, so a later full swing from that same stroke cannot leak an individual action. Without an eligible combined binding or a fresh Nunchuk stream, Remote actions do not acquire the delay. Disconnects, lost modifiers, stale samples and active macros discard pending evidence rather than replaying it.
|
||||
- **Stroke detection and macros:** initially settle each enabled sensor for 120 ms. Further strokes need only a 20 ms lower-force gap and at least 200 ms between presses; early rebounds are discarded. Buttons generate 80 ms presses. Gesture macros run one cycle regardless of their physical-trigger playback mode and must contain a positive-duration step before binding. Physical macro triggers and cancel controls take priority; gestures during active macros are ignored, not queued. The detector retains stroke history so repeated gesture macros do not require full stops. Profile changes and reserved hotkeys cancel pending gesture output; physical button holds remain intact.
|
||||
- **Independent Nunchuk acceleration:** plain and MotionPlus passthrough reports use separate calibrated samples and freshness counters. Nunchuk acceleration never replaces the Remote's console IMU. Invalid factory accelerometer calibration disables Nunchuk/combined gestures without disabling the stick, C/Z buttons, or Remote gestures; unplugging and replacement discard the old Nunchuk sample.
|
||||
- **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.
|
||||
|
|
@ -657,7 +660,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 8/catalog 3 are separate.
|
||||
migration preserves its approval list. Profile schema 9/catalog 3 are separate.
|
||||
|
||||
The native encoder preserves safe unity bytes when synchronized, otherwise
|
||||
encodes independent actuator/band/substep state with documented quantization.
|
||||
|
|
@ -1120,7 +1123,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–7 profiles retain their meaning when decoded as schema 8.
|
||||
verify. Schema 1–8 profiles retain their meaning when decoded as schema 9.
|
||||
Keep a profile export before downgrading: older firmware cannot read the new
|
||||
catalog/profile format.
|
||||
|
||||
|
|
|
|||
|
|
@ -1262,13 +1262,16 @@ diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_wii.h b/src/
|
|||
@@ -8,1 +8,2 @@
|
||||
+#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
@@ -46,3 +47,8 @@
|
||||
@@ -46,3 +47,11 @@
|
||||
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);
|
||||
+// Independent Nunchuk stream; never includes interleaved MotionPlus samples.
|
||||
+bool uni_hid_parser_wii_nunchuk_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
|
||||
|
|
@ -1343,7 +1346,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,30 @@
|
||||
@@ -159,6 +167,36 @@
|
||||
uint16_t rumble_duration_ms;
|
||||
|
||||
balance_board_calibration_t balance_board_calibration;
|
||||
|
|
@ -1371,10 +1374,16 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen
|
|||
+ uint16_t nunchuk_buttons;
|
||||
+ // Per-axis factory max/min/center, read before MotionPlus maps A4.
|
||||
+ uint8_t nunchuk_stick_calibration[6];
|
||||
+ bool nunchuk_accel_calibrated;
|
||||
+ bool nunchuk_accel_valid;
|
||||
+ uint16_t nunchuk_accel_zero[3];
|
||||
+ uint16_t nunchuk_accel_span[3];
|
||||
+ int32_t nunchuk_accel[3];
|
||||
+ uint32_t nunchuk_accel_sequence;
|
||||
|
||||
// Debug only
|
||||
int debug_fd; // File descriptor where dump is saved
|
||||
@@ -177,7 +209,6 @@
|
||||
@@ -177,7 +215,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);
|
||||
|
|
@ -1382,7 +1391,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 +227,43 @@
|
||||
@@ -196,6 +233,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);
|
||||
|
|
@ -1426,7 +1435,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 +285,436 @@
|
||||
@@ -217,124 +291,447 @@
|
||||
|
||||
// process_ functions
|
||||
|
||||
|
|
@ -1452,6 +1461,11 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen
|
|||
+ ins->nunchuk_x = 0;
|
||||
+ ins->nunchuk_y = 0;
|
||||
+ ins->nunchuk_buttons = 0;
|
||||
+ ins->nunchuk_accel_calibrated = false;
|
||||
+ ins->nunchuk_accel_valid = false;
|
||||
+ // Keep the sequence monotonic across hotplug, even if no snapshot observes
|
||||
+ // the detached interval before the replacement's first real report.
|
||||
+ memset(ins->nunchuk_accel, 0, sizeof(ins->nunchuk_accel));
|
||||
+ // Documented typical physical gate: center 128, travel +/-96.
|
||||
+ // Keep clones with unreadable calibration usable; never learn from a held stick.
|
||||
+ static const uint8_t nominal_stick[] = {224, 32, 128, 224, 32, 128};
|
||||
|
|
@ -1686,25 +1700,29 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen
|
|||
+ wii_setup_read(d, WII_FSM_MP_PROBE, WII_READ_FROM_REGISTERS, 0xa600fa, 6);
|
||||
+}
|
||||
+
|
||||
+static bool wii_read_accel_calibration(wii_instance_t* ins, const uint8_t* c) {
|
||||
+ uint8_t checksum = 0x55;
|
||||
+ for (int i = 0; i < 9; i++)
|
||||
+ checksum += c[i];
|
||||
+ if (checksum != c[9])
|
||||
+ return false;
|
||||
+static bool wii_read_accel_points(uint16_t zero_points[3], uint16_t spans[3], const uint8_t* c) {
|
||||
+ // Dolphin WiimoteCommon::AccelCalibrationPoint is shared by the Remote
|
||||
+ // EEPROM and Nunchuk A40020: XYZ high bytes, then XYZ low bits at 4/2/0.
|
||||
+ for (int i = 0; i < 3; i++) {
|
||||
+ int shift = 4 - 2 * i;
|
||||
+ int zero = (c[i] << 2) | ((c[3] >> shift) & 3);
|
||||
+ int one = (c[i + 4] << 2) | ((c[7] >> shift) & 3);
|
||||
+ // Factory sensitivity is approximately 100 counts/g at 10-bit precision.
|
||||
+ // Sensitivity is about 100 counts/g (Remote) or 200 (Nunchuk).
|
||||
+ if (zero == 0 || zero == 1023 || one - zero < 16 || one - zero > 512)
|
||||
+ return false;
|
||||
+ ins->accel_zero[i] = zero;
|
||||
+ ins->accel_span[i] = one - zero;
|
||||
+ zero_points[i] = zero;
|
||||
+ spans[i] = one - zero;
|
||||
+ }
|
||||
+ return true;
|
||||
+}
|
||||
+
|
||||
+static bool wii_read_accel_calibration(wii_instance_t* ins, const uint8_t* c) {
|
||||
+ uint8_t checksum = 0x55;
|
||||
+ for (int i = 0; i < 9; i++)
|
||||
+ checksum += c[i];
|
||||
+ return checksum == c[9] && wii_read_accel_points(ins->accel_zero, ins->accel_span, c);
|
||||
+}
|
||||
+
|
||||
+static uint16_t wii_be16(const uint8_t* p) {
|
||||
+ return ((uint16_t)p[0] << 8) | p[1];
|
||||
+}
|
||||
|
|
@ -1740,6 +1758,8 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen
|
|||
+ switch (ins->state) {
|
||||
+ case WII_FSM_NUNCHUK_CALIBRATION:
|
||||
+ // Retain nominal stick calibration, but still initialize remote motion.
|
||||
+ ins->nunchuk_accel_calibrated = false;
|
||||
+ ins->nunchuk_accel_valid = false;
|
||||
+ ins->state = WII_FSM_DEV_GUESSED;
|
||||
+ wii_process_fsm(d);
|
||||
+ return;
|
||||
|
|
@ -1973,7 +1993,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 +824,83 @@
|
||||
@@ -444,17 +841,90 @@
|
||||
|
||||
// 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) {
|
||||
|
|
@ -2008,10 +2028,17 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen
|
|||
+ uint8_t checksum = 0x55;
|
||||
+ for (int i = 0; i < 14; i++)
|
||||
+ checksum += c[i];
|
||||
+ bool valid = c[14] == checksum && c[15] == (uint8_t)(checksum + 0x55);
|
||||
+ const bool checksum_valid = c[14] == checksum && c[15] == (uint8_t)(checksum + 0x55);
|
||||
+ ins->nunchuk_accel_valid = false;
|
||||
+ ins->nunchuk_accel_calibrated = checksum_valid &&
|
||||
+ wii_read_accel_points(ins->nunchuk_accel_zero, ins->nunchuk_accel_span, c);
|
||||
+ if (!ins->nunchuk_accel_calibrated)
|
||||
+ logi("Wii: invalid Nunchuk accelerometer calibration; Nunchuk motion disabled\n");
|
||||
+ // Stick validity is independent of accelerometer spans.
|
||||
+ bool stick_valid = checksum_valid;
|
||||
+ for (int i = 8; i <= 11; i += 3)
|
||||
+ valid = valid && c[i + 2] - c[i + 1] >= 16 && c[i] - c[i + 2] >= 16;
|
||||
+ if (valid)
|
||||
+ stick_valid = stick_valid && c[i + 2] - c[i + 1] >= 16 && c[i] - c[i + 2] >= 16;
|
||||
+ if (stick_valid)
|
||||
+ memcpy(ins->nunchuk_stick_calibration, c + 8, sizeof(ins->nunchuk_stick_calibration));
|
||||
+ else
|
||||
+ logi("Wii: invalid Nunchuk stick calibration; using nominal gate\n");
|
||||
|
|
@ -2064,7 +2091,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 +911,6 @@
|
||||
@@ -465,7 +935,6 @@
|
||||
process_req_data_dump_eeprom(d, report, len);
|
||||
break;
|
||||
default:
|
||||
|
|
@ -2072,7 +2099,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen
|
|||
break;
|
||||
}
|
||||
}
|
||||
@@ -473,29 +918,44 @@
|
||||
@@ -473,29 +942,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) {
|
||||
|
|
@ -2140,7 +2167,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen
|
|||
}
|
||||
}
|
||||
|
||||
@@ -514,15 +974,26 @@
|
||||
@@ -514,15 +998,26 @@
|
||||
const uint8_t* data = &report[1];
|
||||
wii_instance_t* ins = get_wii_instance(d);
|
||||
|
||||
|
|
@ -2176,7 +2203,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 +1034,116 @@
|
||||
@@ -563,46 +1058,144 @@
|
||||
// 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) {
|
||||
|
|
@ -2252,6 +2279,20 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen
|
|||
+ return true;
|
||||
+}
|
||||
+
|
||||
+bool uni_hid_parser_wii_nunchuk_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->ext_type != WII_EXT_NUNCHUK ||
|
||||
+ !ins->nunchuk_accel_calibrated || !ins->nunchuk_accel_valid)
|
||||
+ return false;
|
||||
+ for (int i = 0; i < 3; ++i)
|
||||
+ accel[i] = ins->nunchuk_accel[i];
|
||||
+ *sequence = ins->nunchuk_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;
|
||||
|
|
@ -2267,10 +2308,24 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen
|
|||
+static void wii_update_nunchuk(wii_instance_t* ins, const uint8_t* e, bool passthrough) {
|
||||
+ ins->nunchuk_x = wii_nunchuk_axis(e[0], ins->nunchuk_stick_calibration, false);
|
||||
+ ins->nunchuk_y = wii_nunchuk_axis(e[1], ins->nunchuk_stick_calibration + 3, true);
|
||||
+ // MP moves C/Z from bits 1/0 to bits 3/2. Its accelerometer is deliberately
|
||||
+ // not mixed into the remote's sensor stream.
|
||||
+ // MP moves C/Z from bits 1/0 to bits 3/2.
|
||||
+ uint8_t buttons = e[5] >> (passthrough ? 2 : 0);
|
||||
+ ins->nunchuk_buttons = (!(buttons & 2) ? BUTTON_X : 0) | (!(buttons & 1) ? BUTTON_Y : 0);
|
||||
+ if (!ins->nunchuk_accel_calibrated)
|
||||
+ return;
|
||||
+ // WiiBrew Nunchuk / MotionPlus data formats. Passthrough loses bit 0 of
|
||||
+ // each axis and moves AZ bit 2 out of the extension-connected flag.
|
||||
+ int32_t x = (e[2] << 2) | (passthrough ? ((e[5] >> 4) & 1) << 1 : (e[5] >> 2) & 3);
|
||||
+ int32_t y = (e[3] << 2) | (passthrough ? ((e[5] >> 5) & 1) << 1 : (e[5] >> 4) & 3);
|
||||
+ int32_t z = passthrough ? ((e[4] & 0xfe) << 2) | (((e[5] >> 6) & 3) << 1) :
|
||||
+ (e[4] << 2) | ((e[5] >> 6) & 3);
|
||||
+ // Same SDL axes and Q13 units as Remote, but never write console IMU.
|
||||
+ ins->nunchuk_accel[0] = uni_imu_scale(ins->nunchuk_accel_zero[0] - x, ins->nunchuk_accel_span[0], UNI_IMU_ACCEL_RES_PER_G);
|
||||
+ ins->nunchuk_accel[1] = uni_imu_scale(z - ins->nunchuk_accel_zero[2], ins->nunchuk_accel_span[2], UNI_IMU_ACCEL_RES_PER_G);
|
||||
+ ins->nunchuk_accel[2] = uni_imu_scale(y - ins->nunchuk_accel_zero[1], ins->nunchuk_accel_span[1], UNI_IMU_ACCEL_RES_PER_G);
|
||||
+ if (++ins->nunchuk_accel_sequence == 0)
|
||||
+ ++ins->nunchuk_accel_sequence;
|
||||
+ ins->nunchuk_accel_valid = true;
|
||||
+}
|
||||
+
|
||||
+static int32_t wii_calibrate_gyro(const wii_instance_t* ins, int axis, int32_t raw, bool slow) {
|
||||
|
|
@ -2333,7 +2388,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen
|
|||
}
|
||||
|
||||
// Used in WiiMote + Nunchuk Mode
|
||||
@@ -617,31 +1158,17 @@
|
||||
@@ -617,31 +1210,17 @@
|
||||
}
|
||||
|
||||
wii_instance_t* ins = get_wii_instance(d);
|
||||
|
|
@ -2373,7 +2428,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 +1195,59 @@
|
||||
@@ -668,59 +1247,59 @@
|
||||
ctl->gamepad.buttons |= n.button_lower ? BUTTON_Y : 0;
|
||||
}
|
||||
|
||||
|
|
@ -2481,7 +2536,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 +1529,18 @@
|
||||
@@ -1002,52 +1581,18 @@
|
||||
logi("fsm: req_status\n");
|
||||
wii_instance_t* ins = get_wii_instance(d);
|
||||
ins->state = WII_FSM_DID_REQ_STATUS;
|
||||
|
|
@ -2539,7 +2594,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 +1548,7 @@
|
||||
@@ -1055,8 +1600,7 @@
|
||||
wii_instance_t* ins = get_wii_instance(d);
|
||||
ins->state = WII_FSM_EXT_DID_READ_REGISTER;
|
||||
|
||||
|
|
@ -2549,7 +2604,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 +1558,7 @@
|
||||
@@ -1066,8 +1610,7 @@
|
||||
wii_instance_t* ins = get_wii_instance(d);
|
||||
ins->state = WII_FSM_BALANCE_BOARD_DID_READ_CALIBRATION;
|
||||
|
||||
|
|
@ -2559,7 +2614,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 +1568,7 @@
|
||||
@@ -1077,8 +1620,7 @@
|
||||
wii_instance_t* ins = get_wii_instance(d);
|
||||
ins->state = WII_FSM_BALANCE_BOARD_DID_READ_CALIBRATION2;
|
||||
|
||||
|
|
@ -2569,7 +2624,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 +1576,10 @@
|
||||
@@ -1086,6 +1628,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);
|
||||
|
|
@ -2580,7 +2635,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 +1594,8 @@
|
||||
@@ -1100,18 +1646,8 @@
|
||||
}
|
||||
uint8_t report_type = 0xff;
|
||||
if (ins->ext_type == WII_EXT_NUNCHUK) {
|
||||
|
|
@ -2601,7 +2656,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 +1609,17 @@
|
||||
@@ -1125,21 +1661,17 @@
|
||||
d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_UDRAW_TABLET;
|
||||
report_type = WII_REPORT_TYPE_KE;
|
||||
} else {
|
||||
|
|
@ -2633,7 +2688,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 +1644,10 @@
|
||||
@@ -1164,7 +1696,10 @@
|
||||
ins->state = WII_FSM_LED_UPDATED;
|
||||
wii_process_fsm(d);
|
||||
|
||||
|
|
@ -2645,7 +2700,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 +1701,15 @@
|
||||
@@ -1218,7 +1753,15 @@
|
||||
// Do nothing
|
||||
break;
|
||||
case WII_FSM_DEV_GUESSED:
|
||||
|
|
@ -2662,7 +2717,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 +1725,19 @@
|
||||
@@ -1234,6 +1777,19 @@
|
||||
wii_fsm_update_led(d);
|
||||
break;
|
||||
case WII_FSM_LED_UPDATED:
|
||||
|
|
@ -2682,7 +2737,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 +1753,10 @@
|
||||
@@ -1249,10 +1805,10 @@
|
||||
|
||||
ins->mode = WII_MODE_HORIZONTAL;
|
||||
ins->state = WII_FSM_SETUP;
|
||||
|
|
@ -2697,7 +2752,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen
|
|||
|
||||
// Dump EEPROM
|
||||
#if ENABLE_EEPROM_DUMP
|
||||
@@ -1267,13 +1771,18 @@
|
||||
@@ -1267,13 +1823,18 @@
|
||||
}
|
||||
|
||||
void uni_hid_parser_wii_init_report(uni_hid_device_t* d) {
|
||||
|
|
@ -2718,7 +2773,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 +1800,14 @@
|
||||
@@ -1291,6 +1852,14 @@
|
||||
case WII_REPORT_TYPE_KAE:
|
||||
process_drm_kae(d, report, len);
|
||||
break;
|
||||
|
|
@ -2733,7 +2788,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 +1824,9 @@
|
||||
@@ -1307,6 +1876,9 @@
|
||||
logi("Wii parser: unknown report type: 0x%02x\n", report[0]);
|
||||
printf_hexdump(report, len);
|
||||
}
|
||||
|
|
@ -2743,7 +2798,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 +1891,25 @@
|
||||
@@ -1371,29 +1943,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);
|
||||
|
|
@ -2788,7 +2843,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 +1996,13 @@
|
||||
@@ -1480,10 +2048,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);
|
||||
|
|
@ -2803,7 +2858,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 +2010,15 @@
|
||||
@@ -1491,6 +2062,15 @@
|
||||
uni_hid_device_send_intr_report(d, report, sizeof(report));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ struct BleIdentityMapping {
|
|||
struct BackendSlot {
|
||||
ControllerState state;
|
||||
WiiAccelerometerSample accelerometer{};
|
||||
WiiAccelerometerSample nunchuk_accelerometer{};
|
||||
uint16_t pre_hotkey_button_mask;
|
||||
ControllerIdentity identity;
|
||||
// Non-null with active=false is a connected device still becoming ready.
|
||||
|
|
@ -1142,6 +1143,7 @@ void publish_all_neutral() {
|
|||
reset_switch2_outputs(slot);
|
||||
slot.state = make_neutral_state();
|
||||
slot.accelerometer = {};
|
||||
slot.nunchuk_accelerometer = {};
|
||||
slot.pre_hotkey_button_mask = 0;
|
||||
slot.identity = controller_identity_global();
|
||||
slot.device = nullptr;
|
||||
|
|
@ -1675,6 +1677,7 @@ void reset_slot_hotkeys(BackendSlot& slot) {
|
|||
slot.motion_enabled = kDefaultMotionEnabled;
|
||||
slot.pre_hotkey_button_mask = 0;
|
||||
slot.accelerometer = {};
|
||||
slot.nunchuk_accelerometer = {};
|
||||
slot.feedback_pending = false;
|
||||
slot.feedback_until_ms = 0;
|
||||
slot.pending_feedback = {};
|
||||
|
|
@ -3313,6 +3316,16 @@ void platform_on_controller_data(uni_hid_device_t* device,
|
|||
convert_accel(acceleration[1]), sequence,
|
||||
btstack_run_loop_get_time_ms(), true};
|
||||
}
|
||||
if (!uni_hid_parser_wii_nunchuk_accel_snapshot(device, acceleration, &sequence)) {
|
||||
slot.nunchuk_accelerometer = {};
|
||||
} else if (!slot.nunchuk_accelerometer.valid ||
|
||||
sequence != slot.nunchuk_accelerometer.sequence) {
|
||||
slot.nunchuk_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;
|
||||
|
|
@ -3697,6 +3710,7 @@ void bluepad32_input_backend_snapshot(uint8_t slot_index,
|
|||
slot.pre_hotkey_button_mask;
|
||||
out->state = slot.state;
|
||||
out->accelerometer = slot.accelerometer;
|
||||
out->nunchuk_accelerometer = slot.nunchuk_accelerometer;
|
||||
const uint32_t state_generation = slot.state_generation;
|
||||
critical_section_exit(&g_state_lock);
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ struct Bluepad32SlotSnapshot {
|
|||
uint16_t pre_hotkey_button_mask;
|
||||
ControllerState state;
|
||||
WiiAccelerometerSample accelerometer{};
|
||||
WiiAccelerometerSample nunchuk_accelerometer{};
|
||||
};
|
||||
|
||||
enum class Bluepad32ControllerLayout : uint8_t {
|
||||
|
|
|
|||
|
|
@ -19,12 +19,19 @@ void WiiSwingDetector::reset() {
|
|||
}
|
||||
|
||||
bool WiiSwingDetector::update(const WiiAccelerometerSample& sample,
|
||||
uint32_t now_ms, uint8_t sensitivity, bool allowed) {
|
||||
uint32_t now_ms, uint8_t sensitivity, bool allowed,
|
||||
bool observe_confirmation) {
|
||||
if (!allowed || !sample.valid || sensitivity > 2 ||
|
||||
static_cast<int32_t>(now_ms - sample.timestamp_ms) > static_cast<int32_t>(kStaleMs)) {
|
||||
reset();
|
||||
return false;
|
||||
}
|
||||
if (!observe_confirmation) {
|
||||
have_confirmation_ = false;
|
||||
confirmation_candidate_ = false;
|
||||
confirmation_latched_ = false;
|
||||
confirmation_releasing_ = 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_;
|
||||
|
|
@ -71,6 +78,39 @@ bool WiiSwingDetector::update(const WiiAccelerometerSample& sample,
|
|||
} else {
|
||||
quiet_ = false;
|
||||
}
|
||||
if (observe_confirmation) {
|
||||
// Reuse the same calibrated gravity reference and force/tilt guard.
|
||||
// Do not lower the individual trigger or change its filtering/rearm.
|
||||
const bool confirming = dynamic_squared > square(thresholds[sensitivity] / 2) &&
|
||||
force_excursion;
|
||||
if (confirmation_latched_ && released) {
|
||||
if (!confirmation_releasing_) {
|
||||
confirmation_releasing_ = true;
|
||||
confirmation_release_ms_ = sample_ms_;
|
||||
}
|
||||
if (sample_ms_ - confirmation_release_ms_ >= kReleaseMs)
|
||||
confirmation_latched_ = false;
|
||||
} else {
|
||||
confirmation_releasing_ = false;
|
||||
}
|
||||
if (!confirmation_latched_ && confirming && (armed_ || fired_)) {
|
||||
if (!confirmation_candidate_) {
|
||||
confirmation_candidate_ = true;
|
||||
confirmation_since_ms_ = sample_ms_;
|
||||
} else if (sample_ms_ - confirmation_since_ms_ >= kEvidenceMs) {
|
||||
// Reject early rebounds rather than confirming them later.
|
||||
if (!fired_ || now_ms - fired_ms_ >= kRearmMs) {
|
||||
confirmation_ms_ = now_ms;
|
||||
have_confirmation_ = true;
|
||||
}
|
||||
confirmation_latched_ = true;
|
||||
confirmation_candidate_ = false;
|
||||
confirmation_releasing_ = false;
|
||||
}
|
||||
} else {
|
||||
confirmation_candidate_ = false;
|
||||
}
|
||||
}
|
||||
if (armed_ && energetic) {
|
||||
if (!candidate_) {
|
||||
candidate_ = true;
|
||||
|
|
@ -99,3 +139,130 @@ bool WiiSwingDetector::update(const WiiAccelerometerSample& sample,
|
|||
}
|
||||
return pulsing_;
|
||||
}
|
||||
|
||||
bool WiiSwingDetector::confirmation(uint32_t* timestamp_ms) const {
|
||||
if (!have_confirmation_ || timestamp_ms == nullptr) return false;
|
||||
*timestamp_ms = confirmation_ms_;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WiiSwingDetector::consume_confirmation() {
|
||||
if (!have_confirmation_) return;
|
||||
// Count the confirming motion as this source's stroke. Otherwise a weak
|
||||
// confirmation growing into a full swing would later leak a single action.
|
||||
if (!fired_ || static_cast<int32_t>(confirmation_ms_ - fired_ms_) > 0)
|
||||
fired_ms_ = confirmation_ms_;
|
||||
fired_ = true;
|
||||
armed_ = false;
|
||||
candidate_ = false;
|
||||
quiet_ = false;
|
||||
}
|
||||
|
||||
void WiiSwingGestures::reset() {
|
||||
*this = {};
|
||||
}
|
||||
|
||||
void WiiSwingGestures::discard_actions() {
|
||||
pending_ = 0;
|
||||
pending_individual_ = 0;
|
||||
active_ = 0;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
if (detectors_[i].confirmation(&used_confirmation_ms_[i]))
|
||||
used_confirmations_ |= 1u << i;
|
||||
}
|
||||
}
|
||||
|
||||
WiiSwingGestureResult WiiSwingGestures::update(
|
||||
const WiiAccelerometerSample& remote, const WiiAccelerometerSample& nunchuk,
|
||||
uint32_t now_ms, uint8_t remote_sensitivity, uint8_t nunchuk_sensitivity,
|
||||
uint8_t allowed, uint8_t combination_window_ms) {
|
||||
const auto fresh = [now_ms](const WiiAccelerometerSample& sample) {
|
||||
return sample.valid &&
|
||||
static_cast<int32_t>(now_ms - sample.timestamp_ms) <= static_cast<int32_t>(kStaleMs);
|
||||
};
|
||||
const bool remote_fresh = fresh(remote);
|
||||
const bool nunchuk_fresh = fresh(nunchuk);
|
||||
if (!remote_fresh) allowed &= ~5u;
|
||||
if (!nunchuk_fresh) allowed &= ~6u;
|
||||
if (allowed == 0) {
|
||||
reset();
|
||||
return {};
|
||||
}
|
||||
const bool combining = (allowed & 4u) != 0;
|
||||
if (combining != combining_) {
|
||||
pending_ = 0;
|
||||
pending_individual_ = 0;
|
||||
combining_ = combining;
|
||||
used_confirmations_ = 0;
|
||||
}
|
||||
active_ &= allowed;
|
||||
pending_individual_ &= allowed;
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
if (now_ms - pulse_since_ms_[i] >= kPulseMs) active_ &= ~(1u << i);
|
||||
}
|
||||
const bool pulses[2] = {
|
||||
detectors_[0].update(remote, now_ms, remote_sensitivity, (allowed & 5u) != 0, combining),
|
||||
detectors_[1].update(nunchuk, now_ms, nunchuk_sensitivity, (allowed & 6u) != 0, combining)};
|
||||
uint8_t edges = 0;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
if (pulses[i] && !previous_[i]) edges |= 1u << i;
|
||||
previous_[i] = pulses[i];
|
||||
}
|
||||
uint32_t confirmation_ms[2]{};
|
||||
uint8_t confirmations = 0;
|
||||
for (unsigned i = 0; combining && i < 2; ++i) {
|
||||
if (detectors_[i].confirmation(&confirmation_ms[i]) &&
|
||||
now_ms - confirmation_ms[i] <= combination_window_ms &&
|
||||
(!(used_confirmations_ & (1u << i)) ||
|
||||
used_confirmation_ms_[i] != confirmation_ms[i])) {
|
||||
confirmations |= 1u << i;
|
||||
}
|
||||
}
|
||||
WiiSwingGestureResult result;
|
||||
const auto emit = [&](uint8_t bits) {
|
||||
bits &= allowed;
|
||||
result.started |= bits;
|
||||
active_ |= bits;
|
||||
for (unsigned i = 0; i < 3; ++i)
|
||||
if (bits & (1u << i)) pulse_since_ms_[i] = now_ms;
|
||||
};
|
||||
const auto combine = [&] {
|
||||
emit(4);
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
if (detectors_[i].confirmation(&used_confirmation_ms_[i])) {
|
||||
used_confirmations_ |= 1u << i;
|
||||
detectors_[i].consume_confirmation();
|
||||
}
|
||||
}
|
||||
confirmations = 0;
|
||||
};
|
||||
if (pending_ && now_ms - pending_since_ms_ > combination_window_ms) {
|
||||
emit(pending_individual_);
|
||||
pending_ = pending_individual_ = 0;
|
||||
}
|
||||
if (combining) {
|
||||
// Match the older pending stroke before a new stroke on the same source.
|
||||
// This also makes the inclusive window boundary deterministic.
|
||||
const uint8_t opposite = pending_ == 1 ? 2 : pending_ == 2 ? 1 : 0;
|
||||
if (opposite && ((edges | confirmations) & opposite)) {
|
||||
combine();
|
||||
edges &= ~opposite;
|
||||
pending_ = pending_individual_ = 0;
|
||||
}
|
||||
if (edges == 3 || ((edges & 1) && (confirmations & 2)) ||
|
||||
((edges & 2) && (confirmations & 1))) {
|
||||
combine();
|
||||
edges = 0;
|
||||
}
|
||||
if (edges) {
|
||||
if (pending_) emit(pending_individual_);
|
||||
pending_ = edges;
|
||||
pending_individual_ = edges & allowed;
|
||||
pending_since_ms_ = now_ms;
|
||||
}
|
||||
} else {
|
||||
emit(edges);
|
||||
}
|
||||
result.active = active_;
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ public:
|
|||
// subsequent strokes need a brief force release and a 200ms cooldown.
|
||||
// Repeated reads cannot extend a pulse or build evidence.
|
||||
bool update(const WiiAccelerometerSample& sample, uint32_t now_ms,
|
||||
uint8_t sensitivity, bool allowed);
|
||||
uint8_t sensitivity, bool allowed, bool observe_confirmation = false);
|
||||
// Lower-threshold evidence is never an individual action. Each observation
|
||||
// can confirm only one combined stroke, before or after the full swing.
|
||||
bool confirmation(uint32_t* timestamp_ms) const;
|
||||
void consume_confirmation();
|
||||
|
||||
private:
|
||||
int32_t gravity_[3]{};
|
||||
|
|
@ -35,4 +39,42 @@ private:
|
|||
bool armed_ = false;
|
||||
bool pulsing_ = false;
|
||||
bool fired_ = false;
|
||||
uint32_t confirmation_ms_ = 0;
|
||||
uint32_t confirmation_since_ms_ = 0;
|
||||
uint32_t confirmation_release_ms_ = 0;
|
||||
bool have_confirmation_ = false;
|
||||
bool confirmation_candidate_ = false;
|
||||
bool confirmation_latched_ = false;
|
||||
bool confirmation_releasing_ = false;
|
||||
};
|
||||
|
||||
struct WiiSwingGestureResult {
|
||||
uint8_t active = 0;
|
||||
uint8_t started = 0;
|
||||
};
|
||||
|
||||
// Bits 0/1/2 identify Remote, Nunchuk, and combined gestures. Combining is
|
||||
// enabled only while both sensors are fresh and the combined binding is allowed.
|
||||
class WiiSwingGestures {
|
||||
public:
|
||||
void reset();
|
||||
// Discard outputs during a macro without losing stroke/rearm history.
|
||||
void discard_actions();
|
||||
WiiSwingGestureResult update(const WiiAccelerometerSample& remote,
|
||||
const WiiAccelerometerSample& nunchuk,
|
||||
uint32_t now_ms, uint8_t remote_sensitivity,
|
||||
uint8_t nunchuk_sensitivity, uint8_t allowed,
|
||||
uint8_t combination_window_ms);
|
||||
|
||||
private:
|
||||
WiiSwingDetector detectors_[2]{};
|
||||
bool previous_[2]{};
|
||||
bool combining_ = false;
|
||||
uint8_t pending_ = 0;
|
||||
uint8_t pending_individual_ = 0;
|
||||
uint32_t pending_since_ms_ = 0;
|
||||
uint8_t active_ = 0;
|
||||
uint32_t pulse_since_ms_[3]{};
|
||||
uint8_t used_confirmations_ = 0;
|
||||
uint32_t used_confirmation_ms_[2]{};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ bool valid_source_control(uint8_t control) {
|
|||
control == CONTROLLER_PROFILE_NO_BUTTON;
|
||||
}
|
||||
|
||||
bool valid_swing_action(uint8_t button, uint8_t macro, uint8_t modifier) {
|
||||
return valid_button(button) && valid_source_control(modifier) &&
|
||||
(macro == CONTROLLER_PROFILE_NO_BUTTON ||
|
||||
(macro < CONTROLLER_PROFILE_MACRO_COUNT &&
|
||||
button == CONTROLLER_PROFILE_NO_BUTTON));
|
||||
}
|
||||
|
||||
bool valid_turbo_settings(const ControllerProfileTurboSettings& settings) {
|
||||
return settings.rate_hz >= CONTROLLER_PROFILE_TURBO_RATE_MIN &&
|
||||
settings.rate_hz <= CONTROLLER_PROFILE_TURBO_RATE_MAX &&
|
||||
|
|
@ -296,9 +303,18 @@ bool controller_profile_validate(const ControllerProfile& profile) {
|
|||
}
|
||||
}
|
||||
if (!valid_source_control(profile.shortcuts.modifier) ||
|
||||
!valid_button(profile.swing.button) ||
|
||||
!valid_swing_action(profile.swing.button, profile.swing.macro,
|
||||
profile.swing.modifier) ||
|
||||
!valid_swing_action(profile.nunchuk_swing.button,
|
||||
profile.nunchuk_swing.macro,
|
||||
profile.nunchuk_swing.modifier) ||
|
||||
!valid_swing_action(profile.combined_swing.button,
|
||||
profile.combined_swing.macro,
|
||||
profile.combined_swing.modifier) ||
|
||||
profile.swing.sensitivity > 2 ||
|
||||
!valid_source_control(profile.swing.modifier) ||
|
||||
profile.nunchuk_swing.sensitivity > 2 ||
|
||||
profile.combination_window_ms < 30 ||
|
||||
profile.combination_window_ms > 200 ||
|
||||
!valid_source_control(profile.shift.modifier) ||
|
||||
static_cast<uint8_t>(profile.shift.mode) >
|
||||
static_cast<uint8_t>(ControllerProfileShiftMode::kToggle) ||
|
||||
|
|
@ -404,6 +420,12 @@ bool controller_profile_validate(const ControllerProfile& profile) {
|
|||
encoded_macro_size += sparse_macro_step_size(value);
|
||||
duration_ms += value.duration_ms;
|
||||
}
|
||||
if ((profile.swing.macro == macro_index ||
|
||||
profile.nunchuk_swing.macro == macro_index ||
|
||||
profile.combined_swing.macro == macro_index) &&
|
||||
(macro.step_count == 0 || duration_ms == 0)) {
|
||||
return false;
|
||||
}
|
||||
if (macro.trigger_mask != 0 && macro.step_count != 0 &&
|
||||
macro.mode != ControllerProfileMacroMode::kOnce &&
|
||||
duration_ms == 0) {
|
||||
|
|
@ -557,6 +579,15 @@ bool controller_profile_encode(const ControllerProfile& profile,
|
|||
output[364] = profile.swing.button;
|
||||
output[365] = profile.swing.sensitivity;
|
||||
output[366] = profile.swing.modifier;
|
||||
output[367] = profile.swing.macro;
|
||||
output[368] = profile.nunchuk_swing.button;
|
||||
output[369] = profile.nunchuk_swing.sensitivity;
|
||||
output[370] = profile.nunchuk_swing.modifier;
|
||||
output[371] = profile.nunchuk_swing.macro;
|
||||
output[372] = profile.combined_swing.button;
|
||||
output[373] = profile.combined_swing.macro;
|
||||
output[374] = profile.combined_swing.modifier;
|
||||
output[375] = profile.combination_window_ms;
|
||||
return stream_offset <= CONTROLLER_PROFILE_MACRO_STREAM_SIZE;
|
||||
}
|
||||
|
||||
|
|
@ -591,6 +622,8 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
|
|||
const bool has_extra_controls =
|
||||
schema_version >= CONTROLLER_PROFILE_EXTRA_CONTROL_SCHEMA_VERSION;
|
||||
const bool has_swing =
|
||||
schema_version >= CONTROLLER_PROFILE_SWING_SCHEMA_VERSION;
|
||||
const bool has_combined_swing =
|
||||
schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION;
|
||||
if ((has_control_mapping
|
||||
? input[61] != 0 || input[71] != 0
|
||||
|
|
@ -849,7 +882,8 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
|
|||
return false;
|
||||
}
|
||||
}
|
||||
const size_t reserved_offset = has_swing ? 367 : 364;
|
||||
const size_t reserved_offset =
|
||||
has_combined_swing ? 376 : (has_swing ? 367 : 364);
|
||||
if (!profile_bytes_are_zero(
|
||||
&input[reserved_offset], expected_size - reserved_offset)) {
|
||||
return false;
|
||||
|
|
@ -873,6 +907,13 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
|
|||
if (has_swing) {
|
||||
profile.swing = {input[364], input[365], input[366]};
|
||||
}
|
||||
if (has_combined_swing) {
|
||||
profile.swing.macro = input[367];
|
||||
profile.nunchuk_swing = {
|
||||
input[368], input[369], input[370], input[371]};
|
||||
profile.combined_swing = {input[372], input[373], input[374]};
|
||||
profile.combination_window_ms = input[375];
|
||||
}
|
||||
if (!controller_profile_validate(profile)) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ 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_EXTRA_CONTROL_SCHEMA_VERSION = 7;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 8;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_SWING_SCHEMA_VERSION = 8;
|
||||
constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 9;
|
||||
constexpr size_t CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE = 256;
|
||||
constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 384;
|
||||
constexpr uint8_t CONTROLLER_PROFILE_COUNT = 8;
|
||||
|
|
@ -189,12 +190,19 @@ struct ControllerProfileMacro {
|
|||
};
|
||||
|
||||
struct ControllerProfileSwingConfiguration {
|
||||
// Logical output button; NO_BUTTON disables the accelerometer gesture.
|
||||
// Exactly one output may be selected; both NO_BUTTON disables the 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;
|
||||
uint8_t macro = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
};
|
||||
|
||||
struct ControllerProfileCombinedSwingConfiguration {
|
||||
uint8_t button = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
uint8_t macro = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
uint8_t modifier = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
};
|
||||
|
||||
struct ControllerProfile {
|
||||
|
|
@ -222,6 +230,9 @@ struct ControllerProfile {
|
|||
ControllerProfileTurboSettings
|
||||
turbo_overrides[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{};
|
||||
ControllerProfileSwingConfiguration swing{};
|
||||
ControllerProfileSwingConfiguration nunchuk_swing{};
|
||||
ControllerProfileCombinedSwingConfiguration combined_swing{};
|
||||
uint8_t combination_window_ms = 100;
|
||||
};
|
||||
|
||||
struct ControllerProfileDatabaseEntry {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ struct ControllerProfileRuntimeContext {
|
|||
bool profile_snapshot_valid = false;
|
||||
ControllerProfile profile{};
|
||||
ControllerSyntheticInputContext synthetic{};
|
||||
WiiSwingDetector swing{};
|
||||
WiiSwingGestures swing{};
|
||||
bool runtime_generations_initialized = false;
|
||||
AdapterUsbMode output_mode = AdapterUsbMode::kSwitchProbe;
|
||||
uint32_t configuration_reset_generation = 0;
|
||||
|
|
@ -353,21 +353,53 @@ ControllerProfileTransformResult controller_profile_runtime_transform(
|
|||
process_hotkeys(context, slot, input_control_mask);
|
||||
context->previous_hotkey_control_mask = input_control_mask;
|
||||
const bool macro_was_active = context->synthetic.macro_active;
|
||||
const ControllerProfile& profile = context->profile;
|
||||
const auto binding_allowed = [&](uint8_t button, uint8_t macro, uint8_t modifier) {
|
||||
const bool assigned = button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT ||
|
||||
macro < CONTROLLER_PROFILE_MACRO_COUNT;
|
||||
return assigned && (modifier == CONTROLLER_PROFILE_NO_BUTTON ||
|
||||
(modifier < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT &&
|
||||
(input_control_mask & (1u << modifier)) != 0));
|
||||
};
|
||||
bool cancel_pressed = false;
|
||||
for (const auto& macro : profile.macros) {
|
||||
cancel_pressed |= macro.cancel_control < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT &&
|
||||
(input_control_mask & (1u << macro.cancel_control)) != 0;
|
||||
}
|
||||
uint8_t allowed = 0;
|
||||
if (consumed_controls == 0 && !cancel_pressed) {
|
||||
if (binding_allowed(profile.swing.button, profile.swing.macro, profile.swing.modifier))
|
||||
allowed |= 1;
|
||||
if (binding_allowed(profile.nunchuk_swing.button, profile.nunchuk_swing.macro,
|
||||
profile.nunchuk_swing.modifier))
|
||||
allowed |= 2;
|
||||
if (binding_allowed(profile.combined_swing.button, profile.combined_swing.macro,
|
||||
profile.combined_swing.modifier))
|
||||
allowed |= 4;
|
||||
}
|
||||
const WiiSwingGestureResult gestures = context->swing.update(
|
||||
snapshot.accelerometer, snapshot.nunchuk_accelerometer, now_ms,
|
||||
profile.swing.sensitivity, profile.nunchuk_swing.sensitivity,
|
||||
allowed, profile.combination_window_ms);
|
||||
const uint8_t macros[3] = {
|
||||
profile.swing.macro, profile.nunchuk_swing.macro, profile.combined_swing.macro};
|
||||
const uint8_t buttons[3] = {
|
||||
profile.swing.button, profile.nunchuk_swing.button, profile.combined_swing.button};
|
||||
uint8_t requested_macro = CONTROLLER_PROFILE_NO_BUTTON;
|
||||
for (unsigned i = 0; i < 3; ++i)
|
||||
if ((gestures.started & (1u << i)) && macros[i] < requested_macro)
|
||||
requested_macro = macros[i];
|
||||
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);
|
||||
&context->synthetic, snapshot.state, profile, now_ms,
|
||||
consumed_controls, consumed_controls != 0, requested_macro);
|
||||
if (macro_was_active || context->synthetic.macro_active) {
|
||||
context->swing.discard_actions();
|
||||
} else {
|
||||
uint16_t output_buttons = controller_profile_extract_button_mask(result.state);
|
||||
for (unsigned i = 0; i < 3; ++i)
|
||||
if ((gestures.active & (1u << i)) && buttons[i] < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT)
|
||||
output_buttons |= static_cast<uint16_t>(1u << buttons[i]);
|
||||
controller_profile_apply_button_mask(output_buttons, &result.state);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ void advance_binding(ControllerSyntheticBindingState* binding,
|
|||
|
||||
void stop_macro(ControllerSyntheticInputContext* context) {
|
||||
context->macro_active = false;
|
||||
context->macro_from_gesture = false;
|
||||
context->macro_index = 0;
|
||||
context->macro_step_index = 0;
|
||||
context->macro_cycle_duration_ms = 0;
|
||||
|
|
@ -67,7 +68,7 @@ void stop_macro(ControllerSyntheticInputContext* context) {
|
|||
|
||||
bool start_macro(ControllerSyntheticInputContext* context,
|
||||
const ControllerProfile& profile, uint8_t macro_index,
|
||||
uint32_t now_ms) {
|
||||
uint32_t now_ms, bool from_gesture = false) {
|
||||
if (macro_index >= CONTROLLER_PROFILE_MACRO_COUNT) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -87,13 +88,14 @@ bool start_macro(ControllerSyntheticInputContext* context,
|
|||
return false;
|
||||
}
|
||||
context->macro_active = true;
|
||||
context->macro_from_gesture = from_gesture;
|
||||
context->macro_index = macro_index;
|
||||
context->macro_step_index = 0;
|
||||
context->macro_cycle_duration_ms = duration_ms;
|
||||
context->macro_cycle_elapsed_ms = 0;
|
||||
context->macro_last_update_ms = now_ms;
|
||||
context->macro_cycles_remaining =
|
||||
macro.mode == ControllerProfileMacroMode::kOnce ? 1 :
|
||||
from_gesture || macro.mode == ControllerProfileMacroMode::kOnce ? 1 :
|
||||
macro.mode == ControllerProfileMacroMode::kRepeat ? macro.repeat_count : 0;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -177,7 +179,7 @@ void controller_synthetic_input_cancel(
|
|||
ControllerProfileTransformResult controller_synthetic_input_apply(
|
||||
ControllerSyntheticInputContext* context, const ControllerState& input,
|
||||
const ControllerProfile& profile, uint32_t now_ms,
|
||||
uint32_t suppressed_control_mask, bool suppress_shift) {
|
||||
uint32_t suppressed_control_mask, bool suppress_shift, uint8_t gesture_macro) {
|
||||
if (context == nullptr) {
|
||||
return controller_profile_transform(input, profile);
|
||||
}
|
||||
|
|
@ -219,7 +221,7 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
|
|||
|
||||
bool macro_started = false;
|
||||
const bool macro_was_active = context->macro_active;
|
||||
if (macro_was_active) {
|
||||
if (macro_was_active && !context->macro_from_gesture) {
|
||||
const ControllerProfileMacro& macro =
|
||||
profile.macros[context->macro_index];
|
||||
const bool held = (input_control_mask & macro.trigger_mask) ==
|
||||
|
|
@ -249,10 +251,14 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
|
|||
}
|
||||
}
|
||||
}
|
||||
if (!cancel_pressed && !macro_was_active && !macro_started &&
|
||||
gesture_macro < CONTROLLER_PROFILE_MACRO_COUNT) {
|
||||
macro_started = start_macro(context, profile, gesture_macro, now_ms, true);
|
||||
}
|
||||
if (!macro_started) {
|
||||
advance_macro(context, profile, now_ms);
|
||||
}
|
||||
if (context->macro_active &&
|
||||
if (context->macro_active && !context->macro_from_gesture &&
|
||||
context->macro_index < CONTROLLER_PROFILE_MACRO_COUNT) {
|
||||
consumed_controls |=
|
||||
profile.macros[context->macro_index].trigger_mask;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ struct ControllerSyntheticBindingState {
|
|||
|
||||
struct ControllerSyntheticInputContext {
|
||||
bool macro_active = false;
|
||||
bool macro_from_gesture = false;
|
||||
uint8_t macro_index = 0;
|
||||
uint8_t macro_step_index = 0;
|
||||
uint32_t macro_cycle_duration_ms = 0;
|
||||
|
|
@ -36,7 +37,10 @@ void controller_synthetic_input_cancel(
|
|||
// Consume reserved controls and Shift before physical macro/Turbo arbitration.
|
||||
// Only the selected button map changes; base analog tuning and final macro
|
||||
// overrides retain their precedence. Suppressed inputs still track raw edges.
|
||||
// gesture_macro is an edge request for one cycle, regardless of the macro's
|
||||
// physical-trigger playback mode. Physical triggers/cancel and active macros win.
|
||||
ControllerProfileTransformResult controller_synthetic_input_apply(
|
||||
ControllerSyntheticInputContext* context, const ControllerState& input,
|
||||
const ControllerProfile& profile, uint32_t now_ms,
|
||||
uint32_t suppressed_control_mask = 0, bool suppress_shift = false);
|
||||
uint32_t suppressed_control_mask = 0, bool suppress_shift = false,
|
||||
uint8_t gesture_macro = CONTROLLER_PROFILE_NO_BUTTON);
|
||||
|
|
|
|||
|
|
@ -144,7 +144,8 @@ PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4
|
|||
PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5
|
||||
PROFILE_EXPANDED_SCHEMA_VERSION = 6
|
||||
PROFILE_EXTRA_CONTROL_SCHEMA_VERSION = 7
|
||||
PROFILE_SCHEMA_VERSION = 8
|
||||
PROFILE_SWING_SCHEMA_VERSION = 8
|
||||
PROFILE_SCHEMA_VERSION = 9
|
||||
PROFILE_LEGACY_SIZE = 256
|
||||
PROFILE_SIZE = 384
|
||||
PROFILE_CAPACITY = 8
|
||||
|
|
@ -170,6 +171,9 @@ PROFILE_TURBO_BURST_MIN = 1
|
|||
PROFILE_TURBO_BURST_MAX = 255
|
||||
PROFILE_MACRO_REPEAT_MIN = 1
|
||||
PROFILE_MACRO_REPEAT_MAX = 255
|
||||
PROFILE_COMBINATION_WINDOW_MIN = 30
|
||||
PROFILE_COMBINATION_WINDOW_MAX = 200
|
||||
PROFILE_COMBINATION_WINDOW_DEFAULT = 100
|
||||
PROFILE_PLAYTEST_LEGACY_SCHEMA_VERSION = 2
|
||||
PROFILE_PLAYTEST_LEGACY_SIZE = 54
|
||||
PROFILE_PLAYTEST_EXTRA_BUTTON_SCHEMA_VERSION = 3
|
||||
|
|
@ -1778,45 +1782,92 @@ class ControllerMacro:
|
|||
)
|
||||
|
||||
|
||||
def _validate_swing_action(button: int, macro: int, modifier: int) -> None:
|
||||
if type(button) is not int or (
|
||||
button != PROFILE_NONE_BUTTON and not 0 <= button < len(LOGICAL_BUTTONS)
|
||||
):
|
||||
raise ConfigManagerError("invalid swing output button")
|
||||
if type(macro) is not int or (
|
||||
macro != PROFILE_NONE_BUTTON and not 0 <= macro < PROFILE_MACRO_COUNT
|
||||
):
|
||||
raise ConfigManagerError("invalid swing macro index")
|
||||
if button != PROFILE_NONE_BUTTON and macro != PROFILE_NONE_BUTTON:
|
||||
raise ConfigManagerError("swing action must select either a button or a macro, not both")
|
||||
if type(modifier) is not int or (
|
||||
modifier != PROFILE_NONE_BUTTON and not 0 <= modifier < len(LOGICAL_CONTROLS)
|
||||
):
|
||||
raise ConfigManagerError("invalid swing modifier")
|
||||
|
||||
|
||||
def _swing_macro_index(value: Any, name: str) -> int:
|
||||
if value is None:
|
||||
return PROFILE_NONE_BUTTON
|
||||
return _require_int(value, name, 1, PROFILE_MACRO_COUNT) - 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProfileSwing:
|
||||
button: int = PROFILE_NONE_BUTTON
|
||||
sensitivity: int = 1
|
||||
modifier: int = PROFILE_NONE_BUTTON
|
||||
macro: 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")
|
||||
_validate_swing_action(self.button, self.macro, self.modifier)
|
||||
_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),
|
||||
"macro": None if self.macro == PROFILE_NONE_BUTTON else self.macro + 1,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json_object(cls, value: Any) -> ProfileSwing:
|
||||
obj = _require_object(
|
||||
value, ("button", "sensitivity", "modifier"), "profile.swing"
|
||||
)
|
||||
def from_json_object(
|
||||
cls, value: Any, name: str = "profile.swing",
|
||||
*, schema_version: int = PROFILE_SCHEMA_VERSION,
|
||||
) -> ProfileSwing:
|
||||
fields = ["button", "sensitivity", "modifier"]
|
||||
if schema_version >= PROFILE_SCHEMA_VERSION:
|
||||
fields.append("macro")
|
||||
obj = _require_object(value, fields, name)
|
||||
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"),
|
||||
_button_index(obj["button"], f"{name}.button"),
|
||||
_require_enum(obj["sensitivity"], SWING_SENSITIVITIES, f"{name}.sensitivity"),
|
||||
_control_index(obj["modifier"], f"{name}.modifier", schema_version=schema_version),
|
||||
_swing_macro_index(obj["macro"], f"{name}.macro")
|
||||
if schema_version >= PROFILE_SCHEMA_VERSION else PROFILE_NONE_BUTTON,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProfileCombinedSwing:
|
||||
button: int = PROFILE_NONE_BUTTON
|
||||
macro: int = PROFILE_NONE_BUTTON
|
||||
modifier: int = PROFILE_NONE_BUTTON
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_swing_action(self.button, self.macro, self.modifier)
|
||||
|
||||
def to_json_object(self) -> dict[str, Any]:
|
||||
return {
|
||||
"button": _button_name(self.button),
|
||||
"macro": None if self.macro == PROFILE_NONE_BUTTON else self.macro + 1,
|
||||
"modifier": _control_name(self.modifier),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json_object(cls, value: Any) -> ProfileCombinedSwing:
|
||||
name = "profile.combined_swing"
|
||||
obj = _require_object(value, ("button", "macro", "modifier"), name)
|
||||
return cls(
|
||||
_button_index(obj["button"], f"{name}.button"),
|
||||
_swing_macro_index(obj["macro"], f"{name}.macro"),
|
||||
_control_index(obj["modifier"], f"{name}.modifier"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1840,6 +1891,9 @@ class ControllerProfile:
|
|||
turbo_overrides: tuple[TurboSettings | None, ...] = (None,) * len(LOGICAL_BUTTONS)
|
||||
extra_button_map: tuple[int, ...] = (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS)
|
||||
swing: ProfileSwing = ProfileSwing()
|
||||
nunchuk_swing: ProfileSwing = ProfileSwing()
|
||||
combined_swing: ProfileCombinedSwing = ProfileCombinedSwing()
|
||||
combination_window_ms: int = PROFILE_COMBINATION_WINDOW_DEFAULT
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if type(self.button_map) is not tuple or len(self.button_map) != len(
|
||||
|
|
@ -1922,8 +1976,25 @@ 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")
|
||||
for name, gesture_type in (
|
||||
("swing", ProfileSwing),
|
||||
("nunchuk_swing", ProfileSwing),
|
||||
("combined_swing", ProfileCombinedSwing),
|
||||
):
|
||||
gesture = getattr(self, name)
|
||||
if not isinstance(gesture, gesture_type):
|
||||
raise ConfigManagerError(f"profile {name} must be {gesture_type.__name__}")
|
||||
if gesture.macro != PROFILE_NONE_BUTTON:
|
||||
target = self.macros[gesture.macro]
|
||||
if not target.steps or not any(step.duration_ms for step in target.steps):
|
||||
raise ConfigManagerError(
|
||||
f"profile.{name} macro {gesture.macro + 1} must contain "
|
||||
"at least one step and a positive total duration"
|
||||
)
|
||||
_require_int(
|
||||
self.combination_window_ms, "profile.combination_window_ms",
|
||||
PROFILE_COMBINATION_WINDOW_MIN, PROFILE_COMBINATION_WINDOW_MAX,
|
||||
)
|
||||
if not isinstance(self.turbo_defaults, TurboSettings):
|
||||
raise ConfigManagerError("Turbo defaults must be TurboSettings")
|
||||
if (
|
||||
|
|
@ -1990,7 +2061,8 @@ class ControllerProfile:
|
|||
has_action_controls = version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION
|
||||
sparse_macros = version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION
|
||||
has_extra_buttons = version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION
|
||||
has_swing = version >= PROFILE_SCHEMA_VERSION
|
||||
has_swing = version >= PROFILE_SWING_SCHEMA_VERSION
|
||||
has_combined_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:
|
||||
|
|
@ -2159,7 +2231,11 @@ class ControllerProfile:
|
|||
settings_offset += 3
|
||||
if payload[settings_offset:336] != bytes(336 - settings_offset):
|
||||
raise ConfigManagerError("nonzero Turbo override padding")
|
||||
reserved_offset = 367 if has_swing else (364 if has_extra_buttons else 344)
|
||||
reserved_offset = (
|
||||
376 if has_combined_swing else
|
||||
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):
|
||||
|
|
@ -2183,7 +2259,13 @@ 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(),
|
||||
swing=(
|
||||
ProfileSwing(*payload[364:368]) if has_combined_swing else
|
||||
ProfileSwing(*payload[364:367]) if has_swing else ProfileSwing()
|
||||
),
|
||||
nunchuk_swing=ProfileSwing(*payload[368:372]) if has_combined_swing else ProfileSwing(),
|
||||
combined_swing=ProfileCombinedSwing(*payload[372:375]) if has_combined_swing else ProfileCombinedSwing(),
|
||||
combination_window_ms=payload[375] if has_combined_swing else PROFILE_COMBINATION_WINDOW_DEFAULT,
|
||||
)
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
|
|
@ -2263,8 +2345,16 @@ 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)
|
||||
payload[364:368] = bytes(
|
||||
(self.swing.button, self.swing.sensitivity, self.swing.modifier, self.swing.macro)
|
||||
)
|
||||
payload[368:372] = bytes(
|
||||
(self.nunchuk_swing.button, self.nunchuk_swing.sensitivity,
|
||||
self.nunchuk_swing.modifier, self.nunchuk_swing.macro)
|
||||
)
|
||||
payload[372:376] = bytes(
|
||||
(self.combined_swing.button, self.combined_swing.macro,
|
||||
self.combined_swing.modifier, self.combination_window_ms)
|
||||
)
|
||||
return bytes(payload)
|
||||
|
||||
|
|
@ -2303,6 +2393,9 @@ class ControllerProfile:
|
|||
"shortcuts": self.shortcuts.to_json_object(),
|
||||
"shift": self.shift.to_json_object(),
|
||||
"swing": self.swing.to_json_object(),
|
||||
"nunchuk_swing": self.nunchuk_swing.to_json_object(),
|
||||
"combined_swing": self.combined_swing.to_json_object(),
|
||||
"combination_window_ms": self.combination_window_ms,
|
||||
"turbo_settings": {
|
||||
"defaults": self.turbo_defaults.to_json_object(),
|
||||
"overrides": {
|
||||
|
|
@ -2352,8 +2445,10 @@ class ControllerProfile:
|
|||
fields.extend(("shortcuts", "shift", "turbo_settings"))
|
||||
if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION:
|
||||
fields.append("extra_button_map")
|
||||
if schema_version >= PROFILE_SCHEMA_VERSION:
|
||||
if schema_version >= PROFILE_SWING_SCHEMA_VERSION:
|
||||
fields.append("swing")
|
||||
if schema_version >= PROFILE_SCHEMA_VERSION:
|
||||
fields.extend(("nunchuk_swing", "combined_swing", "combination_window_ms"))
|
||||
obj = _require_object(value, fields, "profile")
|
||||
expected_size = (
|
||||
PROFILE_SIZE
|
||||
|
|
@ -2538,10 +2633,22 @@ class ControllerProfile:
|
|||
for name in EXTRA_BUTTONS
|
||||
),
|
||||
swing=(
|
||||
ProfileSwing.from_json_object(obj["swing"])
|
||||
if schema_version >= PROFILE_SCHEMA_VERSION
|
||||
ProfileSwing.from_json_object(obj["swing"], schema_version=schema_version)
|
||||
if schema_version >= PROFILE_SWING_SCHEMA_VERSION
|
||||
else ProfileSwing()
|
||||
),
|
||||
nunchuk_swing=(
|
||||
ProfileSwing.from_json_object(obj["nunchuk_swing"], "profile.nunchuk_swing")
|
||||
if schema_version >= PROFILE_SCHEMA_VERSION else ProfileSwing()
|
||||
),
|
||||
combined_swing=(
|
||||
ProfileCombinedSwing.from_json_object(obj["combined_swing"])
|
||||
if schema_version >= PROFILE_SCHEMA_VERSION else ProfileCombinedSwing()
|
||||
),
|
||||
combination_window_ms=(
|
||||
obj["combination_window_ms"] if schema_version >= PROFILE_SCHEMA_VERSION
|
||||
else PROFILE_COMBINATION_WINDOW_DEFAULT
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -3666,6 +3773,7 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]:
|
|||
PROFILE_SPARSE_MACRO_SCHEMA_VERSION,
|
||||
PROFILE_EXPANDED_SCHEMA_VERSION,
|
||||
PROFILE_EXTRA_CONTROL_SCHEMA_VERSION,
|
||||
PROFILE_SWING_SCHEMA_VERSION,
|
||||
PROFILE_SCHEMA_VERSION,
|
||||
):
|
||||
raise ConfigManagerError("unsupported profile-list schema")
|
||||
|
|
@ -3745,6 +3853,7 @@ def read_selected_profile(device: UsbDevice) -> ControllerProfile:
|
|||
PROFILE_SPARSE_MACRO_SCHEMA_VERSION,
|
||||
PROFILE_EXPANDED_SCHEMA_VERSION,
|
||||
PROFILE_EXTRA_CONTROL_SCHEMA_VERSION,
|
||||
PROFILE_SWING_SCHEMA_VERSION,
|
||||
PROFILE_SCHEMA_VERSION,
|
||||
):
|
||||
raise ConfigManagerError("unsupported profile schema")
|
||||
|
|
|
|||
|
|
@ -315,6 +315,10 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
|
|||
"turbo_modes": list(config_manager.TURBO_MODES),
|
||||
"shift_modes": list(config_manager.SHIFT_MODES),
|
||||
"swing_sensitivities": list(config_manager.SWING_SENSITIVITIES),
|
||||
"combination_window_bounds": {
|
||||
"min": config_manager.PROFILE_COMBINATION_WINDOW_MIN,
|
||||
"max": config_manager.PROFILE_COMBINATION_WINDOW_MAX,
|
||||
},
|
||||
"shortcut_selectors": list(
|
||||
config_manager.SHORTCUT_SELECTOR_BUTTONS
|
||||
),
|
||||
|
|
|
|||
|
|
@ -496,7 +496,12 @@ input[type="range"] { width: 100%; accent-color: var(--blue); }
|
|||
.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; }
|
||||
.swing-cards { display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 14px; }
|
||||
.swing-card { display: flex; flex-direction: column; flex: 1 1 320px; min-width: 0; }
|
||||
.swing-card .action-card-heading { min-height: 0; margin-bottom: 12px; }
|
||||
.swing-card-grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
|
||||
.swing-card:last-child { flex-basis: 100%; }
|
||||
.swing-card:last-child .swing-card-grid { grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); }
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -979,6 +979,100 @@ function buttonOptions(
|
|||
)).join("");
|
||||
}
|
||||
|
||||
function macroSummary(macro) {
|
||||
if (!macro) return "empty";
|
||||
const steps = macro.steps || [];
|
||||
if (!steps.length) return "empty";
|
||||
const duration = steps.reduce((sum, step) => sum + (step.duration_ms || 0), 0);
|
||||
return `${steps.length} step${steps.length === 1 ? "" : "s"} · ${duration} ms`;
|
||||
}
|
||||
|
||||
function gestureActionOptions(
|
||||
gesture,
|
||||
choices = state.schema.buttons,
|
||||
style = currentControllerStyle(),
|
||||
noneLabel = "Disabled"
|
||||
) {
|
||||
const selectedButton = gesture?.button ?? null;
|
||||
const selectedMacro = gesture?.macro ?? null;
|
||||
const noneSelected = selectedButton === null && selectedMacro === null;
|
||||
const noneOption = `<option value=""${noneSelected ? " selected" : ""}>${escapeHtml(noneLabel)}</option>`;
|
||||
|
||||
const buttonGroup = choices.length ? `
|
||||
<optgroup label="Output buttons">
|
||||
${choices.map((button) => (
|
||||
`<option value="${button}"${selectedButton === button ? " selected" : ""}>${escapeHtml(controlLabel(button, style))}</option>`
|
||||
)).join("")}
|
||||
</optgroup>` : "";
|
||||
|
||||
const macroList = state.profile?.macros || [];
|
||||
const macroGroup = `
|
||||
<optgroup label="Custom macros">
|
||||
${[1, 2, 3, 4].map((macroNum) => {
|
||||
const macro = macroList[macroNum - 1];
|
||||
const summary = macroSummary(macro);
|
||||
const isSelected = selectedMacro === macroNum;
|
||||
return `<option value="macro:${macroNum}"${isSelected ? " selected" : ""}>Macro ${macroNum} (${escapeHtml(summary)})</option>`;
|
||||
}).join("")}
|
||||
</optgroup>`;
|
||||
|
||||
return noneOption + buttonGroup + macroGroup;
|
||||
}
|
||||
|
||||
function parseGestureActionValue(value) {
|
||||
if (!value) return { button: null, macro: null };
|
||||
if (value.startsWith("macro:")) {
|
||||
const macroNum = parseInt(value.slice(6), 10);
|
||||
return { button: null, macro: Number.isInteger(macroNum) ? macroNum : null };
|
||||
}
|
||||
return { button: value, macro: null };
|
||||
}
|
||||
|
||||
function validateGestureMacros() {
|
||||
if (!elements.swing || !state.profile) return;
|
||||
const macros = state.profile.macros || [];
|
||||
const gestures = [
|
||||
{ name: "Wii Remote swing", selectId: "#swing-button", gesture: state.profile.swing },
|
||||
{ name: "Nunchuk swing", selectId: "#nunchuk-swing-button", gesture: state.profile.nunchuk_swing },
|
||||
{ name: "Both together", selectId: "#combined-swing-button", gesture: state.profile.combined_swing },
|
||||
];
|
||||
for (const { name, selectId, gesture } of gestures) {
|
||||
const select = elements.swing.querySelector(selectId);
|
||||
if (!select) continue;
|
||||
if (gesture && gesture.macro != null) {
|
||||
const target = macros[gesture.macro - 1];
|
||||
const steps = target?.steps || [];
|
||||
const hasPositiveDuration = steps.some((step) => step.duration_ms > 0);
|
||||
if (!steps.length || !hasPositiveDuration) {
|
||||
select.setCustomValidity(`${name} is bound to Macro ${gesture.macro}, which must contain at least one step and a positive total duration.`);
|
||||
} else {
|
||||
select.setCustomValidity("");
|
||||
}
|
||||
} else {
|
||||
select.setCustomValidity("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateGestureActionSelects() {
|
||||
if (!elements.swing || !state.profile) return;
|
||||
const controllerStyle = currentControllerStyle();
|
||||
const buttons = state.schema.buttons;
|
||||
const remoteSelect = elements.swing.querySelector("#swing-button");
|
||||
if (remoteSelect && state.profile.swing) {
|
||||
remoteSelect.innerHTML = gestureActionOptions(state.profile.swing, buttons, controllerStyle, "Disabled");
|
||||
}
|
||||
const nunchukSelect = elements.swing.querySelector("#nunchuk-swing-button");
|
||||
if (nunchukSelect && state.profile.nunchuk_swing) {
|
||||
nunchukSelect.innerHTML = gestureActionOptions(state.profile.nunchuk_swing, buttons, controllerStyle, "Disabled");
|
||||
}
|
||||
const combinedSelect = elements.swing.querySelector("#combined-swing-button");
|
||||
if (combinedSelect && state.profile.combined_swing) {
|
||||
combinedSelect.innerHTML = gestureActionOptions(state.profile.combined_swing, buttons, controllerStyle, "Disabled");
|
||||
}
|
||||
validateGestureMacros();
|
||||
}
|
||||
|
||||
function modeOptions(modes, selected) {
|
||||
return modes.map((mode) =>
|
||||
`<option value="${mode}"${mode === selected ? " selected" : ""}>${label(mode)}</option>`
|
||||
|
|
@ -1240,7 +1334,9 @@ 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.swing?.querySelector("#swing-modifier"), state.profile.swing?.modifier, []],
|
||||
[elements.swing?.querySelector("#nunchuk-swing-modifier"), state.profile.nunchuk_swing?.modifier, []],
|
||||
[elements.swing?.querySelector("#combined-swing-modifier"), state.profile.combined_swing?.modifier, []],
|
||||
[elements.macroControls.querySelector("#macro-cancel"), state.profile.macros[state.selectedMacro].cancel, []],
|
||||
];
|
||||
for (const [select, selected, excluded] of modifiers) {
|
||||
|
|
@ -1275,16 +1371,7 @@ 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"
|
||||
);
|
||||
}
|
||||
updateGestureActionSelects();
|
||||
}
|
||||
|
||||
elements.form.addEventListener("focusout", event => {
|
||||
|
|
@ -1981,6 +2068,7 @@ function mutateMacroSteps(change, focusIndex, focusSelector = "[data-step-handle
|
|||
macro.steps = steps;
|
||||
renderMacroSteps();
|
||||
updateMacroBudgets();
|
||||
updateGestureActionSelects();
|
||||
updateDirtyState();
|
||||
const index = Math.min(focusIndex, steps.length - 1);
|
||||
const focus = elements.macroSteps.querySelector(`[data-step-index="${index}"] ${focusSelector}`);
|
||||
|
|
@ -2132,36 +2220,109 @@ function renderMacro() {
|
|||
),
|
||||
].join("");
|
||||
const swing = state.profile.swing;
|
||||
const nunchukSwing = state.profile.nunchuk_swing;
|
||||
const combinedSwing = state.profile.combined_swing;
|
||||
const swingSensitivities = state.schema.swing_sensitivities;
|
||||
const windowBounds = state.schema.combination_window_bounds;
|
||||
const combinationWindowMs = state.profile.combination_window_ms;
|
||||
|
||||
elements.swing.innerHTML = `
|
||||
<div class="subpanel-heading">
|
||||
<div>
|
||||
<span class="action-kind">Motion gesture</span>
|
||||
<h4>Wii Remote swing</h4>
|
||||
<span class="action-kind">Motion gestures</span>
|
||||
<h4>Motion gestures & swings</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 class="swing-cards">
|
||||
<div class="action-card swing-card">
|
||||
<div class="action-card-heading">
|
||||
<div>
|
||||
<span class="action-kind">Individual gesture</span>
|
||||
<h4>Wii Remote swing</h4>
|
||||
</div>
|
||||
<span>Triggers when the Wii Remote is swung deliberately.</span>
|
||||
</div>
|
||||
<div class="swing-card-grid">
|
||||
<div class="control-card">
|
||||
<label for="swing-button">Remote action</label>
|
||||
<select class="select" id="swing-button" data-kind="swing-action" data-gesture="swing" data-output-select="swing">
|
||||
${gestureActionOptions(swing, 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" data-gesture="swing">
|
||||
${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" data-gesture="swing">
|
||||
${modifierOptions(swing.modifier)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</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 class="action-card swing-card">
|
||||
<div class="action-card-heading">
|
||||
<div>
|
||||
<span class="action-kind">Individual gesture</span>
|
||||
<h4>Nunchuk swing</h4>
|
||||
</div>
|
||||
<span>Triggers when the connected Nunchuk is swung deliberately.</span>
|
||||
</div>
|
||||
<div class="swing-card-grid">
|
||||
<div class="control-card">
|
||||
<label for="nunchuk-swing-button">Nunchuk action</label>
|
||||
<select class="select" id="nunchuk-swing-button" data-kind="swing-action" data-gesture="nunchuk_swing" data-output-select="nunchuk_swing">
|
||||
${gestureActionOptions(nunchukSwing, state.schema.buttons, controllerStyle, "Disabled")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-card">
|
||||
<label for="nunchuk-swing-sensitivity">Sensitivity</label>
|
||||
<select class="select" id="nunchuk-swing-sensitivity" data-kind="swing-sensitivity" data-gesture="nunchuk_swing">
|
||||
${modeOptions(swingSensitivities, nunchukSwing.sensitivity)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-card">
|
||||
<label for="nunchuk-swing-modifier">Held modifier (optional)</label>
|
||||
<select class="select" id="nunchuk-swing-modifier" data-kind="swing-modifier" data-gesture="nunchuk_swing">
|
||||
${modifierOptions(nunchukSwing.modifier)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</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 class="action-card swing-card">
|
||||
<div class="action-card-heading">
|
||||
<div>
|
||||
<span class="action-kind">Combined gesture</span>
|
||||
<h4>Both together</h4>
|
||||
</div>
|
||||
<span>One full swing plus qualifying movement from the other device within the timing window.</span>
|
||||
</div>
|
||||
<div class="swing-card-grid">
|
||||
<div class="control-card">
|
||||
<label for="combined-swing-button">Combined action</label>
|
||||
<select class="select" id="combined-swing-button" data-kind="swing-action" data-gesture="combined_swing" data-output-select="combined_swing">
|
||||
${gestureActionOptions(combinedSwing, state.schema.buttons, controllerStyle, "Disabled")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-card">
|
||||
<label for="combination-window-ms">Combination window · ${windowBounds.min}–${windowBounds.max} ms</label>
|
||||
<input class="number-input" id="combination-window-ms" type="number" required min="${windowBounds.min}" max="${windowBounds.max}" step="1" value="${combinationWindowMs}" data-kind="combination-window">
|
||||
</div>
|
||||
<div class="control-card">
|
||||
<label for="combined-swing-modifier">Held modifier (optional)</label>
|
||||
<select class="select" id="combined-swing-modifier" data-kind="swing-modifier" data-gesture="combined_swing">
|
||||
${modifierOptions(combinedSwing.modifier)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Triggers one ~80 ms press per deliberate stroke. Settle once to arm; back-and-forth strokes can then repeat after brief lower-force gaps, with at least 200 ms between presses. 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>`;
|
||||
<p class="field-help">Triggers one ~80 ms press or one-shot macro cycle per deliberate stroke. When Both together is enabled and both sensors are available, individual swings wait for the combination window. One full swing can combine with smaller sustained movement from the other sensor; two small movements alone do not trigger. Individual thresholds are unchanged, and each confirmation is used only once. Gestures play bound macros exactly once regardless of physical-trigger playback modes. Empty or zero-duration macro targets must be configured before saving.</p>`;
|
||||
validateGestureMacros();
|
||||
elements.macroControls.innerHTML = `
|
||||
<div class="macro-picker">
|
||||
<div class="macro-tabs" role="group" aria-label="Choose a macro draft">
|
||||
|
|
@ -2468,12 +2629,22 @@ function handleFormChange(event) {
|
|||
});
|
||||
}
|
||||
updateTurboTiming();
|
||||
} else if (kind === "swing-button") {
|
||||
state.profile.swing.button = target.value || null;
|
||||
} else if (kind === "swing-action") {
|
||||
const gestureName = target.dataset.gesture;
|
||||
const { button, macro } = parseGestureActionValue(target.value);
|
||||
state.profile[gestureName].button = button;
|
||||
state.profile[gestureName].macro = macro;
|
||||
validateGestureMacros();
|
||||
} else if (kind === "swing-sensitivity") {
|
||||
state.profile.swing.sensitivity = target.value;
|
||||
const gestureName = target.dataset.gesture;
|
||||
state.profile[gestureName].sensitivity = target.value;
|
||||
} else if (kind === "swing-modifier") {
|
||||
state.profile.swing.modifier = target.value || null;
|
||||
const gestureName = target.dataset.gesture;
|
||||
state.profile[gestureName].modifier = target.value || null;
|
||||
} else if (kind === "combination-window") {
|
||||
if (target.validity.valid) {
|
||||
state.profile.combination_window_ms = Number(target.value);
|
||||
}
|
||||
} else if (kind === "macro-selector") {
|
||||
state.profile.macros[state.selectedMacro][target.dataset.field] = target.value || null;
|
||||
} else if (kind === "macro-playback" || kind === "macro-repeat") {
|
||||
|
|
@ -2536,6 +2707,7 @@ function handleFormChange(event) {
|
|||
if (kind.startsWith("macro-")) {
|
||||
macroNotice("");
|
||||
updateMacroBudgets();
|
||||
updateGestureActionSelects();
|
||||
}
|
||||
updateDirtyState();
|
||||
if (state.liveSample) renderPlaytest(state.liveSample);
|
||||
|
|
@ -2998,6 +3170,9 @@ document.querySelectorAll("[data-reset-section]").forEach((button) => {
|
|||
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.nunchuk_swing = clone(defaults.nunchuk_swing);
|
||||
state.profile.combined_swing = clone(defaults.combined_swing);
|
||||
state.profile.combination_window_ms = defaults.combination_window_ms;
|
||||
state.profile.macros = clone(defaults.macros);
|
||||
}
|
||||
renderEditor();
|
||||
|
|
|
|||
|
|
@ -32,6 +32,23 @@ uni_platform* installed_platform = nullptr;
|
|||
bool observed_status_led_on = false;
|
||||
int observed_status_led_writes = 0;
|
||||
uint32_t now_ms = 0;
|
||||
struct WiiAccelFixture {
|
||||
uni_hid_device_t* device = nullptr;
|
||||
int32_t acceleration[3]{};
|
||||
uint32_t sequence = 0;
|
||||
bool valid = false;
|
||||
};
|
||||
WiiAccelFixture remote_accel_fixture;
|
||||
WiiAccelFixture nunchuk_accel_fixture;
|
||||
|
||||
bool wii_accel_fixture_snapshot(const WiiAccelFixture& fixture,
|
||||
uni_hid_device_t* controller,
|
||||
int32_t acceleration[3], uint32_t* sequence) {
|
||||
if (!fixture.valid || fixture.device != controller) return false;
|
||||
memcpy(acceleration, fixture.acceleration, sizeof(fixture.acceleration));
|
||||
*sequence = fixture.sequence;
|
||||
return true;
|
||||
}
|
||||
bool bondable = true;
|
||||
bool ssp_auto_accept = true;
|
||||
uint8_t accepted_stk_methods = 0xff;
|
||||
|
|
@ -722,6 +739,16 @@ extern "C" void uni_hid_parser_wii_set_mode(
|
|||
"Wii mode reconfiguration must retain the ready connection");
|
||||
}
|
||||
|
||||
extern "C" bool uni_hid_parser_wii_accel_snapshot(
|
||||
uni_hid_device_t* controller, int32_t acceleration[3], uint32_t* sequence) {
|
||||
return wii_accel_fixture_snapshot(remote_accel_fixture, controller, acceleration, sequence);
|
||||
}
|
||||
|
||||
extern "C" bool uni_hid_parser_wii_nunchuk_accel_snapshot(
|
||||
uni_hid_device_t* controller, int32_t acceleration[3], uint32_t* sequence) {
|
||||
return wii_accel_fixture_snapshot(nunchuk_accel_fixture, controller, acceleration, sequence);
|
||||
}
|
||||
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
namespace {
|
||||
std::vector<btstack_timer_source_t*> native_timers;
|
||||
|
|
@ -5496,6 +5523,81 @@ uni_hid_device_t wii_device(int index) {
|
|||
return result;
|
||||
}
|
||||
|
||||
void test_wii_accelerometer_streams() {
|
||||
start_pairing_backend();
|
||||
auto remote = wii_device(0);
|
||||
remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL;
|
||||
require(platform_on_device_ready(&remote) == UNI_ERROR_SUCCESS, "Wii must become ready");
|
||||
const auto generation = slot_snapshot(0).connection_generation;
|
||||
if (kDefaultMotionEnabled) {
|
||||
require(bluepad32_input_backend_toggle_motion(0, generation),
|
||||
"Wii console motion must be disabled for independent gesture streams");
|
||||
}
|
||||
remote_accel_fixture = {&remote, {8192, 0, 0}, 7, true};
|
||||
nunchuk_accel_fixture = {&remote, {0, 8192, 0}, 9, false};
|
||||
uni_controller_t input{};
|
||||
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
input.gamepad.accel[0] = 8192;
|
||||
input.gamepad.gyro[2] = 1024;
|
||||
now_ms = 100;
|
||||
platform_on_controller_data(&remote, &input);
|
||||
auto snapshot = slot_snapshot(0);
|
||||
require(snapshot.state.motion_sample_count == 0 &&
|
||||
snapshot.accelerometer.valid && !snapshot.nunchuk_accelerometer.valid &&
|
||||
snapshot.accelerometer.y == -4096 && snapshot.accelerometer.timestamp_ms == 100,
|
||||
"Remote gesture acceleration must remain calibrated with console motion off");
|
||||
|
||||
now_ms = 125;
|
||||
nunchuk_accel_fixture.valid = true;
|
||||
platform_on_controller_data(&remote, &input);
|
||||
snapshot = slot_snapshot(0);
|
||||
require(snapshot.state.motion_sample_count == 0 &&
|
||||
snapshot.nunchuk_accelerometer.valid && snapshot.nunchuk_accelerometer.z == 4096 &&
|
||||
snapshot.nunchuk_accelerometer.timestamp_ms == 125 &&
|
||||
snapshot.accelerometer.timestamp_ms == 100,
|
||||
"Nunchuk interleave must publish its own sample without refreshing the Remote");
|
||||
|
||||
now_ms = 150;
|
||||
++remote_accel_fixture.sequence; // Same physical values, genuinely new report.
|
||||
platform_on_controller_data(&remote, &input);
|
||||
now_ms = 200; // Status/ack callback exposes the same parser snapshots.
|
||||
platform_on_controller_data(&remote, &input);
|
||||
snapshot = slot_snapshot(0);
|
||||
bluepad32_input_backend_report_sent(0);
|
||||
snapshot = slot_snapshot(0);
|
||||
require(snapshot.accelerometer.timestamp_ms == 150 &&
|
||||
snapshot.nunchuk_accelerometer.timestamp_ms == 125 &&
|
||||
snapshot.accelerometer.valid && snapshot.nunchuk_accelerometer.valid,
|
||||
"cached parser callbacks and USB consumption must not freshen either sensor");
|
||||
|
||||
now_ms = 225;
|
||||
nunchuk_accel_fixture.valid = false;
|
||||
platform_on_controller_data(&remote, &input);
|
||||
snapshot = slot_snapshot(0);
|
||||
require(!snapshot.nunchuk_accelerometer.valid &&
|
||||
snapshot.accelerometer.valid && snapshot.accelerometer.timestamp_ms == 150,
|
||||
"Nunchuk detach/calibration failure must immediately invalidate only its own sensor");
|
||||
now_ms = 250;
|
||||
nunchuk_accel_fixture.valid = true;
|
||||
++nunchuk_accel_fixture.sequence;
|
||||
platform_on_controller_data(&remote, &input);
|
||||
snapshot = slot_snapshot(0);
|
||||
require(snapshot.nunchuk_accelerometer.valid &&
|
||||
snapshot.nunchuk_accelerometer.timestamp_ms == 250 &&
|
||||
snapshot.accelerometer.timestamp_ms == 150,
|
||||
"replacement Nunchuk must resume with its own fresh timestamp");
|
||||
|
||||
platform_on_device_disconnected(&remote);
|
||||
snapshot = slot_snapshot(0);
|
||||
require(!snapshot.accelerometer.valid && !snapshot.nunchuk_accelerometer.valid,
|
||||
"Remote disconnect must invalidate both accelerometer streams");
|
||||
auto replacement = wii_device(0);
|
||||
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS, "replacement Wii must become ready");
|
||||
snapshot = slot_snapshot(0);
|
||||
require(!snapshot.accelerometer.valid && !snapshot.nunchuk_accelerometer.valid,
|
||||
"reused backend slot must not inherit either sensor sample");
|
||||
}
|
||||
|
||||
void dispatch_wii_requests() {
|
||||
wii_dispatch_active = true;
|
||||
now_ms += g_rumble_timer.timeout_ms;
|
||||
|
|
@ -5945,6 +6047,10 @@ int main(int argc, char** argv) {
|
|||
test_wii_orientation_races();
|
||||
return 0;
|
||||
}
|
||||
if (scenario == "wii-accelerometers") {
|
||||
test_wii_accelerometer_streams();
|
||||
return 0;
|
||||
}
|
||||
if (scenario == "switch2-hd-pro") {
|
||||
test_switch2_hd_pro();
|
||||
} else if (scenario == "switch2-gesture-timing") {
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ typedef enum wii_flags {
|
|||
} wii_mode_t;
|
||||
|
||||
void uni_hid_parser_wii_set_mode(uni_hid_device_t* device, wii_mode_t mode);
|
||||
static inline bool uni_hid_parser_wii_accel_snapshot(
|
||||
uni_hid_device_t*, int32_t[3], uint32_t*) {
|
||||
return false;
|
||||
}
|
||||
bool uni_hid_parser_wii_accel_snapshot(
|
||||
uni_hid_device_t*, int32_t[3], uint32_t*);
|
||||
bool uni_hid_parser_wii_nunchuk_accel_snapshot(
|
||||
uni_hid_device_t*, int32_t[3], uint32_t*);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1363,6 +1363,226 @@ void test_early_rebound_cannot_become_a_delayed_swing() {
|
|||
require(presses == 2, "a new stroke after cooldown and release must trigger");
|
||||
}
|
||||
|
||||
void test_combined_swings_have_priority_at_window_boundary() {
|
||||
for (bool nunchuk_first : {false, true}) {
|
||||
for (unsigned delay : {0u, 100u, 110u}) {
|
||||
WiiSwingGestures gestures;
|
||||
uint32_t now = UINT32_MAX - 100u;
|
||||
uint32_t sequence = 0;
|
||||
unsigned counts[3]{};
|
||||
auto feed = [&](int16_t first, int16_t second) {
|
||||
now += 10;
|
||||
++sequence;
|
||||
WiiAccelerometerSample remote{
|
||||
nunchuk_first ? second : first, 0, 4096, sequence, now, true};
|
||||
WiiAccelerometerSample nunchuk{
|
||||
nunchuk_first ? first : second, 0, 4096, sequence, now, true};
|
||||
auto result = gestures.update(remote, nunchuk, now, 1, 1, 7, 100);
|
||||
for (unsigned i = 0; i < 3; ++i)
|
||||
if (result.started & (1u << i)) ++counts[i];
|
||||
return result;
|
||||
};
|
||||
for (unsigned i = 0; i < 20; ++i) feed(0, 0);
|
||||
for (unsigned step = 0; step < 40; ++step) {
|
||||
auto result = feed(10000, step * 10 >= delay ? 10000 : 0);
|
||||
if (delay <= 100)
|
||||
require((result.active & 3u) == 0, "combined gesture leaked individual buttons");
|
||||
}
|
||||
if (delay <= 100)
|
||||
require(counts[2] == 1 && counts[0] == 0 && counts[1] == 0,
|
||||
"simultaneous/in-window strokes must start only the combined action");
|
||||
else
|
||||
require(counts[2] == 0 && counts[0] == 1 && counts[1] == 1,
|
||||
"out-of-window strokes must remain two individual actions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void test_combined_pending_cancels_on_detach_and_modifier_release() {
|
||||
for (bool detach : {false, true}) {
|
||||
WiiSwingGestures gestures;
|
||||
uint32_t now = 0, sequence = 0;
|
||||
auto feed = [&](int16_t force, uint8_t allowed, bool nunchuk_valid) {
|
||||
now += 10;
|
||||
++sequence;
|
||||
WiiAccelerometerSample remote{force, 0, 4096, sequence, now, true};
|
||||
WiiAccelerometerSample nunchuk{0, 0, 4096, sequence, now, nunchuk_valid};
|
||||
return gestures.update(remote, nunchuk, now, 1, 1, allowed, 100);
|
||||
};
|
||||
for (unsigned i = 0; i < 20; ++i) feed(0, 7, true);
|
||||
feed(10000, 7, true);
|
||||
require(feed(10000, 7, true).started == 0, "single must wait while combined is eligible");
|
||||
for (unsigned i = 0; i < 20; ++i)
|
||||
require(feed(10000, detach ? 7 : 3, !detach).started == 0,
|
||||
"detaching or releasing combined modifier replayed a pending action");
|
||||
}
|
||||
WiiSwingGestures remote_only;
|
||||
WiiAccelerometerSample missing{};
|
||||
WiiAccelerometerSample sample{0, 0, 4096, 0, 0, true};
|
||||
for (unsigned i = 0; i < 20; ++i) {
|
||||
sample.timestamp_ms += 10;
|
||||
++sample.sequence;
|
||||
remote_only.update(sample, missing, sample.timestamp_ms, 1, 1, 7, 100);
|
||||
}
|
||||
sample.x = 10000;
|
||||
++sample.sequence;
|
||||
sample.timestamp_ms += 10;
|
||||
remote_only.update(sample, missing, sample.timestamp_ms, 1, 1, 7, 100);
|
||||
++sample.sequence;
|
||||
sample.timestamp_ms += 10;
|
||||
require(remote_only.update(sample, missing, sample.timestamp_ms, 1, 1, 7, 100).started == 1,
|
||||
"absent Nunchuk must not delay the Remote gesture");
|
||||
}
|
||||
|
||||
void test_nunchuk_button_and_combined_macro_runtime() {
|
||||
prepare_profiles();
|
||||
auto& profile = rows[0].profiles[0];
|
||||
profile.swing.button = 2;
|
||||
profile.nunchuk_swing.button = 1;
|
||||
profile.combined_swing.macro = 0;
|
||||
profile.macros[0].step_count = 1;
|
||||
profile.macros[0].mode = ControllerProfileMacroMode::kWhileHeld;
|
||||
profile.macros[0].trigger_mask = 1; // Not held during the combined gesture.
|
||||
profile.macro_step_count = 1;
|
||||
profile.macro_steps[0].override_flags = kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[0].duration_ms = 100;
|
||||
profile.macro_steps[0].output_button_mask = 8;
|
||||
for (unsigned i = 1; i < CONTROLLER_PROFILE_MACRO_COUNT; ++i)
|
||||
profile.macros[i].first_step = 1;
|
||||
auto snapshot = make_snapshot(0);
|
||||
uint32_t now = 0, sequence = 0;
|
||||
auto feed = [&](int16_t remote, int16_t nunchuk) {
|
||||
now += 10;
|
||||
++sequence;
|
||||
snapshot.accelerometer = {remote, 0, 4096, sequence, now, true};
|
||||
snapshot.nunchuk_accelerometer = {nunchuk, 0, 4096, sequence, now, true};
|
||||
return runtime_transform(0, snapshot, now);
|
||||
};
|
||||
for (unsigned i = 0; i < 20; ++i) feed(0, 0);
|
||||
feed(10000, 10000);
|
||||
auto result = feed(10000, 10000);
|
||||
require(result.state.button_north && !result.state.button_west && !result.state.button_east,
|
||||
"combined gesture must start its macro instead of both individual buttons");
|
||||
for (unsigned i = 0; i < 8; ++i)
|
||||
require(feed(0, 0).state.button_north, "gesture macro stopped when physical trigger was absent");
|
||||
feed(0, 0);
|
||||
require(!feed(0, 0).state.button_north, "gesture macro must finish exactly one cycle");
|
||||
for (unsigned i = 0; i < 30; ++i) feed(0, 0);
|
||||
feed(0, 10000);
|
||||
require(!feed(0, 10000).state.button_east, "Nunchuk alone must wait for combination window");
|
||||
for (unsigned i = 0; i < 10; ++i) feed(0, 10000);
|
||||
result = feed(0, 10000);
|
||||
require(result.state.button_east && !result.state.button_west && !result.state.button_north,
|
||||
"Nunchuk-only gesture must produce its configured button after the window");
|
||||
}
|
||||
|
||||
void test_gesture_macros_rearm_during_continuous_swings() {
|
||||
prepare_profiles();
|
||||
auto& profile = rows[0].profiles[0];
|
||||
profile.swing.macro = 0;
|
||||
profile.macros[0].step_count = 1;
|
||||
profile.macros[0].mode = ControllerProfileMacroMode::kRepeat;
|
||||
profile.macros[0].repeat_count = 5;
|
||||
profile.macro_step_count = 1;
|
||||
profile.macro_steps[0].override_flags = kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[0].duration_ms = 100;
|
||||
profile.macro_steps[0].output_button_mask = 8;
|
||||
auto snapshot = make_snapshot(0);
|
||||
uint32_t now = 0;
|
||||
unsigned starts = 0;
|
||||
bool held = false;
|
||||
auto feed = [&](int16_t force) {
|
||||
now += 10;
|
||||
snapshot.accelerometer = {force, 0, 4096, snapshot.accelerometer.sequence + 1, now, true};
|
||||
const bool pressed = runtime_transform(0, snapshot, now).state.button_north;
|
||||
if (pressed && !held) ++starts;
|
||||
held = pressed;
|
||||
};
|
||||
for (unsigned i = 0; i < 20; ++i) feed(0);
|
||||
for (unsigned stroke = 0; stroke < 4; ++stroke) {
|
||||
const int direction = stroke % 2 ? -1 : 1;
|
||||
for (unsigned i = 0; i < 24; ++i) feed(direction * 10000);
|
||||
for (unsigned i = 0; i < 8; ++i) feed(direction * 2000);
|
||||
require(starts == stroke + 1 && !held,
|
||||
"gesture macro must play once per stroke without requiring a full stop");
|
||||
}
|
||||
}
|
||||
|
||||
struct CombinedConfirmationRig {
|
||||
WiiSwingGestures gestures;
|
||||
uint32_t now = 1000, sequence = 0;
|
||||
unsigned counts[3]{};
|
||||
CombinedConfirmationRig() {
|
||||
for (unsigned i = 0; i < 20; ++i) feed(0, 0);
|
||||
}
|
||||
WiiSwingGestureResult feed(int16_t remote_force, int16_t nunchuk_force,
|
||||
uint8_t allowed = 7) {
|
||||
now += 10;
|
||||
++sequence;
|
||||
WiiAccelerometerSample remote{remote_force, 0, 4096, sequence, now, true};
|
||||
WiiAccelerometerSample nunchuk{nunchuk_force, 0, 4096, sequence, now, true};
|
||||
const auto result = gestures.update(remote, nunchuk, now, 1, 1, allowed, 100);
|
||||
for (unsigned i = 0; i < 3; ++i)
|
||||
if (result.started & (1u << i)) ++counts[i];
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
void test_combined_accepts_smaller_motion_before_or_after_full_swing() {
|
||||
for (bool weak_nunchuk : {false, true}) {
|
||||
for (int delay : {-60, 0, 60}) {
|
||||
CombinedConfirmationRig rig;
|
||||
for (int step = 0; step < 40; ++step) {
|
||||
const int strong_start = delay < 0 ? -delay : 0;
|
||||
const int weak_start = delay > 0 ? delay : 0;
|
||||
const int16_t strong = step * 10 >= strong_start ? 10000 : 0;
|
||||
const int16_t weak = step * 10 >= weak_start ? 4500 : 0;
|
||||
const auto result = rig.feed(weak_nunchuk ? strong : weak,
|
||||
weak_nunchuk ? weak : strong);
|
||||
require((result.active & 3) == 0, "confirmed combined motion leaked a single action");
|
||||
}
|
||||
require(rig.counts[2] == 1 && rig.counts[0] == 0 && rig.counts[1] == 0,
|
||||
"one full swing plus smaller companion motion must combine in either order");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void test_confirmation_does_not_weaken_individuals_or_accept_noise() {
|
||||
for (uint8_t allowed : {uint8_t{3}, uint8_t{7}}) {
|
||||
CombinedConfirmationRig rig;
|
||||
for (unsigned i = 0; i < 40; ++i) rig.feed(4500, 4500, allowed);
|
||||
require(rig.counts[0] == 0 && rig.counts[1] == 0 && rig.counts[2] == 0,
|
||||
"two subthreshold movements must not generate any action");
|
||||
}
|
||||
for (bool spike : {false, true}) {
|
||||
CombinedConfirmationRig rig;
|
||||
for (unsigned i = 0; i < 40; ++i)
|
||||
rig.feed(10000, spike && i == 2 ? 4500 : 0);
|
||||
require(rig.counts[0] == 1 && rig.counts[1] == 0 && rig.counts[2] == 0,
|
||||
"a stationary companion or one noisy sample must not confirm a combined swing");
|
||||
}
|
||||
}
|
||||
|
||||
void test_consumed_confirmation_suppresses_late_full_swing() {
|
||||
CombinedConfirmationRig rig;
|
||||
for (unsigned i = 0; i < 45; ++i)
|
||||
rig.feed(10000, i < 8 ? 4500 : 10000);
|
||||
require(rig.counts[2] == 1 && rig.counts[0] == 0 && rig.counts[1] == 0,
|
||||
"a confirmation growing into a full swing must not leak a later individual action");
|
||||
}
|
||||
|
||||
void test_expired_or_discarded_confirmation_cannot_be_reused() {
|
||||
for (bool discard : {false, true}) {
|
||||
CombinedConfirmationRig rig;
|
||||
for (unsigned i = 0; i < 3; ++i) rig.feed(0, 4500);
|
||||
if (discard) rig.gestures.discard_actions();
|
||||
else for (unsigned i = 0; i < 12; ++i) rig.feed(0, 0);
|
||||
for (unsigned i = 0; i < 40; ++i) rig.feed(10000, 0);
|
||||
require(rig.counts[0] == 1 && rig.counts[1] == 0 && rig.counts[2] == 0,
|
||||
"expired or macro-discarded evidence must not confirm a later swing");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool bluepad32_input_backend_toggle_motion(
|
||||
|
|
@ -1441,5 +1661,13 @@ int main() {
|
|||
test_swing_modifier_release_cancels_and_requires_fresh_settle();
|
||||
test_back_and_forth_swings_do_not_require_full_stops();
|
||||
test_early_rebound_cannot_become_a_delayed_swing();
|
||||
test_combined_swings_have_priority_at_window_boundary();
|
||||
test_combined_pending_cancels_on_detach_and_modifier_release();
|
||||
test_nunchuk_button_and_combined_macro_runtime();
|
||||
test_gesture_macros_rearm_during_continuous_swings();
|
||||
test_combined_accepts_smaller_motion_before_or_after_full_swing();
|
||||
test_confirmation_does_not_weaken_individuals_or_accept_noise();
|
||||
test_consumed_confirmation_suppresses_late_full_swing();
|
||||
test_expired_or_discarded_confirmation_cannot_be_reused();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,19 @@ bool read_encoded_database(void*, size_t offset, uint8_t* output,
|
|||
return true;
|
||||
}
|
||||
|
||||
void require_no_new_swing_actions(const ControllerProfile& profile) {
|
||||
require(profile.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
profile.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
profile.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
profile.nunchuk_swing.sensitivity == 1 &&
|
||||
profile.nunchuk_swing.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
profile.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
profile.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
profile.combined_swing.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
profile.combination_window_ms == 100,
|
||||
"legacy profile enabled a new gesture action or lost its defaults");
|
||||
}
|
||||
|
||||
void test_pair_identity_wire_and_member_validation() {
|
||||
ControllerIdentity left = identity(1);
|
||||
left.transport = ControllerTransport::kBle;
|
||||
|
|
@ -123,9 +136,9 @@ void test_profile_wire_schema() {
|
|||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
|
||||
"default profile did not encode");
|
||||
require(encoded[0] == 8 && encoded[1] == 0 &&
|
||||
require(encoded[0] == 9 && encoded[1] == 0 &&
|
||||
encoded[2] == 0x80 && encoded[3] == 1,
|
||||
"profile header is not little-endian v8/384");
|
||||
"profile header is not little-endian v9/384");
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||
require(encoded[4 + index] == index,
|
||||
|
|
@ -280,6 +293,7 @@ void test_legacy_profile_migration() {
|
|||
migrated.swing.sensitivity == 1 &&
|
||||
migrated.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
"legacy padding enabled a swing gesture");
|
||||
require_no_new_swing_actions(migrated);
|
||||
require(migrated.triggers[0].digital_threshold ==
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD &&
|
||||
migrated.triggers[1].digital_threshold ==
|
||||
|
|
@ -405,6 +419,9 @@ void test_legacy_profile_migration() {
|
|||
previous_encoded[0] = version;
|
||||
previous_encoded[75] = version == 4 ? 0x21 : 0;
|
||||
migrated.swing = {2, 2, 24};
|
||||
migrated.nunchuk_swing = {0, 0, 0};
|
||||
migrated.combined_swing = {15, CONTROLLER_PROFILE_NO_BUTTON, 24};
|
||||
migrated.combination_window_ms = 30;
|
||||
require(controller_profile_decode(
|
||||
previous_encoded, sizeof(previous_encoded), &migrated) &&
|
||||
migrated.triggers[0].output ==
|
||||
|
|
@ -423,6 +440,7 @@ void test_legacy_profile_migration() {
|
|||
migrated.swing.sensitivity == 1 &&
|
||||
migrated.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
"legacy control mapping or action migration lost settings");
|
||||
require_no_new_swing_actions(migrated);
|
||||
}
|
||||
|
||||
ControllerProfile current =
|
||||
|
|
@ -554,6 +572,7 @@ void test_set_b_sparse_extension_and_migration() {
|
|||
decoded.swing.sensitivity == 1 &&
|
||||
decoded.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
"schema5 full136-byte macro stream did not migrate");
|
||||
require_no_new_swing_actions(decoded);
|
||||
require(controller_profile_encode(decoded, encoded, sizeof(encoded)) &&
|
||||
memcmp(&legacy[4], &encoded[4], sizeof(legacy) - 4) == 0,
|
||||
"schema5 migration changed existing profile data");
|
||||
|
|
@ -637,6 +656,7 @@ void test_schema6_migration_preserves_every_setting() {
|
|||
profile.swing.sensitivity == 1 &&
|
||||
profile.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
"schema6 padding became a swing gesture");
|
||||
require_no_new_swing_actions(profile);
|
||||
for (uint8_t index = 0; index < CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT; ++index) {
|
||||
require(profile.extra_button_map[index] == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
profile.shift.extra_button_map[index] == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
|
|
@ -750,6 +770,7 @@ void test_schema7_migration_preserves_extra_controls_and_macros() {
|
|||
migrated.swing.sensitivity == 1 &&
|
||||
migrated.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
"schema7 migration lost existing settings or enabled swing");
|
||||
require_no_new_swing_actions(migrated);
|
||||
for (size_t offset = 364; offset < sizeof(legacy); ++offset) {
|
||||
legacy[offset] = 1;
|
||||
require(!controller_profile_decode(legacy, sizeof(legacy), &migrated),
|
||||
|
|
@ -758,39 +779,124 @@ void test_schema7_migration_preserves_extra_controls_and_macros() {
|
|||
}
|
||||
}
|
||||
|
||||
void test_schema8_migration_preserves_remote_swing() {
|
||||
uint8_t legacy[sizeof(kLegacySchema6Profile)]{};
|
||||
memcpy(legacy, kLegacySchema6Profile, sizeof(legacy));
|
||||
legacy[0] = 8;
|
||||
legacy[344] = 16;
|
||||
legacy[351] = 15;
|
||||
legacy[358] = 0x40;
|
||||
legacy[362] = 0x55;
|
||||
legacy[363] = 0x2a;
|
||||
legacy[364] = 15;
|
||||
legacy[365] = 2;
|
||||
legacy[366] = 24;
|
||||
ControllerProfile migrated{};
|
||||
migrated.swing.macro = 0;
|
||||
migrated.nunchuk_swing = {0, 0, 0};
|
||||
migrated.combined_swing = {1, CONTROLLER_PROFILE_NO_BUTTON, 16};
|
||||
migrated.combination_window_ms = 200;
|
||||
uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_decode(legacy, sizeof(legacy), &migrated) &&
|
||||
controller_profile_encode(migrated, upgraded, sizeof(upgraded)) &&
|
||||
memcmp(legacy + 2, upgraded + 2, 365) == 0 &&
|
||||
migrated.swing.button == 15 &&
|
||||
migrated.swing.sensitivity == 2 &&
|
||||
migrated.swing.modifier == 24,
|
||||
"schema8 migration changed Remote, extras, or macro settings");
|
||||
require_no_new_swing_actions(migrated);
|
||||
for (size_t offset = 367; offset < sizeof(legacy); ++offset) {
|
||||
legacy[offset] = 1;
|
||||
require(!controller_profile_decode(legacy, sizeof(legacy), &migrated),
|
||||
"schema8 interpreted reserved padding as new swing actions");
|
||||
legacy[offset] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void test_swing_wire_settings_and_rejection() {
|
||||
ControllerProfile profile =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
profile.swing = {15, 2, 24};
|
||||
profile.nunchuk_swing = {0, 0, 0};
|
||||
profile.combined_swing = {4, CONTROLLER_PROFILE_NO_BUTTON, 16};
|
||||
profile.combination_window_ms = 30;
|
||||
profile.macros[0].step_count = 1;
|
||||
profile.macro_step_count = 1;
|
||||
profile.macro_steps[0].duration_ms = 1;
|
||||
for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) {
|
||||
profile.macros[index].first_step = 1;
|
||||
}
|
||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
ControllerProfile decoded{};
|
||||
const uint8_t button_actions[] = {
|
||||
15, 2, 24, 255, 0, 0, 0, 255, 4, 255, 16, 30};
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
|
||||
encoded[364] == 15 && encoded[365] == 2 && encoded[366] == 24 &&
|
||||
memcmp(encoded + 364, button_actions, sizeof(button_actions)) == 0 &&
|
||||
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
|
||||
decoded.swing.button == 15 && decoded.swing.sensitivity == 2 &&
|
||||
decoded.swing.modifier == 24,
|
||||
"swing wire settings did not round-trip");
|
||||
decoded.swing.modifier == 24 &&
|
||||
decoded.nunchuk_swing.button == 0 &&
|
||||
decoded.nunchuk_swing.sensitivity == 0 &&
|
||||
decoded.nunchuk_swing.modifier == 0 &&
|
||||
decoded.combined_swing.button == 4 &&
|
||||
decoded.combined_swing.modifier == 16 &&
|
||||
decoded.combination_window_ms == 30,
|
||||
"independent swing button actions did not use the schema9 layout");
|
||||
const ControllerProfileSwingConfiguration invalid_settings[] = {
|
||||
{16, 2, 24}, {254, 2, 24}, {15, 3, 24}, {15, 255, 24},
|
||||
{15, 2, 25}, {15, 2, 254}, {255, 3, 255}, {255, 1, 25}};
|
||||
for (const auto& invalid : invalid_settings) {
|
||||
profile.swing = invalid;
|
||||
require(!controller_profile_validate(profile) &&
|
||||
!controller_profile_encode(profile, encoded, sizeof(encoded)),
|
||||
"invalid swing settings were accepted for encoding");
|
||||
encoded[364] = invalid.button;
|
||||
encoded[365] = invalid.sensitivity;
|
||||
encoded[366] = invalid.modifier;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"invalid swing wire settings were accepted");
|
||||
{15, 2, 25}, {15, 2, 254}, {255, 3, 255}, {255, 1, 25},
|
||||
{255, 1, 24, 4}, {255, 1, 24, 254}, {0, 1, 24, 0}};
|
||||
for (uint8_t sensor = 0; sensor < 2; ++sensor) {
|
||||
const size_t offset = 364 + sensor * 4;
|
||||
for (const auto& settings : invalid_settings) {
|
||||
ControllerProfile invalid = profile;
|
||||
(sensor == 0 ? invalid.swing : invalid.nunchuk_swing) = settings;
|
||||
uint8_t malformed[sizeof(encoded)]{};
|
||||
require(!controller_profile_validate(invalid) &&
|
||||
!controller_profile_encode(invalid, malformed,
|
||||
sizeof(malformed)),
|
||||
"invalid single-sensor swing action was accepted for encoding");
|
||||
memcpy(malformed, encoded, sizeof(malformed));
|
||||
malformed[offset] = settings.button;
|
||||
malformed[offset + 1] = settings.sensitivity;
|
||||
malformed[offset + 2] = settings.modifier;
|
||||
malformed[offset + 3] = settings.macro;
|
||||
require(!controller_profile_decode(malformed, sizeof(malformed), &decoded),
|
||||
"invalid single-sensor swing wire action was accepted");
|
||||
}
|
||||
}
|
||||
profile.swing = {0, 0, 0};
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
|
||||
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
|
||||
decoded.swing.button == 0 && decoded.swing.sensitivity == 0 &&
|
||||
decoded.swing.modifier == 0,
|
||||
"zero-valued swing settings were mistaken for disabled settings");
|
||||
for (size_t offset = 367; offset < sizeof(encoded); ++offset) {
|
||||
const ControllerProfileCombinedSwingConfiguration invalid_combined[] = {
|
||||
{16, 255, 24}, {254, 255, 24}, {255, 4, 24},
|
||||
{255, 254, 24}, {0, 0, 24}, {15, 255, 25}, {255, 255, 254}};
|
||||
for (const auto& settings : invalid_combined) {
|
||||
ControllerProfile invalid = profile;
|
||||
invalid.combined_swing = settings;
|
||||
uint8_t malformed[sizeof(encoded)]{};
|
||||
require(!controller_profile_validate(invalid) &&
|
||||
!controller_profile_encode(invalid, malformed, sizeof(malformed)),
|
||||
"invalid combined swing action was accepted for encoding");
|
||||
memcpy(malformed, encoded, sizeof(malformed));
|
||||
malformed[372] = settings.button;
|
||||
malformed[373] = settings.macro;
|
||||
malformed[374] = settings.modifier;
|
||||
require(!controller_profile_decode(malformed, sizeof(malformed), &decoded),
|
||||
"invalid combined swing wire action was accepted");
|
||||
}
|
||||
const uint8_t invalid_windows[] = {29, 201};
|
||||
for (uint8_t window : invalid_windows) {
|
||||
ControllerProfile invalid = profile;
|
||||
invalid.combination_window_ms = window;
|
||||
uint8_t malformed[sizeof(encoded)]{};
|
||||
require(!controller_profile_validate(invalid) &&
|
||||
!controller_profile_encode(invalid, malformed, sizeof(malformed)),
|
||||
"out-of-range combination window was accepted for encoding");
|
||||
memcpy(malformed, encoded, sizeof(malformed));
|
||||
malformed[375] = window;
|
||||
require(!controller_profile_decode(malformed, sizeof(malformed), &decoded),
|
||||
"out-of-range combination window was accepted from wire");
|
||||
}
|
||||
for (size_t offset = 376; offset < sizeof(encoded); ++offset) {
|
||||
require(encoded[offset] == 0, "schema9 reserved tail was not zero");
|
||||
encoded[offset] = 1;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"nonzero swing extension reservation was accepted");
|
||||
|
|
@ -798,12 +904,85 @@ void test_swing_wire_settings_and_rejection() {
|
|||
}
|
||||
profile.swing = {CONTROLLER_PROFILE_NO_BUTTON, 0,
|
||||
CONTROLLER_PROFILE_NO_BUTTON};
|
||||
profile.nunchuk_swing = profile.swing;
|
||||
profile.combined_swing = {};
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
|
||||
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
|
||||
decoded.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
decoded.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
decoded.swing.sensitivity == 0 &&
|
||||
decoded.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
"disabled swing did not preserve sensitivity and absent modifier");
|
||||
decoded.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
decoded.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
decoded.nunchuk_swing.sensitivity == 0 &&
|
||||
decoded.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
decoded.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
"disabled gestures became actions or lost their sensitivity");
|
||||
}
|
||||
|
||||
void test_swing_macro_targets_require_playable_steps() {
|
||||
ControllerProfile profile =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
profile.macros[0].step_count = 2;
|
||||
for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) {
|
||||
profile.macros[index].first_step = 2;
|
||||
}
|
||||
profile.macros[3].step_count = 1;
|
||||
profile.macro_step_count = 3;
|
||||
profile.macro_steps[1].duration_ms = 1;
|
||||
profile.macro_steps[2].duration_ms = 10000;
|
||||
profile.swing = {255, 2, 24, 0};
|
||||
profile.nunchuk_swing = {255, 0, 255, 3};
|
||||
profile.combined_swing = {255, 0, 0};
|
||||
profile.combination_window_ms = 200;
|
||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
ControllerProfile decoded{};
|
||||
const uint8_t macro_actions[] = {
|
||||
255, 2, 24, 0, 255, 0, 255, 3, 255, 0, 0, 200};
|
||||
for (uint8_t mode = 0; mode <= 3; ++mode) {
|
||||
profile.macros[0].mode = static_cast<ControllerProfileMacroMode>(mode);
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
|
||||
memcmp(encoded + 364, macro_actions, sizeof(macro_actions)) == 0 &&
|
||||
controller_profile_decode(encoded, sizeof(encoded), &decoded) &&
|
||||
decoded.swing.macro == 0 && decoded.nunchuk_swing.macro == 3 &&
|
||||
decoded.combined_swing.macro == 0 &&
|
||||
decoded.combination_window_ms == 200 &&
|
||||
decoded.macros[0].mode == profile.macros[0].mode &&
|
||||
decoded.macros[0].trigger_mask == 0 &&
|
||||
decoded.macro_steps[0].duration_ms == 0 &&
|
||||
decoded.macro_steps[1].duration_ms == 1 &&
|
||||
decoded.macro_steps[2].duration_ms == 10000,
|
||||
"gesture macro targets lost steps, playback mode, or index boundaries");
|
||||
}
|
||||
profile.swing = {};
|
||||
profile.nunchuk_swing = {};
|
||||
profile.combined_swing = {};
|
||||
profile.macros[0].mode = ControllerProfileMacroMode::kOnce;
|
||||
profile.macro_steps[1].duration_ms = 0;
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
|
||||
"unbound zero-duration one-shot macro was rejected");
|
||||
const size_t macro_offsets[] = {367, 371, 373};
|
||||
for (uint8_t gesture = 0; gesture < 3; ++gesture) {
|
||||
for (uint8_t target = 0; target < 2; ++target) {
|
||||
ControllerProfile invalid = profile;
|
||||
uint8_t* bindings[] = {&invalid.swing.macro,
|
||||
&invalid.nunchuk_swing.macro,
|
||||
&invalid.combined_swing.macro};
|
||||
*bindings[gesture] = target;
|
||||
uint8_t malformed[sizeof(encoded)]{};
|
||||
require(!controller_profile_validate(invalid) &&
|
||||
!controller_profile_encode(invalid, malformed, sizeof(malformed)),
|
||||
"gesture accepted a zero-duration or empty macro target");
|
||||
memcpy(malformed, encoded, sizeof(malformed));
|
||||
malformed[macro_offsets[gesture]] = target;
|
||||
require(!controller_profile_decode(malformed, sizeof(malformed), &decoded),
|
||||
"wire gesture accepted a zero-duration or empty macro target");
|
||||
}
|
||||
}
|
||||
profile.macro_steps[1].duration_ms = 1;
|
||||
profile.macro_steps[2].duration_ms = 0;
|
||||
profile.nunchuk_swing.macro = 3;
|
||||
require(!controller_profile_validate(profile),
|
||||
"gesture used another macro's duration to validate its target");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
@ -817,6 +996,8 @@ int main() {
|
|||
test_schema6_migration_preserves_every_setting();
|
||||
test_extra_control_schema_round_trip_and_output_limits();
|
||||
test_schema7_migration_preserves_extra_controls_and_macros();
|
||||
test_schema8_migration_preserves_remote_swing();
|
||||
test_swing_wire_settings_and_rejection();
|
||||
test_swing_macro_targets_require_playable_steps();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -813,6 +813,60 @@ void test_extra_shift_and_macro_sources_are_consumed() {
|
|||
"while-held macro survived release of an extra trigger source");
|
||||
}
|
||||
|
||||
void test_gesture_macros_play_once_and_preserve_trigger_inputs() {
|
||||
for (auto mode : {ControllerProfileMacroMode::kWhileHeld,
|
||||
ControllerProfileMacroMode::kToggle,
|
||||
ControllerProfileMacroMode::kRepeat}) {
|
||||
auto profile = profile_with_macro(ControllerProfileLogicalButton::kSouth);
|
||||
profile.macro_step_count = 1;
|
||||
profile.macros[0].step_count = 1;
|
||||
profile.macros[0].mode = mode;
|
||||
profile.macros[0].repeat_count = 5;
|
||||
profile.macro_steps[0].duration_ms = 100;
|
||||
profile.macro_steps[0].override_flags = kControllerProfileOverrideLeftStick;
|
||||
profile.macro_steps[0].left_stick_x = 12345;
|
||||
ControllerSyntheticInputContext context{};
|
||||
ControllerState input{};
|
||||
auto output = controller_synthetic_input_apply(&context, input, profile, 1000, 0, false, 0);
|
||||
require(output.state.left_stick_x == 12345, "gesture must start macro immediately");
|
||||
input.button_south = true;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 1050);
|
||||
require(output.state.left_stick_x == 12345 && output.state.button_south,
|
||||
"gesture macro consumed its unrelated physical trigger button");
|
||||
input.button_south = false;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 1090);
|
||||
require(output.state.left_stick_x == 12345, "gesture macro stopped on trigger release");
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 1100);
|
||||
require(output.state.left_stick_x == 0 && !context.macro_active,
|
||||
"gesture macro repeated according to physical playback mode");
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 1200, 0, false, 0);
|
||||
require(output.state.left_stick_x == 12345, "later gesture could not restart completed macro");
|
||||
input.button_capture = true;
|
||||
output = controller_synthetic_input_apply(&context, input, profile, 1210, 0, false, 0);
|
||||
require(!context.macro_active && output.state.left_stick_x == 0,
|
||||
"explicit cancel must beat a simultaneous gesture request");
|
||||
}
|
||||
}
|
||||
|
||||
void test_physical_macro_precedes_simultaneous_gesture_request() {
|
||||
auto profile = controller_profile_default(controller_identity_global(), 0);
|
||||
profile.macro_step_count = 2;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
profile.macros[i].first_step = i;
|
||||
profile.macros[i].step_count = 1;
|
||||
profile.macro_steps[i].override_flags = kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[i].duration_ms = 100;
|
||||
profile.macro_steps[i].output_button_mask = i == 0 ? 4 : 8;
|
||||
}
|
||||
profile.macros[1].trigger_mask = 1;
|
||||
ControllerState input{};
|
||||
input.button_south = true;
|
||||
ControllerSyntheticInputContext context{};
|
||||
const auto output = controller_synthetic_input_apply(&context, input, profile, 100, 0, false, 0);
|
||||
require(output.state.button_north && !output.state.button_west,
|
||||
"gesture request stole a simultaneous physical macro trigger");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
|
|
@ -829,5 +883,7 @@ int main() {
|
|||
test_shift_maps_consumption_and_physical_bindings();
|
||||
test_macro_playback_modes_and_bounded_cycle_skips();
|
||||
test_extra_shift_and_macro_sources_are_consumed();
|
||||
test_gesture_macros_play_once_and_preserve_trigger_inputs();
|
||||
test_physical_macro_precedes_simultaneous_gesture_request();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -309,6 +309,9 @@ void test_current_schema_validation_and_atomic_selection() {
|
|||
updated.extra_button_map[6] = 16;
|
||||
updated.shift.extra_button_map[0] = 3;
|
||||
updated.swing = {2, 2, 24};
|
||||
updated.nunchuk_swing = {255, 0, 18, 0};
|
||||
updated.combined_swing = {15, 255, 16};
|
||||
updated.combination_window_ms = 200;
|
||||
updated.macros[0].trigger_mask = 1u << 24;
|
||||
updated.macros[0].cancel_control = 19;
|
||||
updated.macros[0].step_count = 1;
|
||||
|
|
@ -322,7 +325,7 @@ void test_current_schema_validation_and_atomic_selection() {
|
|||
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
|
||||
"extended service profile did not encode");
|
||||
require(profile_service_begin(
|
||||
20, id, 6, CONTROLLER_PROFILE_EXTRA_CONTROL_SCHEMA_VERSION,
|
||||
20, id, 6, CONTROLLER_PROFILE_SWING_SCHEMA_VERSION,
|
||||
CONTROLLER_PROFILE_ENCODED_SIZE, 0) ==
|
||||
ConfigurationTransactionStatus::kUnsupportedSchema &&
|
||||
profile_service_begin(21, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION,
|
||||
|
|
@ -352,24 +355,46 @@ void test_current_schema_validation_and_atomic_selection() {
|
|||
old_selection.profile.turbo_defaults.rate_hz,
|
||||
"rejected extension replaced the old selected snapshot");
|
||||
|
||||
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
|
||||
"swing rejection baseline did not encode");
|
||||
encoded[366] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
|
||||
require(profile_service_begin(
|
||||
25, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
|
||||
profile_storage_crc32(encoded, sizeof(encoded))) ==
|
||||
ConfigurationTransactionStatus::kReceiving &&
|
||||
profile_service_append(25, 0, encoded, sizeof(encoded)) ==
|
||||
ConfigurationTransactionStatus::kReceiving &&
|
||||
profile_service_commit(25) ==
|
||||
ConfigurationTransactionStatus::kMalformed,
|
||||
"service admitted an invalid swing modifier with a valid CRC");
|
||||
profile_service_selected_snapshot(&selected);
|
||||
require(selected.metadata.generation == old_selection.metadata.generation &&
|
||||
selected.profile.swing.button == old_selection.profile.swing.button &&
|
||||
active_snapshot(id).profile.swing.button ==
|
||||
old_selection.profile.swing.button,
|
||||
"rejected swing replaced a selected or active profile");
|
||||
const struct {
|
||||
size_t offset;
|
||||
uint8_t value;
|
||||
} malformed_gestures[] = {
|
||||
{366, 25}, {367, 0}, {368, 0}, {369, 3}, {370, 25},
|
||||
{371, 1}, {371, 4}, {372, 16}, {373, 0}, {374, 25},
|
||||
{375, 29}, {375, 201}, {376, 1}, {121, 0}};
|
||||
uint8_t stored_before[sizeof(flash.bytes)];
|
||||
memcpy(stored_before, flash.bytes, sizeof(stored_before));
|
||||
uint32_t transaction_id = 25;
|
||||
for (const auto& malformed : malformed_gestures) {
|
||||
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
|
||||
"gesture rejection baseline did not encode");
|
||||
encoded[malformed.offset] = malformed.value;
|
||||
require(profile_service_begin(
|
||||
transaction_id, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION,
|
||||
sizeof(encoded), profile_storage_crc32(encoded, sizeof(encoded))) ==
|
||||
ConfigurationTransactionStatus::kReceiving &&
|
||||
profile_service_append(transaction_id, 0, encoded, sizeof(encoded)) ==
|
||||
ConfigurationTransactionStatus::kReceiving &&
|
||||
profile_service_commit(transaction_id) ==
|
||||
ConfigurationTransactionStatus::kMalformed,
|
||||
"service admitted a malformed gesture or unplayable macro with a valid CRC");
|
||||
profile_service_selected_snapshot(&selected);
|
||||
const auto active = active_snapshot(id);
|
||||
require(selected.metadata.generation == old_selection.metadata.generation &&
|
||||
selected.profile.swing.button == old_selection.profile.swing.button &&
|
||||
selected.profile.nunchuk_swing.macro ==
|
||||
old_selection.profile.nunchuk_swing.macro &&
|
||||
selected.profile.combined_swing.button ==
|
||||
old_selection.profile.combined_swing.button &&
|
||||
active.profile.swing.button == old_selection.profile.swing.button &&
|
||||
active.profile.nunchuk_swing.macro ==
|
||||
old_selection.profile.nunchuk_swing.macro &&
|
||||
active.profile.combined_swing.button ==
|
||||
old_selection.profile.combined_swing.button &&
|
||||
memcmp(stored_before, flash.bytes, sizeof(stored_before)) == 0,
|
||||
"rejected gesture replaced a selected, active, or stored profile");
|
||||
++transaction_id;
|
||||
}
|
||||
|
||||
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
|
||||
"valid replacement did not encode");
|
||||
|
|
@ -387,7 +412,11 @@ void test_current_schema_validation_and_atomic_selection() {
|
|||
profile_service_selected_snapshot(&selected);
|
||||
require(selected.metadata.generation == old_selection.metadata.generation &&
|
||||
active_snapshot(id).profile.turbo_defaults.rate_hz ==
|
||||
old_selection.profile.turbo_defaults.rate_hz,
|
||||
old_selection.profile.turbo_defaults.rate_hz &&
|
||||
selected.profile.nunchuk_swing.macro ==
|
||||
old_selection.profile.nunchuk_swing.macro &&
|
||||
active_snapshot(id).profile.combined_swing.button ==
|
||||
old_selection.profile.combined_swing.button,
|
||||
"pending write replaced a selected or active profile before commit");
|
||||
profile_service_task_on_storage_core(8000);
|
||||
profile_service_selected_snapshot(&selected);
|
||||
|
|
@ -400,9 +429,21 @@ void test_current_schema_validation_and_atomic_selection() {
|
|||
selected.profile.swing.button == 2 &&
|
||||
selected.profile.swing.sensitivity == 2 &&
|
||||
selected.profile.swing.modifier == 24 &&
|
||||
selected.profile.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
selected.profile.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
selected.profile.nunchuk_swing.macro == 0 &&
|
||||
selected.profile.nunchuk_swing.sensitivity == 0 &&
|
||||
selected.profile.nunchuk_swing.modifier == 18 &&
|
||||
selected.profile.combined_swing.button == 15 &&
|
||||
selected.profile.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
selected.profile.combined_swing.modifier == 16 &&
|
||||
selected.profile.combination_window_ms == 200 &&
|
||||
active_snapshot(id).profile.swing.button == 2 &&
|
||||
active_snapshot(id).profile.swing.sensitivity == 2 &&
|
||||
active_snapshot(id).profile.swing.modifier == 24 &&
|
||||
active_snapshot(id).profile.nunchuk_swing.macro == 0 &&
|
||||
active_snapshot(id).profile.combined_swing.button == 15 &&
|
||||
active_snapshot(id).profile.combination_window_ms == 200 &&
|
||||
active_snapshot(id).profile.macros[0].trigger_mask == (1u << 24) &&
|
||||
active_snapshot(id).profile.macros[0].cancel_control == 19 &&
|
||||
active_snapshot(id).profile.turbo_defaults.rate_hz == 30,
|
||||
|
|
@ -470,6 +511,16 @@ void test_catalog1_selected_and_active_snapshots_migrate() {
|
|||
selected.profile.shortcuts.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
selected.profile.shift.mode == ControllerProfileShiftMode::kOff &&
|
||||
selected.profile.macros[0].mode == ControllerProfileMacroMode::kOnce &&
|
||||
selected.profile.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
selected.profile.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
selected.profile.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
selected.profile.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
selected.profile.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
active.profile.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
active.profile.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
active.profile.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
active.profile.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
active.profile.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
selected.metadata.generation > 42,
|
||||
"old selection/activation snapshots lost migrated content or defaults");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -523,12 +523,31 @@ void test_compaction_preserves_latest_records() {
|
|||
profile.extra_button_map[0] = 16;
|
||||
profile.shift.extra_button_map[6] = 15;
|
||||
profile.swing = {2, 2, 24};
|
||||
profile.nunchuk_swing = {255, 0, 18, 3};
|
||||
profile.combined_swing = {4, 255, 16};
|
||||
profile.combination_window_ms = 30;
|
||||
profile.macros[3].step_count = 1;
|
||||
profile.macros[3].mode = ControllerProfileMacroMode::kToggle;
|
||||
profile.macro_step_count = 1;
|
||||
profile.macro_steps[0].duration_ms = 50;
|
||||
ControllerProfile other = controller_profile_default(global, 7);
|
||||
other.swing = {15, 0, CONTROLLER_PROFILE_NO_BUTTON};
|
||||
other.nunchuk_swing = {1, 2, 24};
|
||||
other.combined_swing = {255, 0, 255};
|
||||
other.combination_window_ms = 200;
|
||||
other.macros[0].step_count = 1;
|
||||
other.macro_step_count = 1;
|
||||
other.macro_steps[0].duration_ms = 75;
|
||||
for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) {
|
||||
other.macros[index].first_step = 1;
|
||||
}
|
||||
const ControllerIdentity stable = identity(1);
|
||||
require(storage.set(global, 7, other) == ProfileStorageResult::kOk,
|
||||
"second swing profile did not append");
|
||||
other.swing = {0, 1, 16};
|
||||
other.swing = {255, 1, 16, 0};
|
||||
other.nunchuk_swing = {3, 1, 17};
|
||||
other.combined_swing = {14, 255, 24};
|
||||
other.combination_window_ms = 120;
|
||||
require(storage.set(stable, 0, other) == ProfileStorageResult::kOk,
|
||||
"stable identity swing profile did not append");
|
||||
for (uint16_t write = 1; write <= 260; ++write) {
|
||||
|
|
@ -550,6 +569,17 @@ void test_compaction_preserves_latest_records() {
|
|||
recovered.swing.button == 2 &&
|
||||
recovered.swing.sensitivity == 2 &&
|
||||
recovered.swing.modifier == 24 &&
|
||||
recovered.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.nunchuk_swing.sensitivity == 0 &&
|
||||
recovered.nunchuk_swing.modifier == 18 &&
|
||||
recovered.nunchuk_swing.macro == 3 &&
|
||||
recovered.combined_swing.button == 4 &&
|
||||
recovered.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.combined_swing.modifier == 16 &&
|
||||
recovered.combination_window_ms == 30 &&
|
||||
recovered.macros[3].mode == ControllerProfileMacroMode::kToggle &&
|
||||
recovered.macro_steps[0].duration_ms == 50 &&
|
||||
reloaded.get_alias(global, metadata, sizeof(metadata)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "Fallback") == 0 &&
|
||||
|
|
@ -558,16 +588,45 @@ void test_compaction_preserves_latest_records() {
|
|||
ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "Compacted") == 0,
|
||||
"compaction did not preserve profiles and metadata");
|
||||
const uint32_t generation = reloaded.snapshot().generation;
|
||||
const int programs = flash.programs;
|
||||
recovered.nunchuk_swing.button = 0;
|
||||
require(reloaded.set(global, 0, recovered) ==
|
||||
ProfileStorageResult::kInvalidArgument &&
|
||||
reloaded.snapshot().generation == generation &&
|
||||
flash.programs == programs &&
|
||||
reloaded.get(global, 0, &recovered) == ProfileStorageResult::kOk &&
|
||||
recovered.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.nunchuk_swing.macro == 3,
|
||||
"rejected gesture action modified the stored profile");
|
||||
require(reloaded.get(global, 7, &recovered) == ProfileStorageResult::kOk &&
|
||||
recovered.swing.button == 15 &&
|
||||
recovered.swing.sensitivity == 0 &&
|
||||
recovered.swing.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.nunchuk_swing.button == 1 &&
|
||||
recovered.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.combined_swing.macro == 0 &&
|
||||
recovered.combination_window_ms == 200 &&
|
||||
recovered.macro_steps[0].duration_ms == 75 &&
|
||||
reloaded.get(stable, 0, &recovered) == ProfileStorageResult::kOk &&
|
||||
recovered.swing.button == 0 &&
|
||||
recovered.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.swing.macro == 0 &&
|
||||
recovered.swing.sensitivity == 1 &&
|
||||
recovered.swing.modifier == 16 &&
|
||||
recovered.nunchuk_swing.button == 3 &&
|
||||
recovered.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.combined_swing.button == 14 &&
|
||||
recovered.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.combination_window_ms == 120 &&
|
||||
reloaded.get(global, 1, &recovered) == ProfileStorageResult::kOk &&
|
||||
recovered.swing.button == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
recovered.swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
recovered.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
"compacted swing settings bled across profiles or identities");
|
||||
}
|
||||
|
||||
|
|
@ -823,6 +882,62 @@ void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() {
|
|||
"editing a schema6 profile lost its active index, alias, or name");
|
||||
}
|
||||
|
||||
void test_schema8_swing_migration_is_lazy_and_preserves_existing_data() {
|
||||
erase_all();
|
||||
install_populated_catalog(3);
|
||||
const auto id = controller_identity_global();
|
||||
ControllerProfile original = catalog_profile(0, 0, true);
|
||||
original.extra_button_map[6] = 16;
|
||||
original.shift.extra_button_map[0] = 15;
|
||||
original.swing = {2, 0, 24};
|
||||
uint8_t legacy[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(original, legacy, sizeof(legacy)),
|
||||
"schema8 storage fixture did not encode");
|
||||
write_u16(legacy, CONTROLLER_PROFILE_SWING_SCHEMA_VERSION);
|
||||
memset(legacy + 367, 0, sizeof(legacy) - 367);
|
||||
install_catalog_record(3, PROFILE_STORAGE_RECORDS_OFFSET, 1, id, 0, 1001,
|
||||
legacy, sizeof(legacy));
|
||||
ProfileStorage storage;
|
||||
ControllerProfile migrated{};
|
||||
require(storage.initialize(fake_io()) &&
|
||||
storage.get(id, 0, &migrated) == ProfileStorageResult::kOk &&
|
||||
migrated.swing.button == 2 && migrated.swing.sensitivity == 0 &&
|
||||
migrated.swing.modifier == 24 &&
|
||||
migrated.swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
migrated.nunchuk_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
migrated.nunchuk_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
migrated.combined_swing.button == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
migrated.combined_swing.macro == CONTROLLER_PROFILE_NO_BUTTON &&
|
||||
migrated.combination_window_ms == 100 &&
|
||||
storage.set(id, 0, migrated) == ProfileStorageResult::kUnchanged &&
|
||||
flash.programs == 0 && flash.erases == 0,
|
||||
"schema8 read enabled new gestures or unnecessarily rewrote flash");
|
||||
migrated.nunchuk_swing = {255, 2, 18, 1};
|
||||
migrated.combined_swing = {255, 0, 16};
|
||||
migrated.combination_window_ms = 200;
|
||||
require(storage.set(id, 0, migrated) == ProfileStorageResult::kOk,
|
||||
"schema8 profile could not add gesture macro actions");
|
||||
ProfileStorage reloaded;
|
||||
ControllerProfile recovered{};
|
||||
uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
||||
require(reloaded.initialize(fake_io()) &&
|
||||
reloaded.get(id, 0, &recovered) == ProfileStorageResult::kOk &&
|
||||
controller_profile_encode(recovered, upgraded, sizeof(upgraded)) &&
|
||||
memcmp(legacy + 2, upgraded + 2, 365) == 0 &&
|
||||
recovered.nunchuk_swing.macro == 1 &&
|
||||
recovered.combined_swing.macro == 0 &&
|
||||
recovered.combination_window_ms == 200 &&
|
||||
reloaded.find(id)->active_profile == 0 &&
|
||||
reloaded.get_alias(id, metadata, sizeof(metadata)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "A") == 0 &&
|
||||
reloaded.get_profile_name(id, 0, metadata, sizeof(metadata)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "A0") == 0,
|
||||
"editing migrated gestures lost Remote, macros, extras, or metadata");
|
||||
}
|
||||
|
||||
void require_pair_bank(const ProfileStorage &storage,
|
||||
const ControllerIdentity &owner, uint8_t base,
|
||||
uint8_t active, char name_prefix) {
|
||||
|
|
@ -1052,6 +1167,7 @@ int main() {
|
|||
test_late_second_page_program_is_not_reused();
|
||||
test_unreadable_legacy_data_is_not_erased();
|
||||
test_schema6_read_migration_is_lazy_and_edit_preserves_metadata();
|
||||
test_schema8_swing_migration_is_lazy_and_preserves_existing_data();
|
||||
test_pair_seed_independence_reconnect_and_compaction();
|
||||
test_pair_capacity_and_unseeded_mutations();
|
||||
test_pair_seed_interruption_and_ambiguous_readback();
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
for scenario in (
|
||||
"wii-orientation",
|
||||
"wii-orientation-races",
|
||||
"wii-accelerometers",
|
||||
"switch2-individual-core-start",
|
||||
"switch2-individual-forward",
|
||||
"switch2-individual-reverse",
|
||||
|
|
|
|||
|
|
@ -1869,7 +1869,10 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
|
|||
legacy_json_object = default_profile.to_json_object()
|
||||
legacy_json_object["schema_version"] = config_manager.PROFILE_LEGACY_SCHEMA_VERSION
|
||||
legacy_json_object["size"] = config_manager.PROFILE_LEGACY_SIZE
|
||||
for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map", "swing"):
|
||||
for field in (
|
||||
"shortcuts", "shift", "turbo_settings", "extra_button_map", "swing",
|
||||
"nunchuk_swing", "combined_swing", "combination_window_ms",
|
||||
):
|
||||
del legacy_json_object[field]
|
||||
del legacy_json_object["motion_toggle_chord"]
|
||||
del legacy_json_object["triggers"]["left"]["output"]
|
||||
|
|
@ -1932,7 +1935,10 @@ def test_schema5_full_macro_stream_migrates_bytes_and_json(monkeypatch) -> None:
|
|||
|
||||
obj["schema_version"] = 5
|
||||
obj["size"] = 256
|
||||
for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map", "swing"):
|
||||
for field in (
|
||||
"shortcuts", "shift", "turbo_settings", "extra_button_map", "swing",
|
||||
"nunchuk_swing", "combined_swing", "combination_window_ms",
|
||||
):
|
||||
del obj[field]
|
||||
for macro in obj["macros"]:
|
||||
del macro["playback"]
|
||||
|
|
@ -1973,7 +1979,6 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
|
|||
assert encoded[336:344] == bytes((3, 255, 0, 1, 1, 1, 2, 1))
|
||||
assert encoded[344:358] == bytes([255]) * 14
|
||||
assert encoded[358:364] == bytes(6)
|
||||
assert encoded[367:] == bytes(17)
|
||||
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
|
||||
assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile
|
||||
|
||||
|
|
@ -1984,7 +1989,8 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
|
|||
legacy_json["schema_version"] = 6
|
||||
del legacy_json["extra_button_map"]
|
||||
del legacy_json["shift"]["extra_button_map"]
|
||||
del legacy_json["swing"]
|
||||
for field in ("swing", "nunchuk_swing", "combined_swing", "combination_window_ms"):
|
||||
del legacy_json[field]
|
||||
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
|
||||
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
|
||||
device = FakeDevice()
|
||||
|
|
@ -1998,7 +2004,7 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
|
|||
assert old_listing[1] == config_manager.ProfileListEntry(device.stable_identity, 1, "Custom controller")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [7, 8])
|
||||
@pytest.mark.parametrize("version", [7, 8, 9])
|
||||
def test_schema7_extra_controls_keep_output_channels_and_wire_layout(version: int) -> None:
|
||||
obj = custom_profile().to_json_object()
|
||||
obj["extra_button_map"] = dict(zip(
|
||||
|
|
@ -2021,6 +2027,11 @@ def test_schema7_extra_controls_keep_output_channels_and_wire_layout(version: in
|
|||
obj["schema_version"] = version
|
||||
if version == 7:
|
||||
del obj["swing"]
|
||||
elif version == 8:
|
||||
del obj["swing"]["macro"]
|
||||
if version < 9:
|
||||
for field in ("nunchuk_swing", "combined_swing", "combination_window_ms"):
|
||||
del obj[field]
|
||||
profile = config_manager.ControllerProfile.from_json_object(obj)
|
||||
encoded = profile.to_bytes()
|
||||
assert encoded[:4] == struct.pack("<HH", config_manager.PROFILE_SCHEMA_VERSION, 384)
|
||||
|
|
@ -2035,6 +2046,8 @@ def test_schema7_extra_controls_keep_output_channels_and_wire_layout(version: in
|
|||
struct.pack_into("<H", legacy_wire, 0, version)
|
||||
if version == 7:
|
||||
legacy_wire[364:] = bytes(20)
|
||||
elif version == 8:
|
||||
legacy_wire[367:] = bytes(17)
|
||||
device = FakeDevice()
|
||||
device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire)
|
||||
assert config_manager.read_profile(device, device.stable_identity, 1) == profile
|
||||
|
|
@ -2115,7 +2128,10 @@ def test_legacy_control_profiles_preserve_custom_actions(version: int) -> None:
|
|||
macro.pop("repeat_count")
|
||||
macro["steps"].append(config_manager.MacroStep.end().to_json_object())
|
||||
obj["macro"] = macro
|
||||
for key in ("shortcuts", "shift", "turbo_settings", "extra_button_map", "swing"):
|
||||
for key in (
|
||||
"shortcuts", "shift", "turbo_settings", "extra_button_map", "swing",
|
||||
"nunchuk_swing", "combined_swing", "combination_window_ms",
|
||||
):
|
||||
del obj[key]
|
||||
assert config_manager.ControllerProfile.from_json_object(obj) == profile
|
||||
assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()) == profile
|
||||
|
|
@ -2134,6 +2150,7 @@ def test_swing_profile_round_trip_preserves_other_settings(
|
|||
obj = before.to_json_object()
|
||||
obj["swing"] = {
|
||||
"button": button, "sensitivity": sensitivity, "modifier": modifier,
|
||||
"macro": None,
|
||||
}
|
||||
profile = config_manager.ControllerProfile.from_json_object(obj)
|
||||
assert replace(profile, swing=before.swing) == before
|
||||
|
|
@ -2154,6 +2171,7 @@ def test_swing_profile_round_trip_preserves_other_settings(
|
|||
("sensitivity", 1),
|
||||
("modifier", "unknown"),
|
||||
("modifier", False),
|
||||
("macro", 0), ("macro", 5), ("macro", True), ("macro", "1"),
|
||||
])
|
||||
def test_swing_rejects_invalid_json_settings(field: str, value: object) -> None:
|
||||
obj = config_manager.ControllerProfile.default().to_json_object()
|
||||
|
|
@ -2166,6 +2184,7 @@ def test_swing_rejects_invalid_json_settings(field: str, value: object) -> None:
|
|||
("button", -1), ("button", 16), ("button", True),
|
||||
("sensitivity", -1), ("sensitivity", 3), ("sensitivity", False),
|
||||
("modifier", -1), ("modifier", 25), ("modifier", 256),
|
||||
("macro", -1), ("macro", 4), ("macro", False),
|
||||
])
|
||||
def test_swing_rejects_invalid_in_memory_settings(field: str, value: object) -> None:
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
|
|
@ -2173,7 +2192,9 @@ def test_swing_rejects_invalid_in_memory_settings(field: str, value: object) ->
|
|||
|
||||
|
||||
@pytest.mark.parametrize(("offset", "value"), [
|
||||
(364, 16), (365, 3), (366, 25), (367, 1),
|
||||
(364, 16), (365, 3), (366, 25), (367, 4),
|
||||
(368, 16), (369, 3), (370, 25), (371, 4),
|
||||
(372, 16), (373, 4), (374, 25), (375, 29), (375, 201), (376, 1),
|
||||
])
|
||||
def test_swing_rejects_corrupt_wire_settings(offset: int, value: int) -> None:
|
||||
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
|
||||
|
|
@ -2184,6 +2205,7 @@ def test_swing_rejects_corrupt_wire_settings(offset: int, value: int) -> None:
|
|||
|
||||
@pytest.mark.parametrize("mutation", [
|
||||
"missing", "unknown", "missing_button", "missing_sensitivity", "missing_modifier",
|
||||
"missing_macro",
|
||||
"legacy_field",
|
||||
])
|
||||
def test_swing_json_fields_are_strict(mutation: str) -> None:
|
||||
|
|
@ -2195,11 +2217,117 @@ def test_swing_json_fields_are_strict(mutation: str) -> None:
|
|||
elif mutation.startswith("missing_"):
|
||||
del obj["swing"][mutation.removeprefix("missing_")]
|
||||
else:
|
||||
for field in ("nunchuk_swing", "combined_swing", "combination_window_ms"):
|
||||
del obj[field]
|
||||
obj["schema_version"] = 7
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
|
||||
|
||||
def test_schema8_swing_migration_preserves_remote_binding() -> None:
|
||||
profile = replace(custom_profile(), swing=config_manager.ProfileSwing(2, 2, 24))
|
||||
legacy_wire = bytearray(profile.to_bytes())
|
||||
struct.pack_into("<H", legacy_wire, 0, 8)
|
||||
legacy_wire[367:] = bytes(17)
|
||||
legacy_json = profile.to_json_object()
|
||||
legacy_json["schema_version"] = 8
|
||||
del legacy_json["swing"]["macro"]
|
||||
for field in ("nunchuk_swing", "combined_swing", "combination_window_ms"):
|
||||
del legacy_json[field]
|
||||
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
|
||||
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
|
||||
assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()) == profile
|
||||
legacy_wire[367] = 1
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_bytes(legacy_wire)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("macro_number", [1, 4])
|
||||
def test_gesture_macro_json_indices_and_wire_layout(macro_number: int) -> None:
|
||||
obj = custom_profile().to_json_object()
|
||||
obj["macros"][macro_number - 1] = {
|
||||
**obj["macros"][0], "trigger": [], "playback": "toggle",
|
||||
}
|
||||
obj["swing"].update(macro=macro_number, sensitivity="high", modifier="right_sr")
|
||||
obj["nunchuk_swing"].update(button="dpad_right", sensitivity="low", modifier="c")
|
||||
obj["combined_swing"].update(macro=macro_number, modifier="left_trigger")
|
||||
obj["combination_window_ms"] = 200
|
||||
profile = config_manager.ControllerProfile.from_json_object(obj)
|
||||
assert profile.swing.macro == profile.combined_swing.macro == macro_number - 1
|
||||
encoded = profile.to_bytes()
|
||||
assert encoded[364:376] == bytes((
|
||||
255, 2, 24, macro_number - 1, 15, 0, 18, 255,
|
||||
255, macro_number - 1, 16, 200,
|
||||
))
|
||||
assert encoded[376:] == bytes(8)
|
||||
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
|
||||
assert profile.to_json_object() == obj
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gesture", ["swing", "nunchuk_swing", "combined_swing"])
|
||||
def test_gesture_actions_are_exclusive(gesture: str) -> None:
|
||||
obj = custom_profile().to_json_object()
|
||||
obj[gesture].update(button="south", macro=1)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
action_type = (
|
||||
config_manager.ProfileCombinedSwing if gesture == "combined_swing"
|
||||
else config_manager.ProfileSwing
|
||||
)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
action_type(button=0, macro=0)
|
||||
payload = bytearray(custom_profile().to_bytes())
|
||||
button_offset, macro_offset = {
|
||||
"swing": (364, 367), "nunchuk_swing": (368, 371), "combined_swing": (372, 373),
|
||||
}[gesture]
|
||||
payload[button_offset] = payload[macro_offset] = 0
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_bytes(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gesture", ["swing", "nunchuk_swing", "combined_swing"])
|
||||
@pytest.mark.parametrize("empty", [False, True])
|
||||
def test_gesture_macro_requires_configured_positive_duration_target(
|
||||
gesture: str, empty: bool,
|
||||
) -> None:
|
||||
obj = custom_profile().to_json_object()
|
||||
target = obj["macros"][0]
|
||||
target["trigger"] = []
|
||||
if empty:
|
||||
target["steps"] = []
|
||||
else:
|
||||
for step in target["steps"]:
|
||||
step["duration_ms"] = 0
|
||||
unbound = config_manager.ControllerProfile.from_json_object(obj)
|
||||
obj[gesture]["macro"] = 1
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
replace(unbound, **{gesture: replace(getattr(unbound, gesture), macro=0)})
|
||||
payload = bytearray(unbound.to_bytes())
|
||||
payload[{"swing": 367, "nunchuk_swing": 371, "combined_swing": 373}[gesture]] = 0
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_bytes(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("window", [30, 200])
|
||||
def test_combination_window_inclusive_bounds_round_trip(window: int) -> None:
|
||||
obj = config_manager.ControllerProfile.default().to_json_object()
|
||||
obj["combination_window_ms"] = window
|
||||
profile = config_manager.ControllerProfile.from_json_object(obj)
|
||||
assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()).combination_window_ms == window
|
||||
|
||||
|
||||
@pytest.mark.parametrize("window", [29, 201, True, 100.0, None])
|
||||
def test_combination_window_rejects_out_of_range_or_noninteger_values(window: object) -> None:
|
||||
obj = config_manager.ControllerProfile.default().to_json_object()
|
||||
obj["combination_window_ms"] = window
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_json_object(obj)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
replace(config_manager.ControllerProfile.default(), combination_window_ms=window)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "value"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ def test_editor_serves_assets_and_complete_schema(
|
|||
assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES)
|
||||
assert schema["turbo_modes"] == list(config_manager.TURBO_MODES)
|
||||
assert schema["swing_sensitivities"] == ["low", "medium", "high"]
|
||||
assert schema["combination_window_bounds"] == {"min": 30, "max": 200}
|
||||
assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES)
|
||||
assert schema["profile_capacity"] == 8
|
||||
assert (
|
||||
|
|
@ -142,19 +143,23 @@ def test_switch2_input_choices_are_never_output_targets(
|
|||
assert schema["output_controls"] == list(config_manager.OUTPUT_CONTROLS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [6, 7])
|
||||
@pytest.mark.parametrize("version", [6, 7, 8])
|
||||
def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
|
||||
monkeypatch: pytest.MonkeyPatch, version: int,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
key = (device.stable_identity.to_bytes(), 1)
|
||||
profile = custom_profile()
|
||||
if version == 8:
|
||||
profile = replace(profile, swing=config_manager.ProfileSwing(3, 0, 24))
|
||||
legacy_wire = bytearray(profile.to_bytes())
|
||||
legacy_wire[:2] = version.to_bytes(2, "little")
|
||||
if version == 6:
|
||||
legacy_wire[344:] = bytes(40)
|
||||
else:
|
||||
elif version == 7:
|
||||
legacy_wire[364:] = bytes(20)
|
||||
else:
|
||||
legacy_wire[367:] = bytes(17)
|
||||
device.profiles[key] = bytes(legacy_wire)
|
||||
device.profile_aliases[key[0]] = "Living room"
|
||||
device.profile_names[key] = "Racing"
|
||||
|
|
@ -163,7 +168,7 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
|
|||
status, migrated = request_json(f"{base_url}/api/profiles/1/2")
|
||||
assert status == 200
|
||||
assert config_manager.ControllerProfile.from_json_object(migrated["profile"]) == profile
|
||||
assert migrated["profile"]["swing"]["button"] is None
|
||||
assert migrated["profile"]["swing"] == profile.swing.to_json_object()
|
||||
draft = migrated["profile"]
|
||||
draft["extra_button_map"] = dict(zip(config_manager.EXTRA_BUTTONS, config_manager.LOGICAL_BUTTONS[:7]))
|
||||
draft["shift"]["mode"] = "hold"
|
||||
|
|
@ -175,7 +180,11 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
|
|||
draft["motion_toggle_chord"] = ["right_sl", "c"]
|
||||
draft["swing"] = {
|
||||
"button": "west", "sensitivity": "high", "modifier": "right_trigger",
|
||||
"macro": None,
|
||||
}
|
||||
draft["nunchuk_swing"].update(macro=1, sensitivity="low", modifier="c")
|
||||
draft["combined_swing"].update(macro=2, modifier="left_trigger")
|
||||
draft["combination_window_ms"] = 30
|
||||
status, validated = request_json(
|
||||
f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token,
|
||||
)
|
||||
|
|
@ -199,6 +208,27 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
|
|||
assert sample["buttons"] == ["south", "dpad_up", "dpad_right"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gesture", ["swing", "nunchuk_swing", "combined_swing"])
|
||||
def test_editor_rejects_unconfigured_gesture_macro_without_saving(
|
||||
monkeypatch: pytest.MonkeyPatch, gesture: str,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
key = (device.stable_identity.to_bytes(), 1)
|
||||
original = device.profiles[key]
|
||||
draft = config_manager.ControllerProfile.default().to_json_object()
|
||||
draft[gesture]["macro"] = 4
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
for endpoint, method in (
|
||||
("/api/profiles/validate", "POST"), ("/api/profiles/1/2", "PUT"),
|
||||
):
|
||||
status, result = request_json(
|
||||
f"{base_url}{endpoint}", method=method, value=draft, token=token,
|
||||
)
|
||||
assert status == 400
|
||||
assert "error" in result
|
||||
assert device.profiles[key] == original
|
||||
|
||||
|
||||
def test_editor_identifies_connected_controller_artwork(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -99,8 +99,7 @@ static void update_mp_checksum(void) {
|
|||
put_be16(&f.mp_calibration[30], crc);
|
||||
}
|
||||
|
||||
static void set_nunchuk_stick_calibration(const uint8_t values[6]) {
|
||||
memcpy(f.nunchuk_calibration + 8, values, 6);
|
||||
static void update_nunchuk_checksum(void) {
|
||||
uint8_t checksum = 0x55;
|
||||
for (unsigned i = 0; i < 14; ++i)
|
||||
checksum += f.nunchuk_calibration[i];
|
||||
|
|
@ -108,6 +107,17 @@ static void set_nunchuk_stick_calibration(const uint8_t values[6]) {
|
|||
f.nunchuk_calibration[15] = checksum + 0x55;
|
||||
}
|
||||
|
||||
static void set_nunchuk_stick_calibration(const uint8_t values[6]) {
|
||||
memcpy(f.nunchuk_calibration + 8, values, 6);
|
||||
update_nunchuk_checksum();
|
||||
}
|
||||
|
||||
static void set_nunchuk_accel_calibration(const uint16_t zero[3], const uint16_t one_g[3]) {
|
||||
pack_accel_calibration(f.nunchuk_calibration, zero);
|
||||
pack_accel_calibration(f.nunchuk_calibration + 4, one_g);
|
||||
update_nunchuk_checksum();
|
||||
}
|
||||
|
||||
static void reset_fixture(uint16_t product_id, bool motionplus, extension_t extension) {
|
||||
memset(&f, 0, sizeof(f));
|
||||
f.device.vendor_id = 0x057e;
|
||||
|
|
@ -122,6 +132,10 @@ static void reset_fixture(uint16_t product_id, bool motionplus, extension_t exte
|
|||
const uint8_t nunchuk_identity[6] = {0, 0, 0xa4, 0x20, 0, 0};
|
||||
memcpy(f.extension_identity, nunchuk_identity, sizeof(nunchuk_identity));
|
||||
set_nunchuk_stick_calibration((const uint8_t[]){224, 32, 128, 224, 32, 128});
|
||||
// Dolphin Nunchuk::CalibrationData uses the same packed 10-bit points as
|
||||
// the Remote, but its own centers and sensitivities.
|
||||
set_nunchuk_accel_calibration((const uint16_t[]){501, 510, 519},
|
||||
(const uint16_t[]){693, 766, 647});
|
||||
|
||||
// Non-default centers, unequal spans, and nonzero packed low bits catch
|
||||
// nominal 0x200/100-count calibration and high-byte-only decoding.
|
||||
|
|
@ -417,6 +431,21 @@ static void send_nunchuk_controls(bool passthrough) {
|
|||
expect_accel();
|
||||
}
|
||||
|
||||
static void send_nunchuk_sample(const uint8_t extension[6]) {
|
||||
// Extension-only input proves its sequence does not need Remote accel.
|
||||
uint8_t report[11] = {0x32};
|
||||
memcpy(report + 3, extension, 6);
|
||||
feed(report, sizeof(report));
|
||||
}
|
||||
|
||||
static uint32_t expect_nunchuk_accel(int32_t x, int32_t y, int32_t z) {
|
||||
int32_t acceleration[3];
|
||||
uint32_t sequence;
|
||||
assert(uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
|
||||
expect_vector(acceleration, x, y, z);
|
||||
return sequence;
|
||||
}
|
||||
|
||||
static void integrated_motionplus_calibration_and_slow_bits(void) {
|
||||
reset_fixture(0x0330, true, EXT_NONE);
|
||||
connect_device();
|
||||
|
|
@ -909,6 +938,172 @@ static void unavailable_nunchuk_calibration_keeps_safe_nominal_stick(void) {
|
|||
expect_nunchuk_stick(224, 32, 511, 511);
|
||||
}
|
||||
|
||||
static void plain_nunchuk_accel_uses_own_packed_factory_points(void) {
|
||||
reset_fixture(0x0306, false, EXT_NUNCHUK);
|
||||
connect_device();
|
||||
int32_t acceleration[3];
|
||||
uint32_t remote_sequence;
|
||||
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &remote_sequence));
|
||||
assert(!uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &remote_sequence));
|
||||
|
||||
// Native (693,382,551) = (+1g,-0.5g,+0.25g). Each axis has a different
|
||||
// packed low-bit value (1,2,3), distinct calibration, and a non-default zero.
|
||||
const uint8_t extension[6] = {160, 192, 173, 95, 137, 0xe5};
|
||||
send_nunchuk_sample(extension);
|
||||
uint32_t first = expect_nunchuk_accel(-8192, 2048, -4096);
|
||||
assert(f.device.controller.gamepad.buttons == BUTTON_X);
|
||||
assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == -341);
|
||||
assert(!uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &remote_sequence));
|
||||
expect_vector(f.device.controller.gamepad.accel, 0, 0, 0);
|
||||
|
||||
uint8_t remote[6] = {0x31};
|
||||
put_accel_report(remote, 614, 442, 534);
|
||||
feed(remote, sizeof(remote));
|
||||
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &remote_sequence));
|
||||
assert(expect_nunchuk_accel(-8192, 2048, -4096) == first);
|
||||
send_nunchuk_sample(extension);
|
||||
assert(expect_nunchuk_accel(-8192, 2048, -4096) != first);
|
||||
uint32_t sequence;
|
||||
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
|
||||
assert(sequence == remote_sequence);
|
||||
expect_accel(); // Nunchuk does not overwrite the Remote's console IMU.
|
||||
}
|
||||
|
||||
static void passthrough_nunchuk_accel_bitpacking_and_freshness(void) {
|
||||
reset_fixture(0x0330, true, EXT_NUNCHUK);
|
||||
set_nunchuk_accel_calibration((const uint16_t[]){502, 510, 518},
|
||||
(const uint16_t[]){694, 766, 646});
|
||||
connect_device();
|
||||
// MP relocates AZ bit2 to byte5 bit7, not the connected bit in byte4.
|
||||
// Bits 4/5/6/7 here reconstruct (694,382,550); C held, Z released.
|
||||
uint8_t extension[6] = {160, 192, 173, 95, 137, 0xf4};
|
||||
send_nunchuk_sample(extension);
|
||||
uint32_t first = expect_nunchuk_accel(-8192, 2048, -4096);
|
||||
assert(f.device.controller.gamepad.buttons == BUTTON_X);
|
||||
send_motion(7760, 8200, 7560, false, true, false);
|
||||
assert(expect_nunchuk_accel(-8192, 2048, -4096) == first);
|
||||
int32_t acceleration[3];
|
||||
uint32_t remote_sequence;
|
||||
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &remote_sequence));
|
||||
send_ack(0x16, 0);
|
||||
send_status(false); // MP synthetic detach: the mapped ID still exists.
|
||||
finish_setup();
|
||||
uint8_t short_report[8] = {0x32};
|
||||
feed(short_report, sizeof(short_report));
|
||||
send_nunchuk_sample((const uint8_t[]){255, 255, 255, 255, 255, 255});
|
||||
assert(expect_nunchuk_accel(-8192, 2048, -4096) == first);
|
||||
uint32_t sequence;
|
||||
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
|
||||
assert(sequence == remote_sequence);
|
||||
expect_accel();
|
||||
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
|
||||
|
||||
// Different low bits distinguish the X/Y selectors and both relocated Z
|
||||
// bits; clear bit0 is the missing precision, never a synthesized bit.
|
||||
extension[5] = 0x94; // (694,380,548).
|
||||
send_nunchuk_sample(extension);
|
||||
assert(expect_nunchuk_accel(-8192, 1920, -4160) != first);
|
||||
extension[5] = 0x64; // (692,382,546).
|
||||
send_nunchuk_sample(extension);
|
||||
first = expect_nunchuk_accel(-8106, 1792, -4096);
|
||||
send_nunchuk_sample(extension);
|
||||
assert(expect_nunchuk_accel(-8106, 1792, -4096) != first);
|
||||
}
|
||||
|
||||
static void nunchuk_accel_calibration_is_independent_of_stick_and_remote(void) {
|
||||
reset_fixture(0x0306, false, EXT_NUNCHUK);
|
||||
f.fail_accel_reads = true;
|
||||
set_nunchuk_stick_calibration((const uint8_t[]){128, 32, 128, 224, 32, 128});
|
||||
connect_device();
|
||||
send_nunchuk_sample((const uint8_t[]){160, 192, 173, 95, 137, 0xe5});
|
||||
expect_nunchuk_accel(-8192, 2048, -4096);
|
||||
assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == -341);
|
||||
int32_t acceleration[3];
|
||||
uint32_t sequence;
|
||||
assert(!uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
|
||||
|
||||
// Each axis must have a real span, not merely a valid checksum or X span.
|
||||
for (unsigned axis = 0; axis < 3; ++axis) {
|
||||
reset_fixture(0x0330, true, EXT_NUNCHUK);
|
||||
uint16_t one_g[3] = {693, 766, 647};
|
||||
const uint16_t zero[3] = {501, 510, 519};
|
||||
one_g[axis] = zero[axis];
|
||||
set_nunchuk_accel_calibration(zero, one_g);
|
||||
set_nunchuk_stick_calibration((const uint8_t[]){220, 40, 124, 210, 30, 126});
|
||||
connect_device();
|
||||
expect_nunchuk_stick(124, 126, 0, 0);
|
||||
expect_nunchuk_stick(220, 30, 511, 511);
|
||||
send_motion(7760, 8200, 7560, false, true, false);
|
||||
expect_accel();
|
||||
expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024);
|
||||
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
|
||||
}
|
||||
|
||||
// Either checksum byte or an unreadable block disables only Nunchuk motion.
|
||||
for (unsigned failure = 0; failure < 3; ++failure) {
|
||||
reset_fixture(0x0306, false, EXT_NUNCHUK);
|
||||
if (failure < 2)
|
||||
f.nunchuk_calibration[14 + failure] ^= 1;
|
||||
else
|
||||
f.fail_read_address = 0xa40020;
|
||||
connect_device();
|
||||
send_nunchuk_controls(false);
|
||||
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
|
||||
}
|
||||
}
|
||||
|
||||
static void nunchuk_accel_detach_and_replacement_require_fresh_calibration(void) {
|
||||
for (unsigned passthrough = 0; passthrough < 2; ++passthrough) {
|
||||
reset_fixture(passthrough ? 0x0330 : 0x0306, passthrough, EXT_NUNCHUK);
|
||||
connect_device();
|
||||
send_nunchuk_controls(passthrough);
|
||||
int32_t acceleration[3];
|
||||
uint32_t original_sequence;
|
||||
assert(uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &original_sequence));
|
||||
f.extension = EXT_NONE;
|
||||
if (passthrough)
|
||||
send_motion(7760, 8200, 7560, false, true, false);
|
||||
else
|
||||
send_status(false);
|
||||
uint32_t sequence;
|
||||
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
|
||||
finish_setup();
|
||||
// In-flight extension bytes must not resurrect the detached stream.
|
||||
send_nunchuk_sample((const uint8_t[]){160, 192, 128, 128, 128, 0});
|
||||
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
|
||||
set_nunchuk_accel_calibration((const uint16_t[]){512, 512, 512},
|
||||
(const uint16_t[]){712, 752, 672});
|
||||
f.extension = EXT_NUNCHUK;
|
||||
if (passthrough)
|
||||
send_motion(7760, 8200, 7560, false, true, false);
|
||||
else
|
||||
send_status(true);
|
||||
finish_setup();
|
||||
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
|
||||
send_nunchuk_controls(passthrough);
|
||||
assert(expect_nunchuk_accel(0, 0, 0) != original_sequence);
|
||||
expect_accel();
|
||||
|
||||
// Replacement with unreadable calibration must not inherit old points.
|
||||
f.extension = EXT_NONE;
|
||||
if (passthrough)
|
||||
send_motion(7760, 8200, 7560, false, true, false);
|
||||
else
|
||||
send_status(false);
|
||||
finish_setup();
|
||||
f.fail_read_address = 0xa40020;
|
||||
f.extension = EXT_NUNCHUK;
|
||||
if (passthrough)
|
||||
send_motion(7760, 8200, 7560, false, true, false);
|
||||
else
|
||||
send_status(true);
|
||||
finish_setup();
|
||||
send_nunchuk_controls(passthrough);
|
||||
assert(!uni_hid_parser_wii_nunchuk_accel_snapshot(&f.device, acceleration, &sequence));
|
||||
expect_accel();
|
||||
}
|
||||
}
|
||||
|
||||
static void run_case(const char* name, void (*test)(void)) {
|
||||
printf("Wii parser: %s\n", name);
|
||||
fflush(stdout);
|
||||
|
|
@ -917,6 +1112,10 @@ static void run_case(const char* name, void (*test)(void)) {
|
|||
|
||||
int main(void) {
|
||||
run_case("fresh calibrated accelerometer snapshots without MotionPlus", accelerometer_snapshot_requires_fresh_calibrated_reports);
|
||||
run_case("plain Nunchuk independent packed acceleration", plain_nunchuk_accel_uses_own_packed_factory_points);
|
||||
run_case("MotionPlus Nunchuk acceleration bitpacking and freshness", passthrough_nunchuk_accel_bitpacking_and_freshness);
|
||||
run_case("Nunchuk accelerometer calibration independent of stick and Remote", nunchuk_accel_calibration_is_independent_of_stick_and_remote);
|
||||
run_case("Nunchuk acceleration detach and replacement", nunchuk_accel_detach_and_replacement_require_fresh_calibration);
|
||||
run_case("calibrated Nunchuk left-stick endpoints and replacement", calibrated_nunchuk_left_stick_endpoints_and_replacement);
|
||||
run_case("unavailable Nunchuk calibration uses safe nominal travel", unavailable_nunchuk_calibration_keeps_safe_nominal_stick);
|
||||
run_case("integrated MotionPlus calibration and per-axis slow bits", integrated_motionplus_calibration_and_slow_bits);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue