diff --git a/README.md b/README.md index f5562ac..7273fd8 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ exact pre-capture firmware and persistent state. - DualShock 4: hold Share + PS. - Switch Pro: press its sync button. - Switch 2 Pro / Joy-Con 2: hold SYNC while the Pico pairing window is open; do not pair through the PC's Bluetooth settings. + - Wii Remote / Remote Plus: press the red SYNC button with any desired extension already attached. SYNC creates a remembered pairing; 1 + 2 is temporary discovery. - Xbox Bluetooth controller: hold its pair button. - 8BitDo: use a Bluetooth mode supported by Bluepad32; use Switch/S mode when motion is required. 5. Wait for the controller's player light to settle. Repeat step 4 for additional controllers while the window remains open. Holding BOOTSEL again extends the deadline by 60 seconds from that point. @@ -298,13 +299,15 @@ Switch 2's **C, GL, GR, Left SL/SR and Right SL/SR** are additional source-only Controller Studio uses the supplied lightweight SVGs for Switch 2 Pro, Joy-Con 2 left/right solo and paired layouts, original Switch Pro, DualSense, Xbox, and Wii Remote/Nunchuk views. Hotspots follow the artwork's actual coordinates; solo Joy-Con views rotate with their firmware input mappings. Rear buttons and rails are labeled below the front view rather than drawn in fictitious positions. On narrow screens, pan the diagram or use the **Source control** menu. -**Auto** uses matching-owner live metadata to distinguish a Joy-Con pair from a solo half. **Preview** changes only the editor's diagram and source labels; it does not pair controllers or change saved mappings, and physical highlighting is disabled. Source choices reflect the layout while stored unavailable mappings are retained. Wii orientation is not reported, so horizontal/vertical views require an explicit preview. +**Auto** uses matching-owner live metadata to distinguish Joy-Con pairs/solo halves and Wii horizontal, vertical, and Nunchuk layouts. **Preview** changes only the editor's diagram and source labels; it does not pair controllers or change saved mappings, and physical highlighting is disabled. Source choices reflect the layout while stored unavailable mappings are retained. Legacy Wii metadata without orientation uses a visibly labeled horizontal reference with physical highlighting disabled. + +**Wii orientation** is separate from layout preview. Select a connected Wii Remote owner, choose **Horizontal** or **Vertical**, and click **Apply orientation**. Studio waits for firmware confirmation before reporting success. This changes the current connection's physical button mapping, not saved profiles or adapter configuration. Nunchuk mappings remain vertical while attached; unplugging restores the selected standalone orientation. Changing orientation clears old held-input/macro/capture state and advances the logical input generation without reconnecting Bluetooth. Reconnect uses the horizontal default, or vertical if + is held while connecting. **Joy-Con 2 pair profiles:** the first successful join of an L + R combination creates a separate **Nintendo Joy-Con 2 (L + R)** owner with eight profiles. It initially copies the left bank's profiles, names, and active selection; its alias starts empty. Both solo banks stay unchanged. Pair edits, names, and active selections are independent thereafter. Splitting or losing a half restores solo banks; joining the same members again restores their existing pair bank without copying. Different member combinations have different banks. Select the L + R owner—not either solo owner—to edit paired settings. Pair keys contain both complete Bluetooth addresses and address types, in canonical L/R order. They retain the 14-byte identity size: transport byte 1 is 3; byte 0 contains the stable bit plus the left/right static-random flags in bits 1/2; bytes 2–7 and 8–13 contain the left and right addresses. Pair keys are profile identities, not Bluetooth peers or native-output approvals. Update host tools with firmware when using this identity kind. -The read-only playtest endpoint (`0x39`) uses schema 4, 56 bytes: byte 55 identifies unspecified (0), Joy-Con 2 left solo (1), right solo (2), pair (3), Wii Remote (4), or Wii Remote + Nunchuk (5). Host tools still read schema 2/54-byte and schema 3/55-byte payloads; older firmware cannot confirm Joy-Con topology. This metadata does not change profile records, input mapping, or rumble. +The read-only playtest endpoint (`0x39`) uses schema 5, 56 bytes: byte 55 identifies unspecified (0), Joy-Con 2 left solo (1), right solo (2), pair (3), legacy Wii Remote with unknown orientation (4), Wii Remote + Nunchuk (5), Wii horizontal (6), or Wii vertical (7). Host tools still read schema 2/54-byte, schema 3/55-byte and schema 4/56-byte payloads. Orientation requests (`0x3d`) contain the 14-byte controller identity, four-byte little-endian connection generation, and orientation byte (0 horizontal, 1 vertical); stale/replaced/non-Wii/extension targets are rejected before Bluetooth-core dispatch. These operations do not change profile records or persistent configuration. Profile names and controller aliases are stored as independently checksummed catalog metadata. Runtime profiles use schema 7 and unchanged 384-byte records; schemas 1–6 migrate with extra inputs unmapped and existing settings preserved. Names remain separate. The @@ -387,12 +390,24 @@ When a controller becomes ready, RGB-capable devices such as DualSense and DualS | Switch 2 Pro | Yes, including remappable C/GL/GR | Yes, amplitude translation | Yes | | Joy-Con 2 solo / merged pair | Implemented; hardware qualification pending | Implemented | Implemented | | PS Move ZCM1/ZCM2 | Buttons/trigger | Yes | Yes, after calibration | -| Wii Remote | Mode-dependent | Yes | Accelerometer | +| Wii Remote / Remote Plus | Orientation-dependent buttons; Nunchuk stick/C/Z | Yes | Factory-calibrated accelerometer; gyro with MotionPlus | | 8BitDo in Switch-compatible Bluetooth mode | Yes | Model-dependent | Yes when the mode exposes IMU | | Xbox Bluetooth controller | Yes | Grip + impulse-trigger motors (Microsoft Xbox parser) | No hardware IMU | Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and 8192 units per g in SDL-oriented axes before conversion to Nintendo samples. PS Move motion remains neutral until all model-specific calibration blocks have been received and validated; buttons and rumble remain available while calibration is pending or unavailable. The latest normalized sample is duplicated across the report's three nominal 5 ms slots and remains pending until a regular `0x30` USB report successfully consumes it. +### Wii Remote and MotionPlus input + +The AIO firmware enables motion automatically for original Wii Remotes with an external MotionPlus and Remote Plus models with integrated MotionPlus, including `RVL-CNT-01-TR` (`057E:0330`). Holding A while connecting is no longer required. MotionPlus is probed independently of the status report's extension-present bit; integrated units can return an `A4` identifier from the inactive `A600FA` address. + +- **Buttons:** standalone remotes retain horizontal mappings by default: 1/2/A/B → south/east/west/north, with the D-pad rotated for sideways use. Hold **+ while connecting** for vertical mappings: B/A/1/2 → south/east/west/north and upright D-pad. With a Nunchuk, B/A → south/east, 1/2 → L/R, C/Z → west/north, and the Nunchuk supplies the **left stick**; the right stick stays neutral. Minus/Plus/Home remain Select/Start/Home. Profiles can remap these sources. +- **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. +- **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. + +Protocol references: [WiiBrew Wiimote](https://wiibrew.org/wiki/Wiimote), [MotionPlus registers/calibration](https://wiibrew.org/wiki/Wiimote/Extension_Controllers/Wii_Motion_Plus), [Dolphin MotionPlus calibration](https://github.com/dolphin-emu/dolphin/blob/master/Source/Core/Core/HW/WiimoteEmu/MotionPlus.cpp), and [SDL Wii sensor axes](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_wii.c). + ### Switch 2 controller input The AIO firmware implements the proprietary BLE protocol for Nintendo `057E:2069` (Pro), `057E:2067` (left Joy-Con 2), and `057E:2066` (right Joy-Con 2). This is controller **input** support, distinct from the existing Switch 2 console-wake feature and from emulating a native Switch 2 USB controller. diff --git a/bluepad32_config/parser/uni_hid_parser_imu.h b/bluepad32_config/parser/uni_hid_parser_imu.h index 4e4cf73..fdbc607 100644 --- a/bluepad32_config/parser/uni_hid_parser_imu.h +++ b/bluepad32_config/parser/uni_hid_parser_imu.h @@ -229,14 +229,3 @@ static inline bool uni_psmove_normalize_imu( } return true; } - -static inline void uni_imu_normalize_wii_accel(int32_t x, int32_t y, - int32_t z, - int32_t output[3]) { - if (output == NULL) { - return; - } - output[0] = uni_imu_scale(-x, 100, UNI_IMU_ACCEL_RES_PER_G); - output[1] = uni_imu_scale(z, 100, UNI_IMU_ACCEL_RES_PER_G); - output[2] = uni_imu_scale(y, 100, UNI_IMU_ACCEL_RES_PER_G); -} diff --git a/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index ffdf4a6..65b0fdf 100755 Binary files a/firmware/switch-pico-adapter-feasibility.elf and b/firmware/switch-pico-adapter-feasibility.elf differ diff --git a/firmware/switch-pico-adapter-feasibility.uf2 b/firmware/switch-pico-adapter-feasibility.uf2 index dc23e07..7ab9b75 100644 Binary files a/firmware/switch-pico-adapter-feasibility.uf2 and b/firmware/switch-pico-adapter-feasibility.uf2 differ diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index 521804f..639b4e9 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index dc23e07..7ab9b75 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/patches/bluepad32-sdl3-imu.patch b/patches/bluepad32-sdl3-imu.patch index beb99a1..cc60367 100644 --- a/patches/bluepad32-sdl3-imu.patch +++ b/patches/bluepad32-sdl3-imu.patch @@ -1105,7 +1105,16 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_switch.c b/src/compo diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/components/bluepad32/parser/uni_hid_parser_wii.c --- a/src/components/bluepad32/parser/uni_hid_parser_wii.c +++ b/src/components/bluepad32/parser/uni_hid_parser_wii.c -@@ -19,6 +19,7 @@ +@@ -5,6 +5,8 @@ + // Technical info taken from: + // http://wiibrew.org/wiki/Wiimote + // https://github.com/dvdhrm/xwiimote/blob/master/doc/PROTOCOL ++// https://wiibrew.org/wiki/Wiimote/Extension_Controllers/Wii_Motion_Plus ++// Calibration signs: Dolphin WiimoteEmu/MotionPlus.cpp; axes: SDL_hidapi_wii.c. + + #include + #include +@@ -19,6 +21,7 @@ #endif // ENABLE_EEPROM_DUMP #include "parser/uni_hid_parser_wii.h" @@ -1113,17 +1122,1202 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/componen #include "controller/uni_controller.h" #include "hid_usage.h" -@@ -585,9 +586,7 @@ +@@ -82,6 +85,16 @@ + WII_FSM_EXT_DID_INIT, // Extension initialized + WII_FSM_EXT_DID_NO_ENCRYPTION, // Extension no encryption + WII_FSM_EXT_DID_READ_REGISTER, // Extension read register ++ WII_FSM_NUNCHUK_CALIBRATION, ++ WII_FSM_ACCEL_CALIBRATION, ++ WII_FSM_ACCEL_CALIBRATION_BACKUP, ++ WII_FSM_MP_PROBE, ++ WII_FSM_MP_INIT, ++ WII_FSM_MP_CALIBRATION_FAST, ++ WII_FSM_MP_CALIBRATION_SLOW, ++ WII_FSM_MP_ACTIVATE, ++ WII_FSM_MP_VERIFY, ++ WII_FSM_TOPOLOGY_STATUS, + WII_FSM_BALANCE_BOARD_READ_CALIBRATION, + WII_FSM_BALANCE_BOARD_READ_CALIBRATION2, + WII_FSM_BALANCE_BOARD_DID_READ_CALIBRATION, +@@ -104,16 +117,6 @@ + WII_READ_FROM_REGISTERS = 0x04, + } wii_read_type_t; - uni_controller_t* ctl = &d->controller; +-// nunchuk_t represents the data provided by the Nunchuk. +-typedef struct nunchuk_s { +- int sx; // Analog stick X +- int sy; // Analog stick Y +- int ax; // Accelerometer X +- int ay; // Accelerometer Y +- int az; // Accelerometer Z +- bool bc; // Button C +- bool bz; // Button Z +-} nunchuk_t; + // balance_board_t represents the data provided by the Balance Board. + typedef struct balance_board_s { +@@ -144,7 +147,6 @@ + // wii_instance_t represents data used by the Wii driver instance. + typedef struct wii_instance_s { + uint8_t state; +- uint8_t register_address; + wii_mode_t mode; /* horizontal, accel, vertical, rumble, etc.. */ + enum wii_devtype dev_type; + enum wii_exttype ext_type; +@@ -159,6 +161,29 @@ + uint16_t rumble_duration_ms; + + balance_board_calibration_t balance_board_calibration; ++ ++ uint16_t read_address; ++ uint8_t read_size; ++ uint8_t report_type; ++ bool motion_setup_done; ++ bool accel_calibrated; ++ bool mp_calibrated; ++ bool mp_active; ++ bool ready; ++ bool ext_connected; ++ bool topology_pending; ++ uint8_t ext_retries; ++ uint16_t accel_zero[3]; ++ uint16_t accel_span[3]; ++ // Two factory blocks, fast then slow; preserve all 16 bits of calibration. ++ uint8_t mp_calibration[32]; ++ int32_t accel[3]; ++ int32_t gyro[3]; ++ int16_t nunchuk_x; ++ int16_t nunchuk_y; ++ uint16_t nunchuk_buttons; ++ // Per-axis factory max/min/center, read before MotionPlus maps A4. ++ uint8_t nunchuk_stick_calibration[6]; + + // Debug only + int debug_fd; // File descriptor where dump is saved +@@ -177,7 +202,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); +-static nunchuk_t process_nunchuk(const uint8_t* e, uint16_t len); + 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 +220,8 @@ + 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); ++ ++static void wii_write_register(uni_hid_device_t* d, uint32_t address, uint8_t value); + + // Constants + static const char* wii_devtype_names[] = { +@@ -217,124 +243,258 @@ + + // process_ functions + +-// Defined here: http://wiibrew.org/wiki/Wiimote#0x20:_Status ++static void wii_setup_read(uni_hid_device_t* d, enum wii_fsm state, wii_read_type_t type, ++ uint32_t address, uint8_t size) { ++ get_wii_instance(d)->state = state; ++ wii_read_mem(d, type, address, size); ++} ++ ++static bool wii_is_motion_plus(const uint8_t* id) { ++ // Like SDL/Linux, identify the data format/type suffix, not a vendor prefix. ++ return (id[4] == 0 || id[4] == 4 || id[4] == 5 || id[4] == 7) && id[5] == 5; ++} ++ ++static void wii_clear_extension(uni_hid_device_t* d) { ++ wii_instance_t* ins = get_wii_instance(d); ++ d->controller.gamepad.axis_x = 0; ++ d->controller.gamepad.axis_y = 0; ++ d->controller.gamepad.axis_rx = 0; ++ d->controller.gamepad.axis_ry = 0; ++ d->controller.gamepad.buttons &= ~ins->nunchuk_buttons; ++ ins->nunchuk_x = 0; ++ ins->nunchuk_y = 0; ++ ins->nunchuk_buttons = 0; ++ // 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}; ++ memcpy(ins->nunchuk_stick_calibration, nominal_stick, sizeof(nominal_stick)); ++ ins->ext_type = WII_EXT_NONE; ++ d->controller_subtype = ins->mode == WII_MODE_VERTICAL ? ++ CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL : CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL; ++} ++ ++static void wii_discover_extension(uni_hid_device_t* d) { ++ wii_instance_t* ins = get_wii_instance(d); ++ wii_clear_extension(d); ++ ins->topology_pending = false; ++ ins->ext_retries = 0; ++ ins->motion_setup_done = false; ++ // A400F0 unmaps an active MP and exposes its downstream extension. ++ // Do not decode in-flight extension bytes until the new topology is ready. ++ ins->mp_active = false; ++ wii_fsm_ext_init(d); ++} ++ ++static bool wii_retry_extension(uni_hid_device_t* d) { ++ wii_instance_t* ins = get_wii_instance(d); ++ // Reply-paced transactions allow an initializing/partially inserted ++ // extension to settle without a timer that could outlive this device. ++ // Re-read after successful initialization rather than repeatedly resetting ++ // it, which could restart the initialization on each attempt. ++ if (ins->ext_connected && ins->ext_retries++ < 3) { ++ if (ins->state == WII_FSM_EXT_DID_READ_REGISTER) ++ wii_fsm_ext_read_register(d); ++ else ++ wii_fsm_ext_init(d); ++ return true; ++ } ++ return false; ++} ++ ++static void wii_confirm_topology(uni_hid_device_t* d) { ++ wii_instance_t* ins = get_wii_instance(d); ++ if (!ins->mp_active) { ++ memset(ins->gyro, 0, sizeof(ins->gyro)); ++ memset(d->controller.gamepad.gyro, 0, sizeof(d->controller.gamepad.gyro)); ++ } ++ ins->state = WII_FSM_TOPOLOGY_STATUS; ++ const uint8_t report[] = {0xa2, WIIPROTO_REQ_SREQ, ins->rumble_state == WII_STATE_RUMBLE_IN_PROGRESS}; ++ uni_hid_device_send_intr_report(d, report, sizeof(report)); ++} ++ ++static void wii_finish_motion_setup(uni_hid_device_t* d) { ++ wii_instance_t* ins = get_wii_instance(d); ++ ins->motion_setup_done = true; ++ ins->read_size = 0; ++ logi("Wii: factory accel=%d, MotionPlus calibrated=%d active=%d, Nunchuk=%d\n", ++ ins->accel_calibrated, ins->mp_calibrated, ins->mp_active, ins->ext_type == WII_EXT_NUNCHUK); ++ wii_confirm_topology(d); ++} ++ ++static void wii_probe_motion_plus(uni_hid_device_t* d) { ++ // An inactive built-in or external MP does NOT set the status extension bit. ++ 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; ++ 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. ++ if (zero == 0 || zero == 1023 || one - zero < 16 || one - zero > 512) ++ return false; ++ ins->accel_zero[i] = zero; ++ ins->accel_span[i] = one - zero; ++ } ++ return true; ++} ++ ++static uint16_t wii_be16(const uint8_t* p) { ++ return ((uint16_t)p[0] << 8) | p[1]; ++} ++ ++static bool wii_check_mp_calibration(const uint8_t* c) { ++ // IEEE CRC32 over both 14-byte payloads, excluding the split checksum. ++ uint32_t crc = UINT32_MAX; ++ for (int block = 0; block < 2; block++) { ++ const uint8_t* b = c + block * 16; ++ for (int i = 0; i < 14; i++) { ++ crc ^= b[i]; ++ for (int bit = 0; bit < 8; bit++) ++ crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320u : 0); ++ } ++ if (b[12] == 0 || b[12] == 0xff) ++ return false; ++ for (int axis = 0; axis < 3; axis++) { ++ int zero = wii_be16(b + axis * 2); ++ int scale = wii_be16(b + 6 + axis * 2); ++ int span = scale - zero; ++ if (zero == 0 || zero == 65535 || scale == 0 || scale == 65535 || ++ (span > -256 && span < 256)) ++ return false; ++ } ++ } ++ uint32_t expected = ((uint32_t)wii_be16(c + 14) << 16) | wii_be16(c + 30); ++ return ~crc == expected; ++} ++ ++static void wii_setup_read_error(uni_hid_device_t* d) { ++ wii_instance_t* ins = get_wii_instance(d); ++ logi("Wii: setup read failed in state %u\n", ins->state); ++ switch (ins->state) { ++ case WII_FSM_NUNCHUK_CALIBRATION: ++ // Retain nominal stick calibration, but still initialize remote motion. ++ ins->state = WII_FSM_DEV_GUESSED; ++ wii_process_fsm(d); ++ return; ++ case WII_FSM_ACCEL_CALIBRATION: ++ wii_setup_read(d, WII_FSM_ACCEL_CALIBRATION_BACKUP, WII_READ_FROM_MEM, 0x20, 10); ++ return; ++ case WII_FSM_ACCEL_CALIBRATION_BACKUP: ++ wii_probe_motion_plus(d); ++ return; ++ case WII_FSM_EXT_DID_READ_REGISTER: ++ if (wii_retry_extension(d)) ++ return; ++ ins->ext_type = WII_EXT_NONE; ++ ins->state = WII_FSM_DEV_GUESSED; ++ wii_process_fsm(d); ++ return; ++ case WII_FSM_MP_VERIFY: ++ // A real MP removal differs from its synthetic disconnect status: ++ // its mapped ID is no longer readable. ++ memset(ins->gyro, 0, sizeof(ins->gyro)); ++ memset(d->controller.gamepad.gyro, 0, sizeof(d->controller.gamepad.gyro)); ++ wii_discover_extension(d); ++ return; ++ case WII_FSM_BALANCE_BOARD_DID_READ_CALIBRATION: ++ case WII_FSM_BALANCE_BOARD_DID_READ_CALIBRATION2: ++ // Never run the weight interpolator with incomplete calibration. ++ ins->ext_type = WII_EXT_NONE; ++ break; ++ default: ++ break; ++ } ++ wii_finish_motion_setup(d); ++} ++ + static void process_req_status(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { +- if (len < 7) { +- loge("Wii: Unexpected report length; got %d, want >= 7\n", len); +- return; +- } +- wii_instance_t* ins = get_wii_instance(d); +- uint8_t flags = report[3] & 0x0f; // LF (leds / flags) ++ if (len < 7) ++ return; ++ wii_instance_t* ins = get_wii_instance(d); ++ bool connected = (report[3] & 2) != 0; + if (ins->state == WII_FSM_DID_REQ_STATUS) { +- if (d->product_id == 0x0306) { +- // We are positive that this is a Wii Remote 1st gen +- ins->state = WII_FSM_DEV_GUESSED; +- ins->dev_type = WII_DEVTYPE_REMOTE; +- } else if (d->product_id == 0x0330) { +- // It can be either a Wii Remote 2nd gen or a Wii U Pro Controller +- if ((flags & 0x02) == 0) { +- // If there are no extensions, then we are sure it is a Wii Remote MP. +- ins->state = WII_FSM_DEV_GUESSED; +- ins->dev_type = WII_DEVTYPE_REMOTE_MP; +- } else { +- // Otherwise, it can be either a Wii Remote MP with a Nunchuk or a +- // Wii U Pro controller. +- ins->state = WII_FSM_DEV_UNK; +- } ++ ins->dev_type = d->product_id == 0x0330 ? WII_DEVTYPE_REMOTE_MP : WII_DEVTYPE_REMOTE; ++ ins->ext_connected = connected; ++ // Motion is always enabled; only "+" selects a different orientation. ++ if (report[1] & 0x10) ++ ins->mode = WII_MODE_VERTICAL; ++ // Also reset/probe A4 when the status bit is clear: inactive MP does ++ // not set it, and an attachment can race the initial status request. ++ wii_discover_extension(d); ++ } else if (ins->state == WII_FSM_TOPOLOGY_STATUS) { ++ // Reconcile status after the serialized setup, not while F0/FE writes ++ // are producing their own unplug/plug notifications. ++ if (!ins->mp_active && ins->ext_connected != connected && ++ ins->dev_type != WII_DEVTYPE_PRO_CONTROLLER) { ++ ins->ext_connected = connected; ++ wii_discover_extension(d); ++ } else { ++ wii_fsm_assign_device(d); + } +- +- if ((flags & 0x02) != 0) { +- // Extension detected: Nunchuk? +- // Regardless of the previous FSM state, we overwrite it with "query +- // extension". +- logi("Wii: extension found.\n"); +- ins->state = WII_FSM_EXT_UNK; +- ins->ext_type = WII_EXT_UNK; +- } else { +- logi("Wii: No extensions found.\n"); +- ins->ext_type = WII_EXT_NONE; ++ } else if (ins->state == WII_FSM_LED_UPDATED) { ++ // Every status stops the input stream, even when topology is unchanged. ++ uni_hid_parser_wii_request_report_type(d, ins->report_type); ++ if (ins->dev_type == WII_DEVTYPE_PRO_CONTROLLER) ++ return; ++ if (ins->mp_active) { ++ // A late activation or deactivation status is not proof of removal. ++ wii_setup_read(d, WII_FSM_MP_VERIFY, WII_READ_FROM_REGISTERS, 0xa400fa, 6); ++ } else if (ins->ext_connected != connected) { ++ ins->ext_connected = connected; ++ wii_discover_extension(d); + } +- +- if (report[2] & 0x08) { +- // Wii Remote only: Enter "accel mode" if "A" is pressed. +- ins->mode = WII_MODE_ACCEL; +- } else if (report[1] & 0x10) { +- // Wii Remote only: Enter "vertical mode" if "+" is pressed. +- ins->mode = WII_MODE_VERTICAL; +- } +- +- wii_process_fsm(d); +- } ++ } ++ // All other states own a pending read/write. Never replace their FSM state ++ // or start a second transaction in response to a transitional status. + } + + // Defined here: http://wiibrew.org/wiki/Wiimote#0x21:_Read_Memory_Data + static void process_req_data_read_register(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { +- uint8_t se = report[3]; // SE: size and error +- uint8_t s = se >> 4; // size +- uint8_t e = se & 0x0f; // error +- if (e) { +- loge("Wii: error reading memory: 0x%02x\n.", e); +- return; +- } +- +- wii_instance_t* ins = get_wii_instance(d); +- +- // We are expecting to read 6 bytes from 0xXX00fa +- if (s == 5 && report[4] == 0x00 && report[5] == 0xfa) { +- // This contains the read memory from register 0xa?00fa +- // Data is in report[6]..report[11] +- +- // Try to guess device type. +- if (report[10] == 0x01 && report[11] == 0x20) { +- // Pro Controller: 00 00 a4 20 01 20 +- ins->dev_type = WII_DEVTYPE_PRO_CONTROLLER; +- ins->ext_type = WII_EXT_U_PRO_CONTROLLER; +- } else if (ins->dev_type == WII_DEVTYPE_UNK) { +- if (d->product_id == 0x0330) { +- ins->dev_type = WII_DEVTYPE_REMOTE_MP; +- } else if (d->product_id == 0x0306) { +- ins->dev_type = WII_DEVTYPE_REMOTE; +- } else { +- loge("Wii: Unknown product id: 0x%04x\n", d->product_id); +- } +- } +- +- // Try to guess extension type. +- if (ins->ext_type == WII_EXT_UNK) { +- if (report[10] == 0x00 && report[11] == 0x00) { +- // Nunchuck: 00 00 a4 20 00 00 +- ins->ext_type = WII_EXT_NUNCHUK; +- // If a Nunchuck is attached, WiiMode is treated as vertical mode +- ins->mode = WII_MODE_VERTICAL; +- } else if (report[10] == 0x04 && report[11] == 0x02) { +- // Balance Board: 00 00 a4 20 04 02 +- ins->ext_type = WII_EXT_BALANCE_BOARD; +- } else if (report[10] == 0x01 && report[11] == 0x01) { +- // Classic / Classic Pro: 0? 00 a4 20 01 01 +- ins->ext_type = WII_EXT_CLASSIC_CONTROLLER; +- } else if (report[10] == 0x01 && report[11] == 0x12) { +- // Wii uDraw Tablet: FF 00 A4 20 01 12 +- ins->ext_type = WII_EXT_UDRAW_TABLET; +- // WiiMote is attached vertically to the uDraw Tablet +- ins->mode = WII_MODE_VERTICAL; +- } else { +- loge("Wii: Unknown extension: %#x %#x\n", report[10], report[11]); +- printf_hexdump(report, len); +- } +- } +- +- if (ins->ext_type == WII_EXT_BALANCE_BOARD) { +- ins->state = WII_FSM_BALANCE_BOARD_READ_CALIBRATION; +- } else { +- ins->state = WII_FSM_DEV_GUESSED; +- } +- +- logi("Wii: Device: %s, Extension: %s\n", wii_devtype_names[ins->dev_type], wii_exttype_names[ins->ext_type]); +- wii_process_fsm(d); +- } else { +- loge("Wii: invalid response"); +- printf_hexdump(report, len); +- } ++ ARG_UNUSED(len); ++ wii_instance_t* ins = get_wii_instance(d); ++ const uint8_t* c = report + 6; ++ ins->ext_type = WII_EXT_NONE; ++ if (c[4] == 0xff && c[5] == 0xff) { ++ if (wii_retry_extension(d)) ++ return; ++ } else if (c[4] == 0 && c[5] == 0) { ++ // Vendor/version bytes vary. The upstream parser, SDL, and Linux all ++ // recognize Nunchuk from the format/type suffix after initialization. ++ ins->ext_type = WII_EXT_NUNCHUK; ++ } else if (c[4] == 1 && c[5] == 0x20) { ++ ins->dev_type = WII_DEVTYPE_PRO_CONTROLLER; ++ ins->ext_type = WII_EXT_U_PRO_CONTROLLER; ++ } else if (c[4] == 4 && c[5] == 2) { ++ ins->ext_type = WII_EXT_BALANCE_BOARD; ++ } else if (c[4] == 1 && c[5] == 1) { ++ ins->ext_type = WII_EXT_CLASSIC_CONTROLLER; ++ } else if (c[4] == 1 && c[5] == 0x12) { ++ ins->ext_type = WII_EXT_UDRAW_TABLET; ++ } else if (wii_is_motion_plus(c)) { ++ // A previously active MP can still be remapping A4 after F0 was acked. ++ if (wii_retry_extension(d)) ++ return; ++ } ++ if (ins->ext_type != WII_EXT_NONE) ++ ins->ext_connected = true; ++ else ++ logi("Wii: unidentified extension %02x %02x %02x %02x %02x %02x\n", ++ c[0], c[1], c[2], c[3], c[4], c[5]); ++ if (ins->ext_type == WII_EXT_NUNCHUK) { ++ wii_setup_read(d, WII_FSM_NUNCHUK_CALIBRATION, WII_READ_FROM_REGISTERS, 0xa40020, 16); ++ return; ++ } ++ ins->state = ins->ext_type == WII_EXT_BALANCE_BOARD ? ++ WII_FSM_BALANCE_BOARD_READ_CALIBRATION : WII_FSM_DEV_GUESSED; ++ logi("Wii: Device: %s, Extension: %s\n", wii_devtype_names[ins->dev_type], wii_exttype_names[ins->ext_type]); ++ wii_process_fsm(d); + } + + static void process_req_data_read_calibration_data(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { +@@ -444,17 +604,83 @@ + + // Defined here: http://wiibrew.org/wiki/Wiimote#0x21:_Read_Memory_Data + static void process_req_data(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { +- if (len < 22) { +- loge("Wii: invalid req_data length: got %d, want >= 22\n", len); +- printf_hexdump(report, len); +- return; +- } +- +- wii_instance_t* ins = get_wii_instance(d); ++ if (len < 22) ++ return; ++ wii_instance_t* ins = get_wii_instance(d); ++ uint16_t address = wii_be16(report + 4); ++ uint8_t size = (report[3] >> 4) + 1; ++ uint8_t error = report[3] & 0x0f; ++ // Read replies omit the address bank, so allow only one outstanding read. ++ // An error reply has no meaningful payload size, but still echoes address. ++ if (!ins->read_size || address != ins->read_address || (!error && size != ins->read_size)) ++ return; ++ ins->read_size = 0; ++ if (error) { ++ wii_setup_read_error(d); ++ return; ++ } ++ const uint8_t* c = report + 6; + switch (ins->state) { + case WII_FSM_EXT_DID_READ_REGISTER: + process_req_data_read_register(d, report, len); + break; ++ case WII_FSM_NUNCHUK_CALIBRATION: { ++ 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); ++ 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) ++ memcpy(ins->nunchuk_stick_calibration, c + 8, sizeof(ins->nunchuk_stick_calibration)); ++ else ++ logi("Wii: invalid Nunchuk stick calibration; using nominal gate\n"); ++ ins->state = WII_FSM_DEV_GUESSED; ++ wii_process_fsm(d); ++ break; ++ } ++ case WII_FSM_MP_VERIFY: ++ if (!wii_is_motion_plus(c) || c[4] == 0) { ++ memset(ins->gyro, 0, sizeof(ins->gyro)); ++ memset(d->controller.gamepad.gyro, 0, sizeof(d->controller.gamepad.gyro)); ++ wii_discover_extension(d); ++ } else if (ins->topology_pending) { ++ wii_discover_extension(d); ++ } else { ++ wii_fsm_assign_device(d); ++ } ++ break; ++ case WII_FSM_ACCEL_CALIBRATION: ++ case WII_FSM_ACCEL_CALIBRATION_BACKUP: ++ ins->accel_calibrated = wii_read_accel_calibration(ins, c); ++ if (!ins->accel_calibrated && ins->state == WII_FSM_ACCEL_CALIBRATION) ++ wii_setup_read(d, WII_FSM_ACCEL_CALIBRATION_BACKUP, WII_READ_FROM_MEM, 0x20, 10); ++ else ++ wii_probe_motion_plus(d); ++ break; ++ case WII_FSM_MP_PROBE: ++ if (wii_is_motion_plus(c)) { ++ ins->state = WII_FSM_MP_INIT; ++ wii_write_register(d, 0xa600f0, 0x55); ++ } else { ++ wii_finish_motion_setup(d); ++ } ++ break; ++ case WII_FSM_MP_CALIBRATION_FAST: ++ memcpy(ins->mp_calibration, c, 16); ++ wii_setup_read(d, WII_FSM_MP_CALIBRATION_SLOW, WII_READ_FROM_REGISTERS, 0xa60030, 16); ++ break; ++ case WII_FSM_MP_CALIBRATION_SLOW: ++ memcpy(ins->mp_calibration + 16, c, 16); ++ ins->mp_calibrated = wii_check_mp_calibration(ins->mp_calibration); ++ if (ins->mp_calibrated) { ++ ins->state = WII_FSM_MP_ACTIVATE; ++ wii_write_register(d, 0xa600fe, ins->ext_type == WII_EXT_NUNCHUK ? 5 : 4); ++ } else { ++ loge("Wii: invalid MotionPlus factory calibration; gyro disabled\n"); ++ wii_finish_motion_setup(d); ++ } ++ break; + case WII_FSM_BALANCE_BOARD_DID_READ_CALIBRATION: + process_req_data_read_calibration_data(d, report, len); + break; +@@ -465,7 +691,6 @@ + process_req_data_dump_eeprom(d, report, len); + break; + default: +- loge("process_req_data. Unknown FSM state: 0x%02x\n", ins->state); + break; + } + } +@@ -473,29 +698,38 @@ + // 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) { +- if (len < 5) { +- loge("Invalid len report for process_req_return: got %d, want >= 5\n", len); +- } +- if (report[3] == WIIPROTO_REQ_WMEM) { +- wii_instance_t* ins = get_wii_instance(d); +- // Status != 0: Error. Probably invalid register +- if (report[4] != 0) { +- if (ins->register_address == 0xa6) { +- loge("Failed to read registers from 0xa6... mmmm\n"); +- ins->state = WII_FSM_SETUP; +- } else { +- // If it failed to read registers with 0xa4, then try with 0xa6. +- // If 0xa6 works Ok, it is safe to assume it is a Wii Remote MP, but +- // for the sake of finishing the "read extension" (might be useful in the future), +- // we continue with it. +- logi("Probably a Remote MP device. Switching to 0xa60000 address for registers.\n"); +- ins->state = WII_FSM_DEV_UNK; +- ins->register_address = 0xa6; // Register address used for Wii Remote MP. +- } +- } else { +- // Status Ok. Good +- } +- wii_process_fsm(d); ++ if (len < 5) ++ return; ++ wii_instance_t* ins = get_wii_instance(d); ++ if (report[3] == WIIPROTO_REQ_RMEM && report[4] && ins->read_size) { ++ ins->read_size = 0; ++ wii_setup_read_error(d); ++ return; ++ } ++ if (report[3] != WIIPROTO_REQ_WMEM) ++ return; ++ // Acks contain no register address. Only a pending write may consume one. ++ switch (ins->state) { ++ case WII_FSM_EXT_DID_INIT: ++ case WII_FSM_EXT_DID_NO_ENCRYPTION: ++ if (report[4] && wii_retry_extension(d)) ++ break; ++ // FB can fail on an empty MP passthrough port; still identify A4 ++ // and probe A6 instead of treating an ACK as an extension identity. ++ wii_process_fsm(d); ++ break; ++ case WII_FSM_MP_INIT: ++ if (report[4]) ++ wii_finish_motion_setup(d); ++ else ++ wii_setup_read(d, WII_FSM_MP_CALIBRATION_FAST, WII_READ_FROM_REGISTERS, 0xa60020, 16); ++ break; ++ case WII_FSM_MP_ACTIVATE: ++ ins->mp_active = report[4] == 0; ++ wii_finish_motion_setup(d); ++ break; ++ default: ++ break; + } + } + +@@ -514,15 +748,26 @@ + const uint8_t* data = &report[1]; + wii_instance_t* ins = get_wii_instance(d); + +- switch (ins->mode) { +- case WII_MODE_HORIZONTAL: +- process_drm_k_horizontal(ctl, data); +- break; +- case WII_MODE_VERTICAL: +- process_drm_k_vertical(ctl, data); +- break; +- default: +- break; ++ if (ins->ext_type == WII_EXT_NUNCHUK || ins->ext_type == WII_EXT_UDRAW_TABLET) { ++ // Preserve the existing extension mapping: C/Z -> X/Y, 1/2 -> L/R. ++ ctl->gamepad.dpad |= (data[0] & 0x01) ? DPAD_LEFT : 0; ++ ctl->gamepad.dpad |= (data[0] & 0x02) ? DPAD_RIGHT : 0; ++ ctl->gamepad.dpad |= (data[0] & 0x04) ? DPAD_DOWN : 0; ++ ctl->gamepad.dpad |= (data[0] & 0x08) ? DPAD_UP : 0; ++ ctl->gamepad.buttons |= (data[1] & 0x04) ? BUTTON_A : 0; ++ ctl->gamepad.buttons |= (data[1] & 0x08) ? BUTTON_B : 0; ++ ctl->gamepad.buttons |= (data[1] & 0x02) ? BUTTON_SHOULDER_L : 0; ++ ctl->gamepad.buttons |= (data[1] & 0x01) ? BUTTON_SHOULDER_R : 0; ++ if (ins->ext_type == WII_EXT_NUNCHUK) { ++ ctl->gamepad.axis_x = ins->nunchuk_x; ++ ctl->gamepad.axis_y = ins->nunchuk_y; ++ ctl->gamepad.buttons |= ins->nunchuk_buttons; ++ } ++ } else if (ins->mode == WII_MODE_VERTICAL) { ++ process_drm_k_vertical(ctl, data); ++ } else { ++ // WII_MODE_ACCEL was historically horizontal; motion no longer owns orientation. ++ process_drm_k_horizontal(ctl, data); + } + // Process misc buttons + ctl->gamepad.misc_buttons |= (data[1] & 0x80) ? MISC_BUTTON_SYSTEM : 0; // Button "home" +@@ -563,46 +808,97 @@ + // 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) { +- // Process Wiimote in "accelerator mode". +- /* DRM_KA: BB*2 AA*3*/ +- // Expecting something like: +- // 31 20 60 82 7F 99 +- if (len < 6) { +- loge("wii remote drm_ka: invalid report len %d\n", len); +- return; +- } +- +- uint16_t x = (report[3] << 2) | ((report[1] >> 5) & 0x3); +- uint16_t y = (report[4] << 2) | ((report[2] >> 4) & 0x2); +- uint16_t z = (report[5] << 2) | ((report[2] >> 5) & 0x2); +- +- int16_t sx = x - 0x200; +- int16_t sy = y - 0x200; +- int16_t sz = z - 0x200; +- +- // printf_hexdump(report, len); +- // logi("Wii: x=%d, y=%d, z=%d\n", sx, sy, sz); +- +- uni_controller_t* ctl = &d->controller; +- - ctl->gamepad.accel[0] = sx; - ctl->gamepad.accel[1] = sy; - ctl->gamepad.accel[2] = sz; -+ uni_imu_normalize_wii_accel(sx, sy, sz, ctl->gamepad.accel); +- +- // Dpad works as dpad, useful to navigate menus. +- ctl->gamepad.dpad |= (report[1] & 0x01) ? DPAD_DOWN : 0; +- ctl->gamepad.dpad |= (report[1] & 0x02) ? DPAD_UP : 0; +- ctl->gamepad.dpad |= (report[1] & 0x04) ? DPAD_RIGHT : 0; +- ctl->gamepad.dpad |= (report[1] & 0x08) ? DPAD_LEFT : 0; +- +- ctl->gamepad.buttons |= (report[2] & 0x02) ? BUTTON_A : 0; // Button "1" +- ctl->gamepad.buttons |= (report[2] & 0x01) ? BUTTON_B : 0; // Button "2" +- ctl->gamepad.buttons |= (report[2] & 0x08) ? BUTTON_X : 0; // Big button "A" +- ctl->gamepad.buttons |= (report[2] & 0x04) ? BUTTON_Y : 0; // Button Shoulder +- +- ctl->gamepad.misc_buttons |= (report[2] & 0x80) ? MISC_BUTTON_SYSTEM : 0; // Button "home" +- ctl->gamepad.misc_buttons |= (report[2] & 0x10) ? MISC_BUTTON_SELECT : 0; // Button "-" +- ctl->gamepad.misc_buttons |= (report[1] & 0x10) ? MISC_BUTTON_START : 0; // Button "+" ++ if (len < 6) ++ return; ++ process_drm_k(d, report, len); ++ wii_instance_t* ins = get_wii_instance(d); ++ if (!ins->accel_calibrated) ++ return; ++ int32_t x = (report[3] << 2) | ((report[1] >> 5) & 3); ++ int32_t y = (report[4] << 2) | ((report[2] >> 4) & 2); ++ int32_t z = (report[5] << 2) | ((report[2] >> 5) & 2); ++ // SDL Wii axes: (-X, Z, Y), independent of the button orientation. ++ ins->accel[0] = uni_imu_scale(ins->accel_zero[0] - x, ins->accel_span[0], UNI_IMU_ACCEL_RES_PER_G); ++ ins->accel[1] = uni_imu_scale(z - ins->accel_zero[2], ins->accel_span[2], UNI_IMU_ACCEL_RES_PER_G); ++ ins->accel[2] = uni_imu_scale(y - ins->accel_zero[1], ins->accel_span[1], UNI_IMU_ACCEL_RES_PER_G); ++ for (int i = 0; i < 3; i++) ++ d->controller.gamepad.accel[i] = ins->accel[i]; ++} ++ ++static int16_t wii_nunchuk_axis(uint8_t raw, const uint8_t* calibration, bool invert) { ++ int center = calibration[2]; ++ int delta = (int)raw - center; ++ int span = delta < 0 ? center - calibration[1] : calibration[0] - center; ++ int value = (invert ? -delta : delta) * (AXIS_NORMALIZE_RANGE / 2) / span; ++ if (value < -AXIS_NORMALIZE_RANGE / 2) ++ return -AXIS_NORMALIZE_RANGE / 2; ++ if (value >= AXIS_NORMALIZE_RANGE / 2) ++ return AXIS_NORMALIZE_RANGE / 2 - 1; ++ return value; ++} ++ ++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. ++ uint8_t buttons = e[5] >> (passthrough ? 2 : 0); ++ ins->nunchuk_buttons = (!(buttons & 2) ? BUTTON_X : 0) | (!(buttons & 1) ? BUTTON_Y : 0); ++} ++ ++static int32_t wii_calibrate_gyro(const wii_instance_t* ins, int axis, int32_t raw, bool slow) { ++ const uint8_t* c = ins->mp_calibration + (slow ? 16 : 0); ++ int32_t zero = wii_be16(c + 2 * axis); ++ int32_t span = wii_be16(c + 6 + 2 * axis) - zero; ++ int32_t delta = raw * 4 - zero; // 14-bit sample versus 16-bit factory points. ++ if (span < 0) { ++ span = -span; ++ delta = -delta; ++ } ++ return uni_imu_scale(delta, span, c[12] * 6 * UNI_IMU_GYRO_RES_PER_DEG_S); ++} ++ ++static void wii_process_motion_extension(uni_hid_device_t* d, const uint8_t* e) { ++ wii_instance_t* ins = get_wii_instance(d); ++ if (ins->state != WII_FSM_LED_UPDATED && ins->state != WII_FSM_MP_VERIFY) ++ return; ++ if (!ins->mp_active) { ++ if (ins->ext_type == WII_EXT_NUNCHUK) ++ wii_update_nunchuk(ins, e, false); ++ return; ++ } ++ // Bit zero is reserved in both MP and passthrough frames. In particular, ++ // ignore the FF-filled extension buffer while activation is settling. ++ if (e[5] & 1) ++ return; ++ bool connected = (e[4] & 1) != 0; ++ if (connected != ins->ext_connected) { ++ ins->ext_connected = connected; ++ ins->topology_pending = true; ++ wii_clear_extension(d); ++ } ++ if (!(e[5] & 2)) { ++ if (ins->ext_type == WII_EXT_NUNCHUK && (e[4] & 1)) ++ wii_update_nunchuk(ins, e, true); ++ if (ins->topology_pending && ins->state == WII_FSM_LED_UPDATED) ++ wii_discover_extension(d); ++ return; ++ } ++ if (!ins->mp_calibrated) ++ return; ++ int32_t yaw = e[0] | ((e[3] & 0xfc) << 6); ++ int32_t roll = e[1] | ((e[4] & 0xfc) << 6); ++ int32_t pitch = e[2] | ((e[5] & 0xfc) << 6); ++ // WiiBrew factory points have signed spans; Dolphin applies native ++ // right-hand-rule signs (-pitch,+roll,-yaw). SDL's (-X,Z,Y) then gives ++ // (+pitch,-yaw,+roll). Each axis has its OWN slow/fast selector. ++ ins->gyro[0] = wii_calibrate_gyro(ins, 2, pitch, e[3] & 1); ++ ins->gyro[1] = -wii_calibrate_gyro(ins, 0, yaw, e[3] & 2); ++ ins->gyro[2] = wii_calibrate_gyro(ins, 1, roll, e[4] & 2); ++ for (int i = 0; i < 3; i++) ++ d->controller.gamepad.gyro[i] = ins->gyro[i]; ++ if (ins->topology_pending && ins->state == WII_FSM_LED_UPDATED) ++ wii_discover_extension(d); + } - // Dpad works as dpad, useful to navigate menus. - ctl->gamepad.dpad |= (report[1] & 0x01) ? DPAD_DOWN : 0; + // Used in WiiMote + Nunchuk Mode +@@ -617,31 +913,17 @@ + } + + wii_instance_t* ins = get_wii_instance(d); +- if (ins->ext_type != WII_EXT_NUNCHUK && ins->ext_type != WII_EXT_UDRAW_TABLET) { +- loge("Wii: unexpected Wii extension: got %d, want: %d or %d", ins->ext_type, WII_EXT_NUNCHUK, +- WII_EXT_UDRAW_TABLET); +- return; +- } ++ if (ins->ext_type == WII_EXT_NUNCHUK || ins->mp_active) { ++ wii_process_motion_extension(d, report + 3); ++ process_drm_k(d, report, len); ++ return; ++ } ++ if (ins->ext_type != WII_EXT_UDRAW_TABLET) ++ return; + + uni_controller_t* ctl = &d->controller; + +- if (ins->mode != WII_MODE_VERTICAL) { +- loge("Wii: When Nunchuk or uDraw Tablet is attached, only vertical mode is supported. Found: %d\n", ins->mode); +- return; +- } +- +- if (ins->ext_type == WII_EXT_NUNCHUK) { +- // +- // Process Nunchuk: Right axis, buttons X and Y +- // +- nunchuk_t n = process_nunchuk(&report[3], len - 3); +- const int factor = (AXIS_NORMALIZE_RANGE / 2) / 128; +- +- ctl->gamepad.axis_rx = n.sx * factor; +- ctl->gamepad.axis_ry = n.sy * factor; +- ctl->gamepad.buttons |= n.bc ? BUTTON_X : 0; +- ctl->gamepad.buttons |= n.bz ? BUTTON_Y : 0; +- } else if (ins->ext_type == WII_EXT_UDRAW_TABLET) { ++ { + // Better if uDraw reports its own "type", but for the moment + // it gets reported and the 2nd half of a gamepad. + +@@ -668,59 +950,20 @@ + ctl->gamepad.buttons |= n.button_lower ? BUTTON_Y : 0; + } + +- // +- // Process Wii remote: DPAD, buttons A, B, Shoulder L & R, and misc. +- // +- +- // dpad +- ctl->gamepad.dpad |= (report[1] & 0x01) ? DPAD_LEFT : 0; +- ctl->gamepad.dpad |= (report[1] & 0x02) ? DPAD_RIGHT : 0; +- ctl->gamepad.dpad |= (report[1] & 0x04) ? DPAD_DOWN : 0; +- ctl->gamepad.dpad |= (report[1] & 0x08) ? DPAD_UP : 0; +- +- ctl->gamepad.buttons |= (report[2] & 0x04) ? BUTTON_A : 0; // Shoulder button +- ctl->gamepad.buttons |= (report[2] & 0x08) ? BUTTON_B : 0; // Big button "A" +- +- ctl->gamepad.buttons |= (report[2] & 0x02) ? BUTTON_SHOULDER_L : 0; // Button "1" +- ctl->gamepad.buttons |= (report[2] & 0x01) ? BUTTON_SHOULDER_R : 0; // Button "2" +- +- ctl->gamepad.misc_buttons |= (report[2] & 0x80) ? MISC_BUTTON_SYSTEM : 0; // Button "home" +- ctl->gamepad.misc_buttons |= (report[2] & 0x10) ? MISC_BUTTON_SELECT : 0; // Button "-" +- ctl->gamepad.misc_buttons |= (report[1] & 0x10) ? MISC_BUTTON_START : 0; // Button "+" ++ process_drm_k(d, report, len); + } + + // Defined here: + // http://wiibrew.org/wiki/Wiimote#0x35:_Core_Buttons_and_Accelerometer_with_16_Extension_Bytes + static void process_drm_kae(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { +- // Expecting something like: +- // (a1) 35 BB BB AA AA AA EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE +- ARG_UNUSED(d); +- ARG_UNUSED(report); +- ARG_UNUSED(len); +- loge("Wii: drm_kae not supported yet\n"); +-} +- +-static nunchuk_t process_nunchuk(const uint8_t* e, uint16_t len) { +- // Nunchuk format here: +- // http://wiibrew.org/wiki/Wiimote/Extension_Controllers/Nunchuck +- nunchuk_t n = {0}; +- if (len < 6) { +- loge("Wii: unexpected len; got %d, want >= 6\n", len); +- return n; +- } +- n.sx = e[0] - 0x80; +- // Invert polarity to match virtual gamepad. +- n.sy = -(e[1] - 0x80); +- n.ax = (e[2] << 2) | ((e[5] & 0b00001100) >> 2); +- n.ay = (e[3] << 2) | ((e[5] & 0b00110000) >> 4); +- n.az = (e[4] << 2) | ((e[5] & 0b11000000) >> 6); +- n.ax -= AXIS_NORMALIZE_RANGE / 2; +- n.ay -= AXIS_NORMALIZE_RANGE / 2; +- n.az -= AXIS_NORMALIZE_RANGE / 2; +- n.bc = !(e[5] & 0b00000010); +- n.bz = !(e[5] & 0b00000001); +- return n; +-} ++ // 35 BB BB AA AA AA EE*16 ++ if (len < 22) ++ return; ++ // Update an interleaved extension before applying its cached controls. ++ wii_process_motion_extension(d, report + 6); ++ process_drm_ka(d, report, len); ++} ++ + + static udraw_tablet_t process_udraw_tablet(const uint8_t* e, uint16_t len) { + // uDraw Tablet format here: +@@ -1002,52 +1245,18 @@ + logi("fsm: req_status\n"); + wii_instance_t* ins = get_wii_instance(d); + ins->state = WII_FSM_DID_REQ_STATUS; +- const uint8_t status[] = {0xa2, WIIPROTO_REQ_SREQ, 0x00 /* LEDS & rumble off */}; ++ const uint8_t status[] = {0xa2, WIIPROTO_REQ_SREQ, ins->rumble_state == WII_STATE_RUMBLE_IN_PROGRESS}; + uni_hid_device_send_intr_report(d, status, sizeof(status)); + } + + static void wii_fsm_ext_init(uni_hid_device_t* d) { +- logi("fsm: ext_init\n"); +- wii_instance_t* ins = get_wii_instance(d); +- ins->state = WII_FSM_EXT_DID_INIT; +- // Init Wii +- uint8_t report[] = { +- // clang-format off +- 0xa2, WIIPROTO_REQ_WMEM, +- 0x04, // Control registers +- 0xa4, 0x00, 0xf0, // register init extension +- 0x01, 0x55, // # bytes, byte to write +- // Padding, since at least 16 bytes must be sent +- 0x00, 0x00, 0x00, 0x00, +- 0x00, 0x00, 0x00, 0x00, +- 0x00, 0x00, 0x00, 0x00, +- 0x00, 0x00, 0x00, +- // clang-format on +- }; +- report[3] = ins->register_address; +- uni_hid_device_send_intr_report(d, report, sizeof(report)); ++ get_wii_instance(d)->state = WII_FSM_EXT_DID_INIT; ++ wii_write_register(d, 0xa400f0, 0x55); + } + + static void wii_fsm_ext_encrypt_off(uni_hid_device_t* d) { +- logi("fsm: ext_encrypt_off\n"); +- wii_instance_t* ins = get_wii_instance(d); +- ins->state = WII_FSM_EXT_DID_NO_ENCRYPTION; +- // Init Wii +- uint8_t report[] = { +- // clang-format off +- 0xa2, WIIPROTO_REQ_WMEM, +- 0x04, // Control registers +- 0xa4, 0x00, 0xfb, // register disable encryption +- 0x01, 0x00, // # bytes, byte to write +- // Padding, since at least 16 bytes must be sent +- 0x00, 0x00, 0x00, 0x00, +- 0x00, 0x00, 0x00, 0x00, +- 0x00, 0x00, 0x00, 0x00, +- 0x00, 0x00, 0x00, +- // clang-format on +- }; +- report[3] = ins->register_address; +- uni_hid_device_send_intr_report(d, report, sizeof(report)); ++ get_wii_instance(d)->state = WII_FSM_EXT_DID_NO_ENCRYPTION; ++ wii_write_register(d, 0xa400fb, 0); + } + + static void wii_fsm_ext_read_register(uni_hid_device_t* d) { +@@ -1055,8 +1264,7 @@ + wii_instance_t* ins = get_wii_instance(d); + ins->state = WII_FSM_EXT_DID_READ_REGISTER; + +- // Addr is either 0xA400FA or 0xA600FA +- uint32_t offset = 0x0000fa | (ins->register_address << 16); ++ uint32_t offset = 0xa400fa; + uint16_t bytes_to_read = 6; + wii_read_mem(d, WII_READ_FROM_REGISTERS, offset, bytes_to_read); + } +@@ -1066,8 +1274,7 @@ + wii_instance_t* ins = get_wii_instance(d); + ins->state = WII_FSM_BALANCE_BOARD_DID_READ_CALIBRATION; + +- // Addr is either 0xA40024 or 0xA60024 +- uint32_t offset = 0x000024 | (ins->register_address << 16); ++ uint32_t offset = 0xa40024; + uint16_t bytes_to_read = 16; + wii_read_mem(d, WII_READ_FROM_REGISTERS, offset, bytes_to_read); + } +@@ -1077,8 +1284,7 @@ + wii_instance_t* ins = get_wii_instance(d); + ins->state = WII_FSM_BALANCE_BOARD_DID_READ_CALIBRATION2; + +- // Addr is either 0xA40024 or 0xA60024 +- uint32_t offset = 0x000034 | (ins->register_address << 16); ++ uint32_t offset = 0xa40034; + uint16_t bytes_to_read = 8; + wii_read_mem(d, WII_READ_FROM_REGISTERS, offset, bytes_to_read); + } +@@ -1100,18 +1306,8 @@ + } + uint8_t report_type = 0xff; + if (ins->ext_type == WII_EXT_NUNCHUK) { +- // Request Nunchuk data +- if (ins->mode == WII_MODE_ACCEL) { +- // Request Core buttons + Accel + extension (nunchuk) +- report_type = WII_REPORT_TYPE_KAE; +- logi("Wii: requesting Core buttons + Accelerometer + E (Nunchuk)\n"); +- d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL; +- } else { +- // Request Core buttons + extension (nunchuk) +- report_type = WII_REPORT_TYPE_KE; +- logi("Wii: requesting Core buttons + E (Nunchuk)\n"); +- d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK; +- } ++ report_type = WII_REPORT_TYPE_KAE; ++ d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL; + } else if (ins->ext_type == WII_EXT_CLASSIC_CONTROLLER) { + logi("Wii: requesting E (Classic Controller)\n"); + d->controller_subtype = CONTROLLER_SUBTYPE_WII_CLASSIC; +@@ -1125,20 +1321,11 @@ + d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_UDRAW_TABLET; + report_type = WII_REPORT_TYPE_KE; + } else { +- if (ins->mode == WII_MODE_ACCEL) { +- // Request Core buttons + accel +- report_type = WII_REPORT_TYPE_KA; +- logi("Wii: requesting Core buttons + Accelerometer\n"); +- d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_ACCEL; +- } else { +- report_type = WII_REPORT_TYPE_K; +- logi("Wii: requesting Core buttons\n"); +- if (ins->mode == WII_MODE_VERTICAL) { +- d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL; +- } else { +- d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL; +- } +- } ++ report_type = ins->mp_active ? WII_REPORT_TYPE_KAE : WII_REPORT_TYPE_KA; ++ if (ins->mode == WII_MODE_VERTICAL) ++ d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL; ++ else ++ d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL; + } + uni_hid_parser_wii_request_report_type(d, report_type); + break; +@@ -1164,7 +1351,10 @@ + ins->state = WII_FSM_LED_UPDATED; + wii_process_fsm(d); + +- uni_hid_device_set_ready_complete(d); ++ if (!ins->ready) { ++ ins->ready = true; ++ uni_hid_device_set_ready_complete(d); ++ } + } + + static void wii_fsm_dump_eeprom(struct uni_hid_device_s* d) { +@@ -1218,7 +1408,15 @@ + // Do nothing + break; + case WII_FSM_DEV_GUESSED: +- wii_fsm_assign_device(d); ++ if (!ins->motion_setup_done && ins->dev_type != WII_DEVTYPE_PRO_CONTROLLER && ++ (ins->ext_type == WII_EXT_NONE || ins->ext_type == WII_EXT_NUNCHUK)) { ++ if (ins->accel_calibrated) ++ wii_probe_motion_plus(d); ++ else ++ wii_setup_read(d, WII_FSM_ACCEL_CALIBRATION, WII_READ_FROM_MEM, 0x16, 10); ++ } else { ++ wii_confirm_topology(d); ++ } + break; + case WII_FSM_BALANCE_BOARD_READ_CALIBRATION: + wii_fsm_balance_board_read_calibration(d); +@@ -1234,6 +1432,16 @@ + wii_fsm_update_led(d); + break; + case WII_FSM_LED_UPDATED: ++ case WII_FSM_NUNCHUK_CALIBRATION: ++ case WII_FSM_ACCEL_CALIBRATION: ++ case WII_FSM_ACCEL_CALIBRATION_BACKUP: ++ case WII_FSM_MP_PROBE: ++ case WII_FSM_MP_INIT: ++ case WII_FSM_MP_CALIBRATION_FAST: ++ case WII_FSM_MP_CALIBRATION_SLOW: ++ case WII_FSM_MP_ACTIVATE: ++ case WII_FSM_MP_VERIFY: ++ case WII_FSM_TOPOLOGY_STATUS: + break; + default: + loge("Wii: wii_process_fsm() unexpected state: %d\n", ins->state); +@@ -1250,9 +1458,6 @@ + ins->mode = WII_MODE_HORIZONTAL; + ins->state = WII_FSM_SETUP; + +- // Start with 0xa40000 (all Wii devices, except for the Wii Remote Plus) +- // If it fails it will use 0xa60000 +- ins->register_address = 0xa4; + + // Dump EEPROM + #if ENABLE_EEPROM_DUMP +@@ -1267,13 +1472,18 @@ + } + + void uni_hid_parser_wii_init_report(uni_hid_device_t* d) { +- // Reset old state. Each report contains a full-state. ++ wii_instance_t* ins = get_wii_instance(d); + memset(&d->controller, 0, sizeof(d->controller)); + d->controller.klass = UNI_CONTROLLER_CLASS_GAMEPAD; ++ // Status/ack and Nunchuk interleaves do not contain new motion samples. ++ for (int i = 0; i < 3; i++) { ++ d->controller.gamepad.accel[i] = ins->accel[i]; ++ d->controller.gamepad.gyro[i] = ins->gyro[i]; ++ } + } + + void uni_hid_parser_wii_parse_input_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { +- if (len == 0) ++ if (!report || len == 0) + return; + switch (report[0]) { + case WIIPROTO_REQ_STATUS: +@@ -1307,6 +1517,9 @@ + logi("Wii parser: unknown report type: 0x%02x\n", report[0]); + printf_hexdump(report, len); + } ++ if (len >= 3 && report[0] >= WIIPROTO_REQ_STATUS && report[0] <= WIIPROTO_REQ_RETURN && ++ get_wii_instance(d)->dev_type != WII_DEVTYPE_PRO_CONTROLLER) ++ process_drm_k(d, report, len); + } + + void uni_hid_parser_wii_set_player_leds(uni_hid_device_t* d, uint8_t leds) { +@@ -1371,29 +1584,21 @@ + + void uni_hid_parser_wii_set_mode(uni_hid_device_t* d, wii_mode_t mode) { + wii_instance_t* ins = get_wii_instance(d); +- ++ if (mode != WII_MODE_HORIZONTAL && mode != WII_MODE_VERTICAL && mode != WII_MODE_ACCEL) ++ return; ++ // Extension mappings are vertical independently of the selected standalone ++ // orientation. Preserve the user's choice through attachment and removal. + ins->mode = mode; +- switch (ins->mode) { +- case WII_MODE_HORIZONTAL: +- d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL; +- break; +- case WII_MODE_VERTICAL: +- d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL; +- break; +- case WII_MODE_ACCEL: +- // TODO: request Accel report. As it is, it doesn't work. +- if (ins->ext_type == WII_EXT_NONE) +- d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_ACCEL; +- else if (ins->ext_type == WII_EXT_NUNCHUK) +- d->controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL; +- break; +- default: +- break; +- } ++ if (ins->state == WII_FSM_LED_UPDATED) ++ wii_fsm_assign_device(d); + } + + void uni_hid_parser_wii_request_report_type(struct uni_hid_device_s* d, wii_report_type_t report_type) { +- uint8_t report[] = {0xa2, WIIPROTO_REQ_DRM, 0x00, report_type}; ++ wii_instance_t* ins = get_wii_instance(d); ++ ins->report_type = report_type; ++ uint8_t continuous = (report_type == WII_REPORT_TYPE_KA || report_type == WII_REPORT_TYPE_KAE) ? 4 : 0; ++ uint8_t report[] = {0xa2, WIIPROTO_REQ_DRM, ++ continuous | (ins->rumble_state == WII_STATE_RUMBLE_IN_PROGRESS), report_type}; + uni_hid_device_send_intr_report(d, report, sizeof(report)); + } + +@@ -1480,10 +1685,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); ++ wii_instance_t* ins = get_wii_instance(d); ++ ins->read_address = offset & 0xffff; ++ ins->read_size = size; + uint8_t report[] = { + // clang-format off + 0xa2, WIIPROTO_REQ_RMEM, +- t, // Read from registers or memory ++ t | (ins->rumble_state == WII_STATE_RUMBLE_IN_PROGRESS), + (offset & 0xff0000) >> 16, (offset & 0xff00) >> 8, (offset & 0xff), // Offset + (size & 0xff00) >> 8, (size & 0xff), // Size in bytes + // clang-format on +@@ -1491,6 +1699,15 @@ + uni_hid_device_send_intr_report(d, report, sizeof(report)); + } + ++static void wii_write_register(uni_hid_device_t* d, uint32_t address, uint8_t value) { ++ wii_instance_t* ins = get_wii_instance(d); ++ uint8_t report[23] = { ++ 0xa2, WIIPROTO_REQ_WMEM, 0x04 | (ins->rumble_state == WII_STATE_RUMBLE_IN_PROGRESS), ++ address >> 16, address >> 8, address, 1, value, ++ }; ++ uni_hid_device_send_intr_report(d, report, sizeof(report)); ++} ++ + void uni_hid_parser_wii_device_dump(uni_hid_device_t* d) { + wii_instance_t* ins = get_wii_instance(d); + logi("\tWii: device '%s', extension '%s'\n", wii_devtype_names[ins->dev_type], wii_exttype_names[ins->ext_type]); diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad32/uni_hid_device.c --- a/src/components/bluepad32/uni_hid_device.c +++ b/src/components/bluepad32/uni_hid_device.c diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 6a0de7d..6f58c07 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -23,6 +23,9 @@ #include #include #include +extern "C" { +#include "parser/uni_hid_parser_wii.h" +} #include "parser/uni_hid_parser_switch2.h" #include "parser/uni_switch2_haptics.h" #include "parser/uni_switch2_pairing.h" @@ -162,6 +165,12 @@ struct ProfileFeedbackSequence { bool rumble_enabled; bool led_enabled; }; +struct WiiOrientationRequest { + ControllerIdentity identity; + uint32_t connection_generation; + bool vertical; +}; + // Security Manager identity events arrive before Bluepad32 publishes a ready @@ -192,6 +201,8 @@ struct BackendSlot { uint32_t state_generation; uint32_t connection_generation; bool active; + bool wii_orientation_pending; + WiiOrientationRequest pending_wii_orientation; bool rumble_pending; bool motion_enabled; bool feedback_pending; @@ -1097,6 +1108,8 @@ void publish_all_neutral() { slot.extra_buttons = 0; slot.companion_extra_buttons = 0; slot.active = false; + slot.wii_orientation_pending = false; + slot.pending_wii_orientation = {}; slot.rumble_pending = false; slot.retained_host_rumble_valid = false; slot.retained_host_rumble = {}; @@ -1490,6 +1503,8 @@ void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms, void reset_slot_hotkeys(BackendSlot& slot) { g_macro_capture.disconnect(static_cast(&slot - g_slots), slot.connection_generation, time_us_32()); + slot.wii_orientation_pending = false; + slot.pending_wii_orientation = {}; slot.motion_enabled = kDefaultMotionEnabled; slot.pre_hotkey_button_mask = 0; slot.feedback_pending = false; @@ -1528,6 +1543,21 @@ void release_slot(BackendSlot& slot) { slot.active = false; } +bool is_solo_wii_remote(const BackendSlot& slot) { + if (!slot.active || slot.device == nullptr || slot.companion != nullptr || + slot.device->controller_type != CONTROLLER_TYPE_WiiController) { + return false; + } + switch (slot.device->controller_subtype) { + case CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL: + case CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL: + case CONTROLLER_SUBTYPE_WIIMOTE_ACCEL: + return true; + default: + return false; + } +} + HotkeyDecision update_controller_hotkeys( uint8_t slot_index, uni_hid_device_t* device) { @@ -2033,6 +2063,44 @@ void dispatch_rumble(uni_hid_device_t* device, uint16_t duration_ms, device->report_parser.play_dual_rumble(device, 0, duration_ms, weak, strong); } +// Core 1 only. The mailbox carries values, never a parser pointer supplied by +// Core 0. Revalidate after lifecycle/topology work and before touching the parser. +void process_wii_orientation(uint8_t slot_index) { + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (!slot.wii_orientation_pending) { + critical_section_exit(&g_state_lock); + return; + } + const WiiOrientationRequest request = slot.pending_wii_orientation; + slot.wii_orientation_pending = false; + slot.pending_wii_orientation = {}; + if (!is_solo_wii_remote(slot) || + slot.connection_generation != request.connection_generation || + !controller_identity_equal(slot.identity, request.identity)) { + critical_section_exit(&g_state_lock); + return; + } + uni_hid_device_t* device = slot.device; + // Subtype publication can lag set_mode during extension discovery. Even a + // reselection must reach the parser to replace a deferred opposite choice. + // A new logical epoch retires profile macros, hotkey holds, capture and + // feedback without touching this connection's identity or saved profiles. + invalidate_slot(slot); + slot.gamepad = {}; + slot.extra_buttons = 0; + critical_section_exit(&g_state_lock); + + // The setter can synchronously re-enter the platform ready callback, so + // release the lock first. Lifecycle and parser callbacks share this core. + if (device->report_parser.play_dual_rumble != nullptr) { + dispatch_rumble(device, 0, 0, 0); + } + uni_hid_parser_wii_set_mode( + device, request.vertical ? WII_MODE_VERTICAL : WII_MODE_HORIZONTAL); + apply_slot_lighting(slot_index, device); +} + uint8_t xbox_trigger_magnitude(const SwitchHapticsActuatorFrame& frame) { uint16_t peak = 0; for (uint8_t i = 0; i < frame.sample_count && i < 3; ++i) { @@ -2133,6 +2201,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) { #endif for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { + process_wii_orientation(slot_index); drain_switch2_ingress(slot_index, now_ms); RumbleEnvelope envelope{}; FeedbackEnvelope feedback{}; @@ -3407,10 +3476,13 @@ void bluepad32_input_backend_playtest_snapshot( } else { switch (slot.device->controller_subtype) { case CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL: - case CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL: case CONTROLLER_SUBTYPE_WIIMOTE_ACCEL: out->controller_layout = - Bluepad32ControllerLayout::kWiiRemote; + Bluepad32ControllerLayout::kWiiHorizontal; + break; + case CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL: + out->controller_layout = + Bluepad32ControllerLayout::kWiiVertical; break; case CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK: case CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL: @@ -3550,6 +3622,30 @@ void bluepad32_input_backend_queue_rumble( #endif } +bool bluepad32_input_backend_set_wii_orientation( + const ControllerIdentity& identity, uint32_t connection_generation, + bool vertical) { + if (!g_initialized || !identity.stable || + controller_identity_is_global(identity)) { + return false; + } + bool queued = false; + critical_section_enter_blocking(&g_state_lock); + for (BackendSlot& slot : g_slots) { + if (!is_solo_wii_remote(slot) || + slot.connection_generation != connection_generation || + !controller_identity_equal(slot.identity, identity)) { + continue; + } + slot.pending_wii_orientation = {identity, connection_generation, vertical}; + slot.wii_orientation_pending = true; + queued = true; + break; + } + critical_section_exit(&g_state_lock); + return queued; +} + bool bluepad32_input_backend_identify( const ControllerIdentity& identity) { if (!g_initialized || !identity.stable || diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index b56cbd8..da59cdc 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -71,6 +71,8 @@ enum class Bluepad32ControllerLayout : uint8_t { kJoyCon2MergedPair = 3, kWiiRemote = 4, kWiiNunchuk = 5, + kWiiHorizontal = 6, + kWiiVertical = 7, }; // Side-effect-free raw input snapshot for management telemetry. Unlike the @@ -133,6 +135,12 @@ void bluepad32_input_backend_queue_rumble( uint8_t slot, const ControllerRumbleOutput& rumble); bool bluepad32_input_backend_identify( const ControllerIdentity& identity); +// Queue a connection-local standalone Wii mapping change for Core 1's 5 ms +// timer. Acceptance is not application; dispatch advances the logical +// connection generation once, including reselection. Nunchuk is ineligible. +bool bluepad32_input_backend_set_wii_orientation( + const ControllerIdentity& identity, uint32_t connection_generation, + bool vertical); // Enqueue bounded local profile confirmation for the matching live connection // generation. The two-entry per-slot FIFO preserves initial-then-switch // ordering. Profile lighting is transient and restored to the steady slot diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index 0c4f163..24a7c0f 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -121,6 +121,9 @@ bool valid_out_size(Operation operation, size_t size) { case Operation::kProfileIdentify: return size == kRequestHeaderSize + CONTROLLER_IDENTITY_ENCODED_SIZE; + case Operation::kWiiOrientation: + return size == kRequestHeaderSize + + CONTROLLER_IDENTITY_ENCODED_SIZE + 5; case Operation::kPairingRefresh: case Operation::kPairingClear: return size == kRequestHeaderSize; @@ -518,7 +521,7 @@ size_t encode_profile_playtest( CONTROLLER_MOTION_SAMPLE_CAPACITY || (snapshot.state.extra_buttons & 0x80u) != 0 || static_cast(snapshot.controller_layout) > - static_cast(Bluepad32ControllerLayout::kWiiNunchuk)) { + static_cast(Bluepad32ControllerLayout::kWiiVertical)) { return 0; } payload[0] = 1; @@ -899,6 +902,15 @@ bool process_out_request() { &identity) && bluepad32_input_backend_identify(identity); } + case Operation::kWiiOrientation: { + ControllerIdentity identity{}; + constexpr size_t offset = CONTROLLER_IDENTITY_ENCODED_SIZE; + return payload[offset + 4] <= 1 && + controller_identity_decode(payload, offset, &identity) && + bluepad32_input_backend_set_wii_orientation( + identity, UsbConfigurationManagement::read_u32(payload + offset), + payload[offset + 4] != 0); + } case Operation::kPairingRefresh: bluepad32_input_backend_request_pairing_snapshot(); return true; diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index c277696..15b9444 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -23,7 +23,7 @@ constexpr size_t kProfileListRowSize = 16 + PROFILE_SERVICE_METADATA_MAX_BYTES + 1; constexpr size_t kProfileListPayloadSize = 1 + PROFILE_SERVICE_LIST_CAPACITY * kProfileListRowSize; -constexpr uint16_t kProfilePlaytestSchemaVersion = 4; +constexpr uint16_t kProfilePlaytestSchemaVersion = 5; constexpr size_t kProfilePlaytestPayloadSize = 56; constexpr size_t kProfileMetadataPayloadSize = (CONTROLLER_PROFILE_COUNT + 1) * @@ -74,6 +74,7 @@ enum class Operation : uint8_t { kProfileMetadataRead = 0x3a, kProfileMetadataSet = 0x3b, kProfileIdentify = 0x3c, + kWiiOrientation = 0x3d, kHapticsExperiment = 0x40, kHapticsTransportProbe = 0x41, kMacroCapture = 0x42, diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 34ad6ae..234aec5 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -64,6 +64,7 @@ OP_PROFILE_PLAYTEST = 0x39 OP_PROFILE_METADATA_READ = 0x3A OP_PROFILE_METADATA_SET = 0x3B OP_PROFILE_IDENTIFY = 0x3C +OP_WII_ORIENTATION = 0x3D OP_HAPTICS_EXPERIMENT = 0x40 OP_HAPTICS_TRANSPORT_PROBE = 0x41 OP_MACRO_CAPTURE = 0x42 @@ -172,7 +173,8 @@ PROFILE_PLAYTEST_LEGACY_SCHEMA_VERSION = 2 PROFILE_PLAYTEST_LEGACY_SIZE = 54 PROFILE_PLAYTEST_EXTRA_BUTTON_SCHEMA_VERSION = 3 PROFILE_PLAYTEST_EXTRA_BUTTON_SIZE = 55 -PROFILE_PLAYTEST_SCHEMA_VERSION = 4 +PROFILE_PLAYTEST_TOPOLOGY_SCHEMA_VERSION = 4 +PROFILE_PLAYTEST_SCHEMA_VERSION = 5 PROFILE_PLAYTEST_SIZE = 56 PROFILE_PLAYTEST_LAYOUTS = ( None, @@ -181,6 +183,8 @@ PROFILE_PLAYTEST_LAYOUTS = ( "joycon2-pair", "wii-remote", "wii-nunchuk", + "wii-horizontal", + "wii-vertical", ) PROFILE_PLAYTEST_SLOT_COUNT = 4 PROFILE_METADATA_SCHEMA_VERSION = 1 @@ -3774,19 +3778,38 @@ def identify_controller(device: UsbDevice, identity: ControllerIdentity) -> None _control_out(device, OP_PROFILE_IDENTIFY, identity.to_bytes()) +def set_wii_orientation( + device: UsbDevice, + identity: ControllerIdentity, + connection_generation: int, + orientation: str, +) -> None: + if identity.is_global_fallback or not identity.stable: + raise ConfigManagerError("select a connected Wii Remote to change orientation") + _require_int(connection_generation, "connection generation", 0, 0xFFFFFFFF) + mode = _require_enum(orientation, ("horizontal", "vertical"), "Wii orientation") + _control_out( + device, + OP_WII_ORIENTATION, + identity.to_bytes() + struct.pack(" ProfilePlaytest: _raise_status(envelope) expected_size = { PROFILE_PLAYTEST_LEGACY_SCHEMA_VERSION: PROFILE_PLAYTEST_LEGACY_SIZE, PROFILE_PLAYTEST_EXTRA_BUTTON_SCHEMA_VERSION: PROFILE_PLAYTEST_EXTRA_BUTTON_SIZE, + PROFILE_PLAYTEST_TOPOLOGY_SCHEMA_VERSION: PROFILE_PLAYTEST_SIZE, PROFILE_PLAYTEST_SCHEMA_VERSION: PROFILE_PLAYTEST_SIZE, }.get(envelope.schema_version) if len(envelope.payload) != expected_size: raise ConfigManagerError("invalid profile playtest payload") payload = envelope.payload extra_buttons = payload[54] if len(payload) >= PROFILE_PLAYTEST_EXTRA_BUTTON_SIZE else 0 - layout_code = payload[55] if envelope.schema_version == PROFILE_PLAYTEST_SCHEMA_VERSION else 0 - if layout_code >= len(PROFILE_PLAYTEST_LAYOUTS): + layout_code = payload[55] if len(payload) >= PROFILE_PLAYTEST_SIZE else 0 + layout_count = 6 if envelope.schema_version == PROFILE_PLAYTEST_TOPOLOGY_SCHEMA_VERSION else len(PROFILE_PLAYTEST_LAYOUTS) + if layout_code >= layout_count: raise ConfigManagerError("invalid playtest controller layout") if extra_buttons & ~0x7F: raise ConfigManagerError("invalid playtest extra buttons") diff --git a/src/switch_pico_bridge/profile_web.py b/src/switch_pico_bridge/profile_web.py index 7af8a63..0646d80 100644 --- a/src/switch_pico_bridge/profile_web.py +++ b/src/switch_pico_bridge/profile_web.py @@ -155,6 +155,8 @@ def _controller_presentation( "joycon2-right": ("Nintendo Joy-Con 2 (R)", "switch", {0x2066}), "joycon2-pair": ("Nintendo Joy-Con 2 pair", "switch", {0x2067, 0x2066}), "wii-remote": ("Nintendo Wii Remote", "wii", {0x0306, 0x0330}), + "wii-horizontal": ("Nintendo Wii Remote (horizontal)", "wii", {0x0306, 0x0330}), + "wii-vertical": ("Nintendo Wii Remote (vertical)", "wii", {0x0306, 0x0330}), "wii-nunchuk": ("Nintendo Wii Remote + Nunchuk", "wii", {0x0306, 0x0330}), } live = live_layouts.get(layout) @@ -208,7 +210,7 @@ def _source_controls(controller: dict[str, str]) -> list[str]: if layout == "joycon2-left" else ("start", "system", "right_trigger", "c", "right_sl", "right_sr") ) - elif layout in {"wii-remote", "wii-nunchuk"}: + elif layout in {"wii-remote", "wii-horizontal", "wii-vertical", "wii-nunchuk"}: controls = ( "south", "east", "west", "north", "select", "start", "system", "dpad_up", "dpad_down", "dpad_left", "dpad_right", @@ -444,6 +446,9 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): if action == "identify": self._api_call(lambda: self._identify(identity_index)) return + if action == "wii-orientation": + self._api_call(lambda: self._set_wii_orientation(identity_index)) + return selection = self._parse_profile_path(path) if selection is not None: identity_index, profile_index, action = selection @@ -966,6 +971,25 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): config_manager.identify_controller(device, identity) return {"identified": True} + def _set_wii_orientation(self, identity_index: int) -> dict[str, Any]: + body = self._read_json_object() + orientation = body.get("orientation") + generation = body.get("connection_generation") + if ( + not isinstance(orientation, str) + or not isinstance(generation, int) + or isinstance(generation, bool) + or not 0 <= generation <= 0xFFFFFFFF + ): + raise config_manager.ConfigManagerError( + "request must contain orientation ('horizontal' or 'vertical') " + "and a valid connection_generation" + ) + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + config_manager.set_wii_orientation(device, identity, generation, orientation) + return {"queued": True} + def _copy_profile(self, identity_index: int, profile_index: int) -> dict[str, Any]: destination = self._read_json_object() destination_identity_index = destination.get("identity_index") diff --git a/src/switch_pico_bridge/web/controller_layouts.js b/src/switch_pico_bridge/web/controller_layouts.js index 5c901dd..6572c4e 100644 --- a/src/switch_pico_bridge/web/controller_layouts.js +++ b/src/switch_pico_bridge/web/controller_layouts.js @@ -149,12 +149,17 @@ const ControllerLayouts = (() => { }, "wii-remote": { name: "Wii Remote · orientation unknown", style: "switch", asset: "wii-remote-simple.svg", - viewBox: [0, 0, 200, 632], minWidth: 240, maxWidth: 260, + viewBox: [0, 0, 200, 632], rotation: -90, minWidth: 760, controls: { ...wiiCommon, - ...Object.fromEntries(["south", "east", "west", "north", "dpad_up", "dpad_right", "dpad_down", "dpad_left"].map(id => [id, back("Logical " + id.replaceAll("_", " "), id.startsWith("dpad") ? id.slice(5) : id)])), + south: point(100, 445, "1 (horizontal default)", "1"), east: point(100, 495, "2 (horizontal default)", "2"), + west: point(100, 216, "A (horizontal default)", "A"), north: back("B · underside", "B"), + dpad_left: point(100, 103, "D-pad physical Up → Left (horizontal default)", "◀"), + dpad_up: point(125, 128, "D-pad physical Right → Up (horizontal default)", "▲"), + dpad_right: point(100, 153, "D-pad physical Down → Right (horizontal default)", "▶"), + dpad_down: point(75, 128, "D-pad physical Left → Down (horizontal default)", "▼"), }, - note: "Firmware reports a Wii Remote, not its orientation. Only Minus, Plus and Home can be placed unambiguously. Choose an explicit horizontal/accelerometer or vertical preview to place the other sources. Power is not remappable.", + note: "Legacy firmware reports a Wii Remote without detected orientation. Controls default to standard horizontal positions. Select an explicit preview to compare layouts. B is underneath; Power is not remappable.", }, "wii-horizontal": { name: "Wii Remote · horizontal / accelerometer", style: "switch", asset: "wii-remote-simple.svg", @@ -168,12 +173,12 @@ const ControllerLayouts = (() => { dpad_right: point(100, 153, "D-pad physical Down → Right", "▶"), dpad_down: point(75, 128, "D-pad physical Left → Down", "▼"), }, - note: "Explicit orientation preview, not detected: 1→south, 2→east, A→west, B→north. D-pad sources rotate counterclockwise. B is underneath; Power is not remappable.", + note: "1→south, 2→east, A→west, B→north. D-pad sources rotate counterclockwise for horizontal grip. B is underneath; Power is not remappable.", }, "wii-vertical": { name: "Wii Remote · vertical", style: "switch", asset: "wii-remote-simple.svg", viewBox: [0, 0, 200, 632], minWidth: 240, maxWidth: 260, controls: wiiVertical, - note: "Explicit orientation preview, not detected: B→south, A→east, 1→west, 2→north. D-pad directions stay upright. B is underneath; Power is not remappable.", + note: "B→south, A→east, 1→west, 2→north. D-pad directions stay upright. B is underneath; Power is not remappable.", }, "wii-nunchuk": { name: "Wii Remote + Nunchuk", style: "switch", asset: "wii-remote-nunchuk-simple.svg", @@ -184,8 +189,8 @@ const ControllerLayouts = (() => { left_shoulder: point(420, 455, "1", "1"), right_shoulder: point(420, 505, "2", "2"), west: back("Nunchuk C · rear", "C"), north: back("Nunchuk Z · rear", "Z"), }, - annotations: [{ x: 233, y: 220.4, label: "Stick → RIGHT axes (no click)" }], - note: "Nunchuk stick uses RIGHT axes and has no click. B→south, A→east, 1/2→left/right shoulder, Nunchuk C→west and Z→north. Nunchuk C is a normal face source, not the Switch 2 C extra. Power is not remappable.", + annotations: [{ x: 233, y: 220.4, label: "Stick → LEFT axes (no click)" }], + note: "Nunchuk stick uses LEFT axes with calibrated travel and has no click. B→south, A→east, 1/2→left/right shoulder, Nunchuk C→west and Z→north. Nunchuk C is a normal face source, not the Switch 2 C extra. Power is not remappable.", }, }; return layouts; diff --git a/src/switch_pico_bridge/web/profile_editor.css b/src/switch_pico_bridge/web/profile_editor.css index 9e30b76..018892c 100644 --- a/src/switch_pico_bridge/web/profile_editor.css +++ b/src/switch_pico_bridge/web/profile_editor.css @@ -320,6 +320,15 @@ h4 { font-size: 1rem; } .controller-live[data-state="live"] { color: var(--mint); } .layout-picker { margin-block: 16px 8px; } .layout-picker .select { width: 100%; margin-top: 6px; } +.wii-orientation-control { margin-block: 14px 10px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: rgba(14, 23, 42, 0.6); } +.wii-orientation-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; align-items: center; } +.wii-orientation-actions .select { flex: 1 1 220px; } +.wii-orientation-actions .button { flex: 0 0 auto; } +.wii-orientation-status { margin-top: 8px; font-size: 0.74rem; line-height: 1.5; overflow-wrap: anywhere; color: var(--mint); } +.wii-orientation-status[data-state="idle"] { display: none; } +.wii-orientation-status[data-state="pending"], .wii-orientation-status[data-state="unsupported"] { color: var(--amber); } +.wii-orientation-status[data-state="error"] { color: var(--rose); } +.wii-orientation-status[data-state="locked"] { color: var(--muted); } .controller-viewport { max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 16px 0; scrollbar-color: var(--subtle) var(--surface-2); } .controller-viewport:focus-visible { outline: 2px solid var(--sky); outline-offset: 2px; } .controller-photo-wrap { --diagram-min: 640px; --diagram-max: 900px; position: relative; width: 100%; min-width: var(--diagram-min); max-width: var(--diagram-max); margin: 0 auto; aspect-ratio: var(--diagram-ratio, 800 / 552); } diff --git a/src/switch_pico_bridge/web/profile_editor.html b/src/switch_pico_bridge/web/profile_editor.html index ca6aca5..dbcd5ae 100644 --- a/src/switch_pico_bridge/web/profile_editor.html +++ b/src/switch_pico_bridge/web/profile_editor.html @@ -154,6 +154,18 @@

Preview changes only this editor's source labels and diagram, never firmware topology or saved mappings.

+
diff --git a/src/switch_pico_bridge/web/profile_editor.js b/src/switch_pico_bridge/web/profile_editor.js index dbbfa23..4fa4d8e 100644 --- a/src/switch_pico_bridge/web/profile_editor.js +++ b/src/switch_pico_bridge/web/profile_editor.js @@ -37,6 +37,24 @@ const joyconMode = { readError: "", applyError: "", }; +const wiiOrientation = { + current: null, + selected: "horizontal", + pending: false, + applying: false, + applyError: "", + submittedGeneration: null, + desiredLayout: null, + ownerKey: null, + applyTimer: 0, +}; +function resetWiiOrientationDraft() { + wiiOrientation.pending = false; + wiiOrientation.applyError = ""; + if (wiiOrientation.current) { + wiiOrientation.selected = wiiOrientation.current; + } +} const state = { schema: null, identities: [], @@ -93,6 +111,11 @@ const elements = { controllerLayoutPreview: document.querySelector("#controllerLayoutPreview"), controllerLayoutHelp: document.querySelector("#controllerLayoutHelp"), controllerLayoutNote: document.querySelector("#controllerLayoutNote"), + wiiOrientationControl: document.querySelector("#wiiOrientationControl"), + wiiOrientation: document.querySelector("#wiiOrientationSelect"), + applyWiiOrientation: document.querySelector("#applyWiiOrientationButton"), + wiiOrientationStatus: document.querySelector("#wiiOrientationStatus"), + wiiOrientationHelp: document.querySelector("#wiiOrientationHelp"), controllerPhotoWrap: document.querySelector("#controllerPhotoWrap"), controllerStage: document.querySelector("#controllerStage"), selectedSource: document.querySelector("#selectedSource"), @@ -246,8 +269,9 @@ function syncControllerPresentation() { const reported = live?.source_controls || owner?.source_controls || state.schema.controls; const sources = preview ? Object.keys(layout.controls) : reported.filter(control => layout.generic || Object.hasOwn(layout.controls, control)); - const topologyKnown = !layoutId.startsWith("joycon2-") || live?.layout === layoutId; - const liveDiagram = Boolean(live && !preview && topologyKnown && layoutId !== "wii-remote" && !layout.generic); + const topologyKnown = layoutId !== "wii-remote" && + (!(layoutId.startsWith("joycon2-") || layoutId.startsWith("wii-")) || live?.layout === layoutId); + const liveDiagram = Boolean(live && !preview && topologyKnown && !layout.generic); const style = preview ? layout.style : controller.style || layout.style; const key = [owner?.key, layoutId, style, preview, liveDiagram, Boolean(live), live?.identity_key, controller.model, sources.join(",")].join("|"); if (state.presentationKey === key) return false; @@ -356,6 +380,17 @@ function clearPlaytest(message, stateName = "waiting") { refreshSourceControls(); } renderCapture(); + if (wiiOrientation.applying) { + window.clearTimeout(wiiOrientation.applyTimer); + wiiOrientation.applying = false; + wiiOrientation.submittedGeneration = null; + wiiOrientation.desiredLayout = null; + wiiOrientation.ownerKey = null; + setBusy(false); + } + wiiOrientation.current = null; + resetWiiOrientationDraft(); + renderWiiOrientation(); } function renderPlaytest(sample) { @@ -466,12 +501,48 @@ function renderPlaytest(sample) { elements.playtestHelp.textContent = "Yellow is raw input; blue is the output produced by this unsaved draft."; renderCapture(); + if (wiiOrientation.applying) { + const submittedGen = wiiOrientation.submittedGeneration; + const nextGen = ((submittedGen + 1) >>> 0); + const gen = sample.connection_generation; + const desired = wiiOrientation.desiredLayout; + const matchesOwner = owner && wiiOrientation.ownerKey === owner.key; + if (matchesOwner && sample.layout === desired && (gen === submittedGen || gen === nextGen)) { + window.clearTimeout(wiiOrientation.applyTimer); + wiiOrientation.applying = false; + wiiOrientation.submittedGeneration = null; + wiiOrientation.desiredLayout = null; + wiiOrientation.ownerKey = null; + wiiOrientation.current = desired === "wii-horizontal" ? "horizontal" : "vertical"; + wiiOrientation.selected = wiiOrientation.current; + wiiOrientation.pending = false; + wiiOrientation.applyError = ""; + setBusy(false); + toast(`${label(wiiOrientation.current)} orientation applied.`); + } else if (!matchesOwner || (gen !== submittedGen && gen !== nextGen)) { + window.clearTimeout(wiiOrientation.applyTimer); + wiiOrientation.applying = false; + wiiOrientation.submittedGeneration = null; + wiiOrientation.desiredLayout = null; + wiiOrientation.ownerKey = null; + resetWiiOrientationDraft(); + setBusy(false); + } + } + if (sample.layout === "wii-horizontal" || sample.layout === "wii-vertical") { + const confirmed = sample.layout === "wii-horizontal" ? "horizontal" : "vertical"; + wiiOrientation.current = confirmed; + if (!wiiOrientation.pending && !wiiOrientation.applying) { + wiiOrientation.selected = confirmed; + } + } + renderWiiOrientation(); } async function pollPlaytest() { window.clearTimeout(state.playtestTimer); if ( - document.hidden || state.busy || state.playtestRequestActive || + document.hidden || (state.busy && !wiiOrientation.applying) || state.playtestRequestActive || captureBlocking() || macroCapture.requestActive || !state.schema || !state.profile ) { state.playtestTimer = window.setTimeout(pollPlaytest, 250); @@ -634,6 +705,92 @@ async function refreshJoyconMode(showLoading = false) { } } +function isWiiController(owner = currentOwner(), live = matchingLiveSample() ? state.liveSample : null) { + const vid = owner?.identity?.vendor_id; + const pid = owner?.identity?.product_id; + const isWiiPid = vid === 0x057E && (pid === 0x0306 || pid === 0x0330); + const liveLayout = live?.layout; + const isWiiLayout = liveLayout === "wii-remote" || liveLayout === "wii-nunchuk" || + liveLayout === "wii-horizontal" || liveLayout === "wii-vertical"; + return Boolean(isWiiPid || isWiiLayout); +} + +function renderWiiOrientation() { + const owner = currentOwner(); + const live = matchingLiveSample() ? state.liveSample : null; + const visible = isWiiController(owner, live); + elements.wiiOrientationControl.hidden = !visible; + if (!visible) return; + + const isNunchuk = live?.layout === "wii-nunchuk"; + const isLegacy = live?.layout === "wii-remote"; + const isSupported = Boolean( + live && (live.layout === "wii-horizontal" || live.layout === "wii-vertical") && + owner && owner.index !== 0 + ); + const locked = (state.busy && !wiiOrientation.applying) || captureBlocking() || + macroCapture.requestActive || wiiOrientation.applying || !state.adapterConnected || !live; + if (isNunchuk) { + elements.wiiOrientation.value = "vertical"; + elements.wiiOrientation.disabled = true; + elements.applyWiiOrientation.disabled = true; + elements.wiiOrientationStatus.dataset.state = "locked"; + elements.wiiOrientationStatus.textContent = "Nunchuk attached: orientation is locked to vertical."; + return; + } + + if (!live) { + elements.wiiOrientation.disabled = true; + elements.applyWiiOrientation.disabled = true; + elements.wiiOrientationStatus.dataset.state = "idle"; + elements.wiiOrientationStatus.textContent = ""; + return; + } + + if (isLegacy) { + elements.wiiOrientation.disabled = true; + elements.applyWiiOrientation.disabled = true; + elements.wiiOrientationStatus.dataset.state = "unsupported"; + elements.wiiOrientationStatus.textContent = "Legacy firmware detected. Update firmware to configure Wii orientation."; + return; + } + + if (owner?.index === 0) { + elements.wiiOrientation.disabled = true; + elements.applyWiiOrientation.disabled = true; + elements.wiiOrientationStatus.dataset.state = "unsupported"; + elements.wiiOrientationStatus.textContent = "Default profile owner cannot set orientation. Select the connected Wii Remote."; + return; + } + + elements.wiiOrientation.disabled = locked || !isSupported; + const hasChanged = wiiOrientation.selected !== wiiOrientation.current; + elements.applyWiiOrientation.disabled = elements.wiiOrientation.disabled || + wiiOrientation.applying || !hasChanged; + if (elements.wiiOrientation.value !== wiiOrientation.selected) { + elements.wiiOrientation.value = wiiOrientation.selected; + } + + let statusText = ""; + let statusState = "idle"; + if (wiiOrientation.applying) { + statusState = "pending"; + statusText = `Applying ${wiiOrientation.selected} orientation…`; + } else if (wiiOrientation.applyError) { + statusState = "error"; + statusText = wiiOrientation.applyError; + } else if (hasChanged) { + statusState = "pending"; + statusText = `Selected: ${label(wiiOrientation.selected)} — not applied.`; + } else if (wiiOrientation.current) { + statusState = "idle"; + statusText = `Current: ${label(wiiOrientation.current)}.`; + } + + elements.wiiOrientationStatus.dataset.state = statusState; + elements.wiiOrientationStatus.textContent = statusText; +} + function setBusy(busy) { state.busy = busy; @@ -660,6 +817,7 @@ function setBusy(busy) { button.disabled = busy; }); renderCapture(); + renderWiiOrientation(); } function updateDirtyState() { @@ -965,7 +1123,9 @@ function renderButtonMap() { elements.controllerLayoutHelp.textContent = preview ? `Manual preview only${live ? `; connected input is ${live.controller.model}` : ""}. No topology or saved mapping is changed. Physical highlighting is off.` : live && !topologyKnown - ? "Legacy firmware does not report Joy-Con pair/solo topology. This is an owner reference, not detected solo mode. Choose a preview or update firmware." + ? layoutId === "wii-remote" + ? "Legacy firmware does not report Wii orientation. This reference shows the default horizontal mapping; physical highlighting is off. Choose a preview or update firmware." + : "Legacy firmware does not report Joy-Con pair/solo topology. This is an owner reference, not detected solo mode. Choose a preview or update firmware." : "Auto uses only the selected owner's metadata. Preview changes this editor's source labels and diagram, never firmware topology or saved mappings."; elements.controllerLayoutNote.textContent = !preview && live?.layout === "joycon2-pair" && !live.identity?.is_joycon_pair @@ -1037,6 +1197,7 @@ function renderButtonMap() { elements.selectedControlDescription.textContent = `${sourceAvailable(selected) ? "Source" : "Stored / unavailable source"} ${sourceLabel(selected)} [${selected}] produces ${mappedOutput == null ? "no output" : controlLabel(mappedOutput)}.${sourceAvailable(selected) ? "" : " Its mapping is retained; change the preview to locate it."}`; elements.selectedMapping.innerHTML = buttonOptions(mappedOutput, true, state.schema.output_controls, style); + renderWiiOrientation(); } elements.controllerCanvas.addEventListener("click", event => { @@ -2354,9 +2515,10 @@ elements.identity.addEventListener("change", async () => { if (owner) persistOwnerKey(owner.key); state.profileIndex = 0; state.profileNames = Array(state.schema.profile_capacity).fill(""); + wiiOrientation.current = null; + resetWiiOrientationDraft(); await loadProfile(); }); - elements.refresh.addEventListener("click", async () => { await loadLibrary(true); }); @@ -2395,6 +2557,63 @@ elements.applyJoyconMode.addEventListener("click", async () => { } }); +elements.wiiOrientation.addEventListener("change", () => { + if (elements.wiiOrientation.disabled) return; + wiiOrientation.selected = elements.wiiOrientation.value; + wiiOrientation.pending = wiiOrientation.selected !== wiiOrientation.current; + wiiOrientation.applyError = ""; + renderWiiOrientation(); +}); + +elements.applyWiiOrientation.addEventListener("click", async () => { + const owner = currentOwner(); + const live = matchingLiveSample() ? state.liveSample : null; + if (elements.applyWiiOrientation.disabled || (state.busy && !wiiOrientation.applying) || + captureBlocking() || macroCapture.requestActive || !owner || owner.index === 0 || !live) return; + + const targetOrientation = wiiOrientation.selected; + const targetLayout = targetOrientation === "horizontal" ? "wii-horizontal" : "wii-vertical"; + const gen = live.connection_generation; + wiiOrientation.applying = true; + wiiOrientation.applyError = ""; + wiiOrientation.submittedGeneration = gen; + wiiOrientation.desiredLayout = targetLayout; + wiiOrientation.ownerKey = owner.key; + window.clearTimeout(wiiOrientation.applyTimer); + wiiOrientation.applyTimer = window.setTimeout(() => { + if (wiiOrientation.applying) { + wiiOrientation.applying = false; + wiiOrientation.submittedGeneration = null; + wiiOrientation.desiredLayout = null; + wiiOrientation.ownerKey = null; + wiiOrientation.applyError = "Orientation confirmation timed out. Check controller connection."; + setBusy(false); + renderWiiOrientation(); + } + }, 5000); + setBusy(true); + try { + await api(`/api/identities/${owner.index}/wii-orientation`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + orientation: targetOrientation, + connection_generation: gen, + }), + }); + } catch (error) { + window.clearTimeout(wiiOrientation.applyTimer); + wiiOrientation.applying = false; + wiiOrientation.submittedGeneration = null; + wiiOrientation.desiredLayout = null; + wiiOrientation.ownerKey = null; + wiiOrientation.applyError = `Could not set orientation: ${error.message}`; + toast(wiiOrientation.applyError, true); + setBusy(false); + renderWiiOrientation(); + } + pollPlaytest(); +}); elements.resetDraft.addEventListener("click", () => { if (!state.schema || !window.confirm("Replace this draft with the default profile? Nothing is saved until you choose Save to Pico.")) return; state.profile = clone(state.schema.default_profile); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index 6c776f3..e70a702 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -12,6 +12,7 @@ #include "parser/uni_hid_parser_switch2.h" #include "parser/uni_switch2_haptics.h" #include "parser/uni_switch2_pairing.h" +#include "parser/uni_hid_parser_wii.h" #include "platform/pico/controller_color_config.h" #include "input/switch2_wake.h" #include "pico/critical_section.h" @@ -103,6 +104,13 @@ struct LocalRumbleEvent { }; std::vector local_rumble_events; +struct WiiModeEvent { + uni_hid_device_t* device; + wii_mode_t mode; +}; +std::vector wii_mode_events; +bool wii_dispatch_active = false; + struct CoreStopped {}; @@ -683,6 +691,19 @@ void tracked_state_lock_exit(critical_section_t* lock) { #undef critical_section_enter_blocking #undef critical_section_exit +extern "C" void uni_hid_parser_wii_set_mode( + uni_hid_device_t* device, wii_mode_t mode) { + require(wii_dispatch_active && state_lock_depth == 0, + "Wii parser mode changes must run in the Bluetooth timer without the state lock"); + wii_mode_events.push_back({device, mode}); + device->controller_subtype = mode == WII_MODE_VERTICAL + ? CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL + : CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL; + // The real parser's report reconfiguration can synchronously announce ready. + require(platform_on_device_ready(device) == UNI_ERROR_SUCCESS, + "Wii mode reconfiguration must retain the ready connection"); +} + #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT namespace { std::vector native_timers; @@ -3493,9 +3514,9 @@ void test_rejections() { Bluepad32ControllerLayout layout; }; const LayoutCase layouts[] = { - {CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL, Bluepad32ControllerLayout::kWiiRemote}, - {CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL, Bluepad32ControllerLayout::kWiiRemote}, - {CONTROLLER_SUBTYPE_WIIMOTE_ACCEL, Bluepad32ControllerLayout::kWiiRemote}, + {CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL, Bluepad32ControllerLayout::kWiiHorizontal}, + {CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL, Bluepad32ControllerLayout::kWiiVertical}, + {CONTROLLER_SUBTYPE_WIIMOTE_ACCEL, Bluepad32ControllerLayout::kWiiHorizontal}, {CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK, Bluepad32ControllerLayout::kWiiNunchuk}, {CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL, Bluepad32ControllerLayout::kWiiNunchuk}, {CONTROLLER_SUBTYPE_WII_CLASSIC, Bluepad32ControllerLayout::kUnspecified}, @@ -5415,6 +5436,222 @@ void test_native_second_slot_selection() { } #endif +uni_hid_device_t wii_device(int index) { + auto result = device(index, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + result.vendor_id = 0x057e; + result.product_id = 0x0330; + result.controller_type = CONTROLLER_TYPE_WiiController; + result.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL; + return result; +} + +void dispatch_wii_requests() { + wii_dispatch_active = true; + now_ms += g_rumble_timer.timeout_ms; + g_rumble_timer.handler(&g_rumble_timer); + wii_dispatch_active = false; +} + +void test_wii_orientation() { + start_pairing_backend(); + initialize_runtime_profile_storage(); + auto remote = wii_device(0); + auto ordinary = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + ready_switch2(remote); + ready_switch2(ordinary); + const auto identity = slot_snapshot(0).identity; + const auto ordinary_identity = slot_snapshot(1).identity; + configure_gesture_profile(identity, 2, 37); + configure_gesture_profile(ordinary_identity, 5, 63); + controller_profile_runtime_reset(); + for (uint8_t index = 0; index < 2; ++index) { + controller_profile_runtime_transform( + index, slot_snapshot(index), now_ms, AdapterUsbMode::kXInput); + } + uni_controller_t report{}; + report.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + report.gamepad.buttons = BUTTON_A; + ++now_ms; + platform_on_controller_data(&remote, &report); + platform_on_controller_data(&ordinary, &report); + for (uint8_t index = 0; index < 2; ++index) { + require(controller_profile_runtime_transform( + index, slot_snapshot(index), now_ms, AdapterUsbMode::kXInput) + .state.button_system, + "both controllers need active toggle macros before changing only Wii orientation"); + } + report.gamepad.buttons = BUTTON_Y; + report.gamepad.dpad = DPAD_UP; + report.gamepad.axis_x = 511; + report.gamepad.brake = 1023; + platform_on_controller_data(&remote, &report); + const auto before = slot_snapshot(0); + const auto ordinary_before = slot_snapshot(1); + require(bluepad32_input_backend_capture_start( + 0, before.connection_generation, CaptureOptions{}), + "orientation transition must have an active recorder to retire"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101}); + bluepad32_input_backend_queue_profile_feedback( + 0, before.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed); + const unsigned writes_before = profile_write_attempts; + const size_t observations_before = observed_profile_identity_count; + require(bluepad32_input_backend_set_wii_orientation( + identity, before.connection_generation, true), + "live standalone Wii must accept a vertical request"); + Bluepad32PlaytestSnapshot playtest{}; + bluepad32_input_backend_playtest_snapshot(0, &playtest); + require(wii_mode_events.empty() && + playtest.controller_layout == Bluepad32ControllerLayout::kWiiHorizontal && + playtest.connection_generation == before.connection_generation && + playtest.state.button_north && playtest.state.dpad_up, + "enqueue must not call the parser or claim a new mapping on the management core"); + dispatch_wii_requests(); + bluepad32_input_backend_playtest_snapshot(0, &playtest); + require(wii_mode_events.size() == 1 && wii_mode_events[0].device == &remote && + wii_mode_events[0].mode == WII_MODE_VERTICAL && + playtest.active && + playtest.controller_layout == Bluepad32ControllerLayout::kWiiVertical && + playtest.connection_generation == before.connection_generation + 1u && + controller_identity_equal(playtest.identity, identity) && + playtest.physical_button_mask == 0 && !playtest.state.button_north && + !playtest.state.dpad_up && playtest.state.left_stick_x == 0 && + playtest.state.left_trigger == 0 && playtest.state.motion_sample_count == 0, + "timer dispatch must atomically retire old input and publish the new live mapping epoch"); + Bluepad32CaptureSnapshot capture{}; + require(bluepad32_input_backend_capture_page(0, 0, &capture) && + capture.state == CaptureState::kDisconnected && + !controller_profile_runtime_transform( + 0, slot_snapshot(0), now_ms, AdapterUsbMode::kXInput).state.button_system && + controller_profile_runtime_transform( + 1, slot_snapshot(1), now_ms, AdapterUsbMode::kXInput).state.button_system && + slot_snapshot(1).connection_generation == ordinary_before.connection_generation && + remote.last_high == 0 && remote.last_low == 0 && + !bluepad32_input_backend_toggle_motion(0, before.connection_generation), + "mapping changes must cancel old macro/capture/hotkey/feedback epochs without disturbing peers"); + report.gamepad = {}; + report.gamepad.buttons = BUTTON_B; + platform_on_controller_data(&remote, &report); + require(slot_snapshot(0).state.button_east && !slot_snapshot(0).state.button_north, + "fresh input must use the new mapping without reviving old controls"); + const uint32_t vertical_generation = playtest.connection_generation; + require(bluepad32_input_backend_set_wii_orientation( + identity, vertical_generation, false), + "current vertical mapping must be reversible without reconnecting"); + dispatch_wii_requests(); + bluepad32_input_backend_playtest_snapshot(0, &playtest); + require(playtest.controller_layout == Bluepad32ControllerLayout::kWiiHorizontal && + playtest.connection_generation == vertical_generation + 1u && + wii_mode_events.size() == 2 && wii_mode_events[1].mode == WII_MODE_HORIZONTAL, + "horizontal selection must return the real parser and metadata to horizontal"); + const uint32_t horizontal_generation = playtest.connection_generation; + require(bluepad32_input_backend_set_wii_orientation( + identity, horizontal_generation, false), + "reselection must still reach a parser with a potentially deferred opposite choice"); + dispatch_wii_requests(); + require(wii_mode_events.size() == 3 && wii_mode_events[2].mode == WII_MODE_HORIZONTAL && + slot_snapshot(0).connection_generation == horizontal_generation + 1u && + profile_write_attempts == writes_before && + observed_profile_identity_count == observations_before && + device_disconnect_calls == 0, + "reselection and orientation roundtrip must preserve profiles, flash and physical connections"); + require_active_profile(identity, 2, 37); + require_active_profile(ordinary_identity, 5, 63); +} + +void test_wii_orientation_races() { + auto remote = wii_device(0); + const auto identity = identity_for_device(&remote); + require(!bluepad32_input_backend_set_wii_orientation(identity, 0, true), + "orientation requests must reject an uninitialized backend"); + start_pairing_backend(); + platform_on_device_connected(&remote); + require(!bluepad32_input_backend_set_wii_orientation( + identity, slot_snapshot(0).connection_generation, true), + "orientation requests must reject connections that are not ready"); + require(platform_on_device_ready(&remote) == UNI_ERROR_SUCCESS, + "Wii race fixture must become ready"); + const auto before = slot_snapshot(0); + auto ordinary = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + ordinary.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL; + ready_switch2(ordinary); + auto unstable = wii_device(2); + unstable.conn.protocol = UNI_BT_CONN_PROTOCOL_NONE; + gap_connection_types[unstable.conn.handle] = GAP_CONNECTION_INVALID; + ready_switch2(unstable); + auto wrong_identity = identity; + wrong_identity.product_id ^= 1u; + require(!bluepad32_input_backend_set_wii_orientation( + identity, before.connection_generation - 1u, true) && + !bluepad32_input_backend_set_wii_orientation( + wrong_identity, before.connection_generation, true) && + !bluepad32_input_backend_set_wii_orientation( + slot_snapshot(1).identity, slot_snapshot(1).connection_generation, true) && + !bluepad32_input_backend_set_wii_orientation( + slot_snapshot(2).identity, slot_snapshot(2).connection_generation, true), + "stale generation, wrong owner, non-Wii and global fallback must not target a parser"); + const uni_controller_subtype_t extensions[] = { + CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK, + CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL, + CONTROLLER_SUBTYPE_WII_CLASSIC, + }; + for (auto subtype : extensions) { + remote.controller_subtype = subtype; + require(!bluepad32_input_backend_set_wii_orientation( + identity, before.connection_generation, false), + "Nunchuk and other extension mappings cannot be changed by standalone orientation"); + } + remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL; + require(bluepad32_input_backend_set_wii_orientation( + identity, before.connection_generation, true), + "race fixture must enqueue while still standalone"); + remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK; + dispatch_wii_requests(); + Bluepad32PlaytestSnapshot playtest{}; + bluepad32_input_backend_playtest_snapshot(0, &playtest); + require(wii_mode_events.empty() && + playtest.controller_layout == Bluepad32ControllerLayout::kWiiNunchuk && + playtest.connection_generation == before.connection_generation, + "extension attachment before dispatch must reject the queued request without changing topology"); + remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL; + dispatch_wii_requests(); + require(wii_mode_events.empty(), + "a request rejected during attachment must not revive when the Nunchuk detaches"); + require(bluepad32_input_backend_set_wii_orientation( + identity, before.connection_generation, true), + "standalone connection must accept another request after detach"); + const WiiOrientationRequest stale = g_slots[0].pending_wii_orientation; + platform_on_device_disconnected(&remote); + remote = wii_device(0); + ready_switch2(remote); + dispatch_wii_requests(); + require(wii_mode_events.empty() && + !bluepad32_input_backend_set_wii_orientation( + identity, before.connection_generation, true), + "physical object reuse with the same identity must discard old pending requests and reject its old epoch"); + // Exercise the dispatch guard even if an already accepted old envelope + // survives a future mailbox refactor. + g_slots[0].pending_wii_orientation = stale; + g_slots[0].wii_orientation_pending = true; + dispatch_wii_requests(); + g_slots[0].pending_wii_orientation = { + wrong_identity, slot_snapshot(0).connection_generation, true}; + g_slots[0].wii_orientation_pending = true; + dispatch_wii_requests(); + require(wii_mode_events.empty() && + slot_snapshot(0).connection_generation == before.connection_generation + 1u, + "dispatch must independently revalidate both connection generation and identity"); + const auto replacement = slot_snapshot(0); + require(bluepad32_input_backend_set_wii_orientation( + replacement.identity, replacement.connection_generation, true) && + bluepad32_input_backend_set_wii_orientation( + replacement.identity, replacement.connection_generation, false), + "rapid standalone choices must coalesce before dispatch"); + dispatch_wii_requests(); + require(wii_mode_events.size() == 1 && wii_mode_events[0].mode == WII_MODE_HORIZONTAL && + slot_snapshot(0).connection_generation == replacement.connection_generation + 1u, + "coalescing back to the current mapping must not apply an obsolete intermediate choice"); +} + } // namespace int main(int argc, char** argv) { @@ -5430,6 +5667,14 @@ int main(int argc, char** argv) { return 0; } #endif + if (scenario == "wii-orientation") { + test_wii_orientation(); + return 0; + } + if (scenario == "wii-orientation-races") { + test_wii_orientation_races(); + return 0; + } if (scenario == "switch2-hd-pro") { test_switch2_hd_pro(); } else if (scenario == "switch2-gesture-timing") { diff --git a/tests/bluepad32_imu_normalization_test.cpp b/tests/bluepad32_imu_normalization_test.cpp index 96775a0..b152e9f 100644 --- a/tests/bluepad32_imu_normalization_test.cpp +++ b/tests/bluepad32_imu_normalization_test.cpp @@ -75,14 +75,6 @@ void set_accel_calibration( } } -void test_wii_accelerometer() { - int32_t output[3]{}; - uni_imu_normalize_wii_accel(100, -50, 25, output); - expect(output[0] == -8192 && output[1] == 2048 && - output[2] == -4096, - "Wii accelerometer scale or SDL axis mapping is wrong"); -} - void test_zcm1_calibration_and_normalization() { constexpr auto model = UNI_PSMOVE_IMU_MODEL_ZCM1; std::array blob{}; @@ -200,7 +192,6 @@ void test_uncalibrated_psmove_is_suppressed() { } // namespace int main() { - test_wii_accelerometer(); test_zcm1_calibration_and_normalization(); test_zcm2_calibration_and_normalization(); test_uncalibrated_psmove_is_suppressed(); diff --git a/tests/bluepad32_native_stubs/parser/uni_hid_parser_wii.h b/tests/bluepad32_native_stubs/parser/uni_hid_parser_wii.h new file mode 100644 index 0000000..78d6d47 --- /dev/null +++ b/tests/bluepad32_native_stubs/parser/uni_hid_parser_wii.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum wii_flags { + WII_MODE_HORIZONTAL = 0, + WII_MODE_VERTICAL = 1, + WII_MODE_ACCEL = 2, +} wii_mode_t; + +void uni_hid_parser_wii_set_mode(uni_hid_device_t* device, wii_mode_t mode); + +#ifdef __cplusplus +} +#endif diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index eda3d99..bbc07b2 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -83,6 +83,11 @@ enum { MISC_BUTTON_CAPTURE = 1 << 3, }; +typedef enum { + CONTROLLER_TYPE_UnknownController = 0, + CONTROLLER_TYPE_WiiController = 35, +} uni_controller_type_t; + typedef enum { CONTROLLER_SUBTYPE_NONE = 0, CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL, @@ -163,6 +168,7 @@ inline uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer) struct uni_hid_device_s { uint16_t vendor_id; uint16_t product_id; + uni_controller_type_t controller_type; uni_controller_subtype_t controller_subtype; uni_bt_conn_t conn; int idx; diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index e724a53..7a48ab1 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -99,6 +99,8 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: subprocess.run(command, check=True, cwd=root) subprocess.run([str(executable), "xbox-rumble"], check=True, cwd=root) for scenario in ( + "wii-orientation", + "wii-orientation-races", "switch2-individual-core-start", "switch2-individual-forward", "switch2-individual-reverse", diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 1cfd0f4..bd96608 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -2396,6 +2396,9 @@ def test_playtest_layout_and_extra_inputs_preserve_legacy_firmware() -> None: assert current.to_json_object()["buttons"] == ["south", "dpad_up", "dpad_right"] assert parse(payload[:55], 3) == replace(current, layout=None) assert parse(payload[:54], 2) == replace(current, extra_buttons=0, layout=None) + assert parse(payload, 5) == current + assert parse(payload[:55] + b"\x06", 5).layout == "wii-horizontal" + assert parse(payload[:55] + b"\x07", 5).layout == "wii-vertical" assert parse(payload[:55] + b"\x00", 4).layout is None for data, schema in ( (payload[:54] + b"\x80", 3), @@ -2406,7 +2409,9 @@ def test_playtest_layout_and_extra_inputs_preserve_legacy_firmware() -> None: (payload, 3), (payload[:55], 4), (payload + b"\x00", 4), - (payload, 5), + (payload[:55] + b"\x08", 5), + (payload[:55], 5), + (payload, 6), ): with pytest.raises(config_manager.ConfigManagerError): parse(data, schema) diff --git a/tests/test_profile_web.py b/tests/test_profile_web.py index eb0b40a..f411264 100644 --- a/tests/test_profile_web.py +++ b/tests/test_profile_web.py @@ -436,7 +436,14 @@ def test_wii_pid_does_not_claim_a_remote_or_extension_without_live_metadata( status, listing = request_json(f"{base_url}/api/profiles") assert status == 200 assert listing["identities"][1]["controller"]["layout"] == "generic" - for code, expected in ((0, "generic"), (4, "wii-remote"), (5, "wii-nunchuk"), (0, "generic")): + for code, expected in ( + (0, "generic"), + (4, "wii-remote"), + (5, "wii-nunchuk"), + (6, "wii-horizontal"), + (7, "wii-vertical"), + (0, "generic"), + ): device.playtest_layout = code status, sample = request_json(f"{base_url}/api/profiles/0/1/playtest") assert status == 200 @@ -445,7 +452,6 @@ def test_wii_pid_does_not_claim_a_remote_or_extension_without_live_metadata( assert set(sample["source_controls"]).isdisjoint(config_manager.EXTRA_BUTTONS) assert ("left_shoulder" in sample["source_controls"]) == (code == 5) - def test_editor_reads_writes_and_activates_profiles_atomically( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -859,4 +865,77 @@ def test_recorder_accepts_first_connection_generation_zero( }, ) assert status == 200 and stopped["state_name"] == "stopped" - assert stopped["steps"][0]["duration_ms"] == 100 + + + +def test_wii_orientation_endpoint_validation_and_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + with running_server(monkeypatch, device) as (base_url, token): + # Missing token + status, _ = request_json( + f"{base_url}/api/identities/1/wii-orientation", + method="POST", + value={"orientation": "horizontal", "connection_generation": 1}, + ) + assert status == 403 + + # Out of bounds identity index + status, _ = request_json( + f"{base_url}/api/identities/99/wii-orientation", + method="POST", + token=token, + value={"orientation": "horizontal", "connection_generation": 1}, + ) + assert status == 400 + + # Invalid / missing fields + for invalid_body in ( + {}, + {"orientation": "diagonal", "connection_generation": 1}, + {"orientation": "horizontal"}, + {"connection_generation": 1}, + {"orientation": "horizontal", "connection_generation": -1}, + {"orientation": "horizontal", "connection_generation": "1"}, + {"orientation": 123, "connection_generation": 1}, + ): + status, _ = request_json( + f"{base_url}/api/identities/1/wii-orientation", + method="POST", + token=token, + value=invalid_body, + ) + assert status == 400 + + # ConfigManagerError from config_manager + def failing_config(*args: Any, **kwargs: Any) -> None: + raise config_manager.ConfigManagerError("firmware rejected orientation") + + monkeypatch.setattr( + config_manager, "set_wii_orientation", failing_config + ) + status, err = request_json( + f"{base_url}/api/identities/1/wii-orientation", + method="POST", + token=token, + value={"orientation": "horizontal", "connection_generation": 1}, + ) + assert status == 400 + assert "error" in err + + # USB error from config_manager + def usb_failing_config(*args: Any, **kwargs: Any) -> None: + raise usb.core.USBError("USB pipe error") + + monkeypatch.setattr( + config_manager, "set_wii_orientation", usb_failing_config + ) + status, err = request_json( + f"{base_url}/api/identities/1/wii-orientation", + method="POST", + token=token, + value={"orientation": "horizontal", "connection_generation": 1}, + ) + assert status == 503 + assert "error" in err diff --git a/tests/test_wii_parser_native.py b/tests/test_wii_parser_native.py new file mode 100644 index 0000000..1fb17c5 --- /dev/null +++ b/tests/test_wii_parser_native.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) + +from prepare_bluepad32 import prepare_bluepad32 + + +def test_wii_parser_native_motion_and_lifecycle(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("cc") or shutil.which("gcc") + assert compiler is not None, "a host C compiler is required" + prepared = prepare_bluepad32( + root / "external" / "bluepad32", + root / "patches" / "bluepad32-sdl3-imu.patch", + tmp_path / "bluepad32-src", + ) + component = prepared / "src" / "components" / "bluepad32" + executable = tmp_path / "wii_parser_native_test" + subprocess.run( + [ + compiler, + "-std=gnu11", + "-O1", + "-Wall", + "-Wextra", + "-ffunction-sections", + "-fdata-sections", + "-DENABLE_BLE", + "-DENABLE_CLASSIC", + f"-I{root / 'tests' / 'switch_parser_native_stubs'}", + f"-I{root / 'bluepad32_config'}", + f"-I{component / 'include'}", + str(root / "tests" / "wii_parser_native_test.c"), + str(component / "parser" / "uni_hid_parser_wii.c"), + str(component / "controller" / "uni_gamepad.c"), + "-Wl,--gc-sections", + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 9aaac89..f7f86cb 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -567,7 +567,14 @@ void test_profile_vendor_requests() { control_payload[kResponseHeaderSize + 55] == 3, "profile playtest response lost live controller state"); current_playtest[2].controller_layout = - static_cast(6); + Bluepad32ControllerLayout::kWiiVertical; + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload[10] == 5 && + control_payload[kResponseHeaderSize + 55] == 7, + "Wii orientation must be available through schema-5 playtest"); + current_playtest[2].controller_layout = + static_cast(8); uint8_t invalid_playtest[kMaximumResponseSize]{}; require(encode_profile_playtest( 2, current_playtest[2], invalid_playtest, sizeof(invalid_playtest)) == 0, @@ -629,6 +636,25 @@ void test_profile_vendor_requests() { require(identify_requested, "controller Identify request was not dispatched"); + std::vector orientation = identify; + orientation.resize(CONTROLLER_IDENTITY_ENCODED_SIZE + 5); + write_u32(&orientation, CONTROLLER_IDENTITY_ENCODED_SIZE, 0x11223344); + orientation.back() = 1; + perform_out(Operation::kWiiOrientation, orientation); + orientation.back() = 2; + next_out_payload = make_request(Operation::kWiiOrientation, orientation); + request = setup_request(Operation::kWiiOrientation, TUSB_DIR_OUT, + static_cast(next_out_payload.size())); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + !usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_ACK, &request), + "invalid Wii orientation was treated as vertical"); + request.wLength--; + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "truncated Wii orientation request was accepted"); + current_profile_transaction = {}; current_profile_transaction.metadata.state = ProfileServiceState::kReady; @@ -1131,6 +1157,11 @@ bool bluepad32_input_backend_identify( return true; } +bool bluepad32_input_backend_set_wii_orientation( + const ControllerIdentity&, uint32_t, bool) { + return true; +} + ConfigurationTransactionStatus profile_service_reset( uint32_t transaction_id, const ControllerIdentity& identity, uint8_t selected_profile) { diff --git a/tests/wii_parser_native_test.c b/tests/wii_parser_native_test.c new file mode 100644 index 0000000..c3a910e --- /dev/null +++ b/tests/wii_parser_native_test.c @@ -0,0 +1,913 @@ +#include +#include +#include +#include +#include + +#include "parser/uni_hid_parser_wii.h" +#include "uni_hid_device.h" + +// The real staged parser and gamepad definitions are linked. Only transport and +// the ready notification are substituted; no parser-private state is inspected. +// Wire fixtures follow WiiBrew's Wiimote and Wii Motion Plus protocol pages. +typedef struct { + uint16_t len; + uint8_t bytes[32]; +} transaction_t; + +typedef enum { EXT_NONE, EXT_NUNCHUK, EXT_WII_U_PRO } extension_t; + +typedef struct { + uni_hid_device_t device; + transaction_t sent[128]; + unsigned sent_count; + unsigned cursor; + unsigned ready_count; + extension_t extension; + bool motionplus; + bool mp_initialized; + bool mp_active; + bool status_before_activation_ack; + bool status_after_activation_ack; + bool deactivation_status_before_ack; + bool deactivation_status_after_ack; + unsigned initializing_extension_reads; + unsigned transient_extension_read_errors; + uint8_t extension_identity[6]; + bool fail_accel_reads; + uint8_t connection_buttons; + uint8_t activation_mode; + uint8_t report_mode; + uint32_t fail_read_address; + uint32_t fail_write_address; + uint8_t accel_calibration[20]; + uint8_t mp_calibration[32]; + uint8_t nunchuk_calibration[16]; +} fixture_t; + +static fixture_t f; + +void uni_hid_device_send_intr_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { + assert(d == &f.device); + assert(f.sent_count < 128 && len >= 2 && len <= sizeof(f.sent[0].bytes)); + assert(report[0] == 0xa2); + transaction_t* transaction = &f.sent[f.sent_count++]; + transaction->len = len; + memcpy(transaction->bytes, report, len); +} + +bool uni_hid_device_set_ready_complete(uni_hid_device_t* d) { + assert(d == &f.device); + ++f.ready_count; + d->conn.state = UNI_BT_CONN_STATE_DEVICE_READY; + return true; +} + +void uni_log(const char* fmt, ...) { (void)fmt; } +void printf_hexdump(const void* data, int len) { (void)data; (void)len; } + +static void put_be16(uint8_t* output, uint16_t value) { + output[0] = value >> 8; + output[1] = value; +} + +static void put_le16(uint8_t* output, uint16_t value) { + output[0] = value; + output[1] = value >> 8; +} + +static void pack_accel_calibration(uint8_t* output, const uint16_t values[3]) { + output[3] = 0; + for (unsigned axis = 0; axis < 3; ++axis) { + output[axis] = values[axis] >> 2; + output[3] |= (values[axis] & 3) << (4 - 2 * axis); + } +} + +static void update_mp_checksum(void) { + // CRC32 covers fast[0..13] followed by slow[0..13], not the CRC slots. + uint32_t crc = UINT32_MAX; + for (unsigned block = 0; block < 2; ++block) { + for (unsigned byte = 0; byte < 14; ++byte) { + crc ^= f.mp_calibration[block * 16 + byte]; + for (unsigned bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ ((crc & 1) ? UINT32_C(0xedb88320) : 0); + } + } + crc ^= UINT32_MAX; + put_be16(&f.mp_calibration[14], crc >> 16); + 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); + uint8_t checksum = 0x55; + for (unsigned i = 0; i < 14; ++i) + checksum += f.nunchuk_calibration[i]; + f.nunchuk_calibration[14] = checksum; + f.nunchuk_calibration[15] = checksum + 0x55; +} + +static void reset_fixture(uint16_t product_id, bool motionplus, extension_t extension) { + memset(&f, 0, sizeof(f)); + f.device.vendor_id = 0x057e; + f.device.product_id = product_id; + f.device.conn.connected = true; + f.device.conn.interrupt_cid = 0x40; + f.extension = extension; + f.motionplus = motionplus; + f.fail_read_address = UINT32_MAX; + f.fail_write_address = UINT32_MAX; + 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}); + + // Non-default centers, unequal spans, and nonzero packed low bits catch + // nominal 0x200/100-count calibration and high-byte-only decoding. + const uint16_t zero[3] = {510, 506, 514}; + const uint16_t one_g[3] = {614, 634, 594}; + pack_accel_calibration(f.accel_calibration, zero); + pack_accel_calibration(&f.accel_calibration[4], one_g); + uint8_t checksum = 0x55; + for (unsigned byte = 0; byte < 9; ++byte) + checksum += f.accel_calibration[byte]; + f.accel_calibration[9] = checksum; + memcpy(&f.accel_calibration[10], f.accel_calibration, 10); + + // MP words have 16-bit precision; reports have 14 bits. Factory yaw and + // pitch spans are negative, whereas roll is positive. Fast and slow also + // deliberately have different centers and sensitivities on every axis. + const uint16_t zero_fast[3] = {32000, 31600, 31200}; + const uint16_t scale_fast[3] = {22400, 36400, 28000}; + const uint16_t zero_slow[3] = {32400, 32000, 31600}; + const uint16_t scale_slow[3] = {21600, 37400, 28000}; + for (unsigned axis = 0; axis < 3; ++axis) { + put_be16(&f.mp_calibration[2 * axis], zero_fast[axis]); + put_be16(&f.mp_calibration[6 + 2 * axis], scale_fast[axis]); + put_be16(&f.mp_calibration[16 + 2 * axis], zero_slow[axis]); + put_be16(&f.mp_calibration[22 + 2 * axis], scale_slow[axis]); + } + f.mp_calibration[12] = 200; // Fast calibration is at 1200 degrees/s. + f.mp_calibration[28] = 45; // Slow calibration is at 270 degrees/s. + f.mp_calibration[13] = 0x31; + f.mp_calibration[29] = 0x72; + update_mp_checksum(); +} + +static void feed(const uint8_t* report, uint16_t len) { + uni_hid_parser_wii_init_report(&f.device); + uni_hid_parser_wii_parse_input_report(&f.device, report, len); +} + +static void send_status(bool extension_present) { + const uint8_t report[] = {0x20, f.connection_buttons, 0, extension_present ? 2 : 0, 0, 0, 0xc0}; + feed(report, sizeof(report)); +} + +static void send_ack(uint8_t command, uint8_t error) { + const uint8_t report[] = {0x22, 0, 0, command, error}; + feed(report, sizeof(report)); +} + +static uint32_t transaction_address(const transaction_t* transaction) { + assert(transaction->len >= 8); + const uint8_t* bytes = transaction->bytes; + return ((uint32_t)bytes[3] << 16) | ((uint32_t)bytes[4] << 8) | bytes[5]; +} + +static void send_read_reply(uint16_t address, const uint8_t* data, uint8_t size, uint8_t error) { + assert(size >= 1 && size <= 16); + uint8_t report[22] = {0x21, 0, 0, (uint8_t)(((size - 1) << 4) | error)}; + put_be16(&report[4], address); + if (!error) + memcpy(&report[6], data, size); + feed(report, sizeof(report)); +} + +static bool read_wire_byte(uint8_t space, uint32_t address, uint8_t* value) { + if (space == 0) { + if (address >= 0x16 && address < 0x2a && !f.fail_accel_reads) { + *value = f.accel_calibration[address - 0x16]; + return true; + } + return false; + } + assert(space == 4); + uint32_t bank = address >> 16; + uint16_t offset = address; + if ((bank == 0xa6 && f.motionplus && !f.mp_active) || + (bank == 0xa4 && f.mp_active)) { + if (offset >= 0x20 && offset < 0x40) { + *value = f.mp_calibration[offset - 0x20]; + return true; + } + if (offset >= 0xfa && offset <= 0xff) { + // RVL-CNT-01-TR hardware returns an A4 signature even when read + // through inactive A600FA; the ID is not an echo of the read bank. + const bool integrated = f.device.product_id == 0x0330; + const uint8_t identity[] = {integrated ? 1 : 0, 0, integrated ? 0xa4 : (uint8_t)bank, + 0x20, f.mp_active ? f.activation_mode : 0, 5}; + *value = identity[offset - 0xfa]; + return true; + } + } + if (bank == 0xa4 && !f.mp_active && f.extension == EXT_NUNCHUK && offset >= 0x20 && offset < 0x30) { + *value = f.nunchuk_calibration[offset - 0x20]; + return true; + } + if (bank == 0xa4 && !f.mp_active && f.extension != EXT_NONE && offset >= 0xfa && offset <= 0xff) { + uint8_t identity[6]; + memcpy(identity, f.extension_identity, sizeof(identity)); + if (f.extension == EXT_WII_U_PRO) { + identity[4] = 1; + identity[5] = 0x20; + } + *value = identity[offset - 0xfa]; + return true; + } + return false; +} + +static void answer_transaction(const transaction_t* transaction) { + const uint8_t* bytes = transaction->bytes; + switch (bytes[1]) { + case 0x11: // LEDs do not acknowledge unless explicitly requested. + if (bytes[2] & 2) + send_ack(0x11, 0); + break; + case 0x12: + assert(transaction->len == 4); + f.report_mode = bytes[3]; + if (bytes[2] & 2) + send_ack(0x12, 0); + break; + case 0x15: + send_status(f.extension != EXT_NONE || f.mp_active); + break; + case 0x16: { + uint32_t address = transaction_address(transaction); + assert((bytes[2] & 0xfe) == 4); + assert(bytes[6] == 1 && transaction->len >= 8); + uint8_t error = 0; + bool deactivated = false; + bool activated = false; + if (address == f.fail_write_address || ((address >> 16) == 0xa6 && !f.motionplus)) { + error = 7; + } else if (address == 0xa600f0) { + assert(bytes[7] == 0x55); + f.mp_initialized = true; + } else if (address == 0xa600fe) { + // Initialization before activation and the passthrough selector + // are protocol requirements, not a pinned parser FSM sequence. + assert(f.mp_initialized); + assert(bytes[7] == 4 || bytes[7] == 5); + f.mp_active = true; + f.activation_mode = bytes[7]; + activated = true; + if (f.status_before_activation_ack) + send_status(true); + } else if (address == 0xa400f0) { + assert(bytes[7] == 0x55); + deactivated = f.mp_active; + f.mp_active = false; + if (deactivated && f.deactivation_status_before_ack) { + send_status(false); + send_status(true); + } + } else { + assert((address == 0xa400fb || address == 0xa600fb) && bytes[7] == 0); + } + send_ack(0x16, error); + if (deactivated && f.deactivation_status_after_ack) { + send_status(false); + send_status(true); + } + if (activated && f.status_after_activation_ack) + send_status(true); + break; + } + case 0x17: { + uint32_t address = transaction_address(transaction); + uint16_t remaining = ((uint16_t)bytes[6] << 8) | bytes[7]; + assert(remaining && remaining <= 32); + if (address == 0xa400fa && !f.mp_active && f.extension != EXT_NONE) { + if (f.transient_extension_read_errors) { + --f.transient_extension_read_errors; + send_read_reply(address, NULL, 1, 7); + break; + } + if (f.initializing_extension_reads) { + --f.initializing_extension_reads; + const uint8_t initializing[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + send_read_reply(address, initializing, sizeof(initializing), 0); + break; + } + } + if (address == f.fail_read_address) { + send_read_reply(address, NULL, 1, 7); + break; + } + while (remaining) { + uint8_t data[16]; + uint8_t size = remaining > 16 ? 16 : remaining; + bool available = true; + for (unsigned i = 0; i < size; ++i) + available &= read_wire_byte(bytes[2] & 0xfe, address + i, &data[i]); + send_read_reply(address, data, size, available ? 0 : 7); + if (!available) + break; + address += size; + remaining -= size; + } + break; + } + default: + assert(!"unexpected Wii output report"); + } +} + +static void finish_setup(void) { + // Output may be emitted recursively by each reply. Bound the wire dialogue + // so retry loops fail instead of hanging pytest. + for (unsigned step = 0; step < 128 && f.cursor < f.sent_count; ++step) { + transaction_t transaction = f.sent[f.cursor++]; + answer_transaction(&transaction); + } + assert(f.cursor == f.sent_count); + assert(f.ready_count == 1); + assert(f.report_mode != 0); +} + +static void connect_device(void) { + uni_hid_parser_wii_setup(&f.device); + finish_setup(); +} + +static transaction_t stop_at_read(uint32_t address) { + for (unsigned step = 0; step < 128 && f.cursor < f.sent_count; ++step) { + transaction_t transaction = f.sent[f.cursor++]; + if (transaction.bytes[1] == 0x17 && transaction_address(&transaction) == address) + return transaction; + answer_transaction(&transaction); + } + assert(!"required Wii memory request was not sent"); + return (transaction_t){0}; +} + +static void put_accel_report(uint8_t* report, uint16_t x, uint16_t y, uint16_t z) { + assert(!(y & 1) && !(z & 1)); // Y/Z wire samples have no least-significant bit. + report[1] |= (x & 3) << 5; + report[2] |= ((y & 2) << 4) | ((z & 2) << 5); + report[3] = x >> 2; + report[4] = y >> 2; + report[5] = z >> 2; +} + +static void put_gyro_report(uint8_t* extension, uint16_t yaw, uint16_t roll, uint16_t pitch, + bool yaw_slow, bool roll_slow, bool pitch_slow) { + extension[0] = yaw; + extension[1] = roll; + extension[2] = pitch; + extension[3] = ((yaw >> 8) << 2) | (yaw_slow ? 2 : 0) | (pitch_slow ? 1 : 0); + extension[4] = ((roll >> 8) << 2) | (roll_slow ? 2 : 0) | (f.extension != EXT_NONE ? 1 : 0); + extension[5] = ((pitch >> 8) << 2) | 2; +} + +static void send_motion(uint16_t yaw, uint16_t roll, uint16_t pitch, + bool yaw_slow, bool roll_slow, bool pitch_slow) { + uint8_t report[22] = {0x35, 1, 0x0a}; // Left + A + 1, no connection-time A needed. + put_accel_report(report, 614, 442, 534); + put_gyro_report(&report[6], yaw, roll, pitch, yaw_slow, roll_slow, pitch_slow); + feed(report, sizeof(report)); +} + +static void expect_vector(const int32_t vector[3], int32_t x, int32_t y, int32_t z) { + assert(vector[0] == x); + assert(vector[1] == y); + assert(vector[2] == z); +} + +static void expect_accel(void) { + // Native (+1g, -0.5g, +0.25g) maps to SDL (-X, +Z, +Y). + expect_vector(f.device.controller.gamepad.accel, -8192, 2048, -4096); +} + +static void send_core_and_accel(void) { + uint8_t report[22] = {f.report_mode, 1, 0x0a}; + assert(f.report_mode == 0x30 || f.report_mode == 0x31 || f.report_mode == 0x35); + uint16_t len = f.report_mode == 0x35 ? 22 : f.report_mode == 0x31 ? 6 : 3; + if (len >= 6) + put_accel_report(report, 614, 442, 534); + feed(report, len); + assert(f.device.controller.gamepad.dpad == DPAD_DOWN); + assert(f.device.controller.gamepad.buttons == (BUTTON_X | BUTTON_A)); +} + +static void send_nunchuk_controls(bool passthrough) { + uint8_t report[22] = {0x35}; + put_accel_report(report, 614, 442, 534); + const uint8_t extension[6] = {160, 192, 128, 128, passthrough ? 129 : 128, passthrough ? 4 : 1}; + memcpy(report + 6, extension, sizeof(extension)); + feed(report, sizeof(report)); + assert(f.device.controller.gamepad.buttons == BUTTON_X); + assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == -341); + assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0); + assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL); + expect_accel(); +} + +static void integrated_motionplus_calibration_and_slow_bits(void) { + reset_fixture(0x0330, true, EXT_NONE); + connect_device(); + assert(f.mp_active && f.activation_mode == 4 && f.report_mode == 0x35); + + // Each axis is the only slow axis in one packet. A shared slow flag or + // swapped e[3]/e[4] bits cannot accidentally pass all three vectors. + send_motion(7760, 8200, 7560, false, true, false); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024); + expect_accel(); + assert(f.device.controller.gamepad.dpad == DPAD_DOWN); + assert(f.device.controller.gamepad.buttons == (BUTTON_X | BUTTON_A)); + + send_motion(8300, 7660, 7560, true, false, false); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, 20 * 1024, -240 * 1024); + send_motion(7760, 7660, 8100, false, false, true); + expect_vector(f.device.controller.gamepad.gyro, -60 * 1024, -120 * 1024, -240 * 1024); +} + +static void external_motionplus_without_extension_status(void) { + reset_fixture(0x0306, true, EXT_NONE); + f.status_before_activation_ack = true; + connect_device(); + assert(f.mp_active && f.activation_mode == 4 && f.report_mode == 0x35); + send_motion(7760, 8200, 7560, false, true, false); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024); + expect_accel(); +} + +static void absent_motionplus_keeps_calibrated_remote(void) { + reset_fixture(0x0306, false, EXT_NONE); + connect_device(); + assert(!f.mp_active && f.report_mode == 0x31); + send_core_and_accel(); + expect_accel(); + expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0); +} + +static void setup_read_and_write_errors_leave_buttons_ready(void) { + reset_fixture(0x0330, true, EXT_NONE); + f.fail_read_address = 0xa60030; + connect_device(); + send_core_and_accel(); + expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0); + + reset_fixture(0x0330, true, EXT_NONE); + f.fail_write_address = 0xa600fe; + connect_device(); + assert(!f.mp_active); + send_core_and_accel(); + expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0); + + reset_fixture(0x0306, false, EXT_NUNCHUK); + f.fail_write_address = 0xa400f0; + connect_device(); + // A failed reset ACK is not an extension identity. If the subsequent read + // succeeds, preserve the real Nunchuk rather than forcing standalone mode. + send_nunchuk_controls(false); + expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0); +} + +static void failed_calibration_never_fabricates_motion(void) { + reset_fixture(0x0330, true, EXT_NONE); + f.accel_calibration[9] ^= 1; + f.accel_calibration[19] ^= 1; + connect_device(); + send_motion(7760, 8200, 7560, false, true, false); + expect_vector(f.device.controller.gamepad.accel, 0, 0, 0); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024); + assert(f.device.controller.gamepad.buttons == (BUTTON_X | BUTTON_A)); + + reset_fixture(0x0330, true, EXT_NONE); + f.mp_calibration[30] ^= 1; + connect_device(); + if (f.report_mode == 0x35) + send_motion(7760, 8200, 7560, false, true, false); + else + send_core_and_accel(); + expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0); + expect_accel(); + assert(f.device.controller.gamepad.buttons == (BUTTON_X | BUTTON_A)); + + // A correct CRC alone is insufficient: zero sensitivity must be rejected. + reset_fixture(0x0330, true, EXT_NONE); + memcpy(&f.mp_calibration[6], f.mp_calibration, 2); + update_mp_checksum(); + connect_device(); + if (f.report_mode == 0x35) + send_motion(7760, 8200, 7560, false, true, false); + else + send_core_and_accel(); + expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0); + expect_accel(); +} + +static void accel_backup_and_memory_error_recovery(void) { + reset_fixture(0x0306, false, EXT_NONE); + f.accel_calibration[9] ^= 1; + connect_device(); + send_core_and_accel(); + expect_accel(); + + reset_fixture(0x0306, false, EXT_NONE); + f.fail_accel_reads = true; + connect_device(); + send_core_and_accel(); + expect_vector(f.device.controller.gamepad.accel, 0, 0, 0); + expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0); +} + +static void malformed_and_mismatched_setup_replies_do_not_advance(void) { + reset_fixture(0x0330, true, EXT_NONE); + uni_hid_parser_wii_setup(&f.device); + transaction_t request = stop_at_read(0xa60020); + unsigned count = f.sent_count; + const uint8_t empty[] = {0x21}; + const uint8_t short_header[] = {0x21, 0, 0, 0xf0, 0}; + const uint8_t short_data[] = {0x21, 0, 0, 0xf0, 0, 0x20, 0}; + const uint8_t short_ack[] = {0x22, 0, 0, 0x16}; + feed(empty, 0); + feed(empty, sizeof(empty)); + feed(short_header, sizeof(short_header)); + feed(short_data, sizeof(short_data)); + feed(short_ack, sizeof(short_ack)); + send_ack(0x12, 0); + send_ack(0x16, 0); // No write is outstanding. + send_read_reply(0x30, &f.mp_calibration[16], 16, 0); + send_read_reply(0x20, f.mp_calibration, 15, 0); + assert(f.sent_count == count && f.ready_count == 0); + + // Accept either two 16-byte reads or one 32-byte read; both produce the + // same two wire replies, since a 0x21 response carries at most 16 bytes. + unsigned requested = ((unsigned)request.bytes[6] << 8) | request.bytes[7]; + assert(requested == 16 || requested == 32); + send_read_reply(0x20, f.mp_calibration, 16, 0); + if (requested == 16) + (void)stop_at_read(0xa60030); + count = f.sent_count; + // Duplicate first half cannot satisfy the outstanding second-half read. + send_read_reply(0x20, f.mp_calibration, 16, 0); + assert(f.sent_count == count && f.ready_count == 0); + send_read_reply(0x30, &f.mp_calibration[16], 16, 0); + finish_setup(); + send_motion(7760, 8200, 7560, false, true, false); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024); +} + +static void short_motion_reports_do_not_publish_partial_samples(void) { + reset_fixture(0x0330, true, EXT_NONE); + connect_device(); + uint8_t report[22]; + memset(report, 0xff, sizeof(report)); + report[0] = 0x35; + unsigned count = f.sent_count; + // The sixth extension byte contains pitch MSBs and the MP discriminator. + // No prefix missing that byte constitutes a valid combined sample. + for (uint16_t len = 1; len < 12; ++len) { + feed(report, len); + expect_vector(f.device.controller.gamepad.accel, 0, 0, 0); + expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0); + } + report[0] = 0x31; + feed(report, 5); + expect_vector(f.device.controller.gamepad.accel, 0, 0, 0); + report[0] = 0x20; + feed(report, 6); + assert(f.sent_count == count && f.ready_count == 1); + send_motion(7760, 8200, 7560, false, true, false); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024); + expect_accel(); +} + +static void nunchuk_passthrough_retains_controls_between_samples(void) { + reset_fixture(0x0330, true, EXT_NUNCHUK); + connect_device(); + assert(f.mp_active && f.activation_mode == 5 && f.report_mode == 0x35); + uint8_t nunchuk[22] = {0x35, 1, 0x0a}; + put_accel_report(nunchuk, 614, 442, 534); + const uint8_t extension[6] = {160, 96, 128, 128, 129, 4}; // C held, Z released. + memcpy(&nunchuk[6], extension, sizeof(extension)); + feed(nunchuk, sizeof(nunchuk)); + assert(f.device.controller.gamepad.dpad == DPAD_LEFT); + assert(f.device.controller.gamepad.buttons == (BUTTON_B | BUTTON_SHOULDER_L | BUTTON_X)); + assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == 170); + + uint8_t gyro[22] = {0x35}; // Release core controls but keep the Nunchuk held. + put_accel_report(gyro, 614, 442, 534); + put_gyro_report(&gyro[6], 7760, 8200, 7560, false, true, false); + feed(gyro, sizeof(gyro)); + assert(f.device.controller.gamepad.buttons == BUTTON_X); + assert(f.device.controller.gamepad.dpad == 0); + assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == 170); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024); + expect_accel(); + + nunchuk[2] &= 0x60; // Preserve accel low bits, release core buttons. + nunchuk[1] &= 0x60; + nunchuk[11] = 8; // C released, Z held: moved bits 3/2, not plain bits 1/0. + feed(nunchuk, sizeof(nunchuk)); + assert(f.device.controller.gamepad.buttons == BUTTON_Y); + feed(gyro, sizeof(gyro)); + assert(f.device.controller.gamepad.buttons == BUTTON_Y); + assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == 170); + + nunchuk[6] = nunchuk[7] = 128; + nunchuk[11] = 12; // Both released; stale cached presses must now disappear. + feed(nunchuk, sizeof(nunchuk)); + feed(gyro, sizeof(gyro)); + assert(f.device.controller.gamepad.buttons == 0); + assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0); + assert(f.device.controller.gamepad.axis_x == 0 && f.device.controller.gamepad.axis_y == 0); +} + +static void legacy_plain_nunchuk_and_wii_u_pro(void) { + reset_fixture(0x0306, false, EXT_NUNCHUK); + connect_device(); + assert(!f.mp_active && (f.report_mode == 0x32 || f.report_mode == 0x35)); + uint8_t nunchuk[22] = {f.report_mode, 1, 0x0a}; + unsigned offset = f.report_mode == 0x35 ? 6 : 3; + if (offset == 6) + put_accel_report(nunchuk, 614, 442, 534); + const uint8_t extension[6] = {160, 192, 128, 128, 128, 1}; // Plain C held, Z released. + memcpy(&nunchuk[offset], extension, sizeof(extension)); + feed(nunchuk, f.report_mode == 0x35 ? 22 : 11); + assert(f.device.controller.gamepad.dpad == DPAD_LEFT); + assert(f.device.controller.gamepad.buttons == (BUTTON_B | BUTTON_SHOULDER_L | BUTTON_X)); + assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.axis_y == -341); + assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0); + + reset_fixture(0x0330, false, EXT_WII_U_PRO); + connect_device(); + assert(!f.mp_active && f.report_mode == 0x34); + uint8_t pro[22] = {0x34}; + put_le16(&pro[3], 0x0a80); + put_le16(&pro[5], 0x0580); + put_le16(&pro[7], 0x06c0); + put_le16(&pro[9], 0x0940); + pro[11] = 0x7b; // Right and + held. + pro[12] = 0xbf; // Native B held. + pro[13] = 0xff; + feed(pro, sizeof(pro)); + assert(f.device.controller.gamepad.axis_x == 256 && f.device.controller.gamepad.axis_y == 128); + assert(f.device.controller.gamepad.axis_rx == -256 && f.device.controller.gamepad.axis_ry == -128); + assert(f.device.controller.gamepad.dpad == DPAD_RIGHT); + assert(f.device.controller.gamepad.buttons == BUTTON_A); + assert(f.device.controller.gamepad.misc_buttons == MISC_BUTTON_START); + expect_vector(f.device.controller.gamepad.accel, 0, 0, 0); + expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0); +} + +static void plus_selects_vertical_without_disabling_motion(void) { + reset_fixture(0x0330, true, EXT_NONE); + f.connection_buttons = 0x10; + connect_device(); + assert(f.mp_active && f.report_mode == 0x35); + send_motion(7760, 8200, 7560, false, true, false); + assert(f.device.controller.gamepad.dpad == DPAD_LEFT); + assert(f.device.controller.gamepad.buttons == (BUTTON_B | BUTTON_X)); + expect_accel(); +} + +static void unsolicited_status_restores_stream_without_duplicate_ready(void) { + reset_fixture(0x0330, true, EXT_NONE); + connect_device(); + unsigned start = f.sent_count; + send_status(true); + finish_setup(); + bool restored = false; + for (unsigned i = start; i < f.sent_count; ++i) { + if (f.sent[i].bytes[1] == 0x12 && f.sent[i].bytes[3] == 0x35) + restored = true; + assert(f.sent[i].bytes[1] != 0x16); // Reinitialization deactivates MP. + } + assert(restored && f.ready_count == 1 && f.mp_active); + start = f.sent_count; + send_ack(0x16, 0); + send_read_reply(0xfa, (const uint8_t[]){0, 0, 0xa6, 0x20, 0, 5}, 6, 0); + assert(f.sent_count == start && f.ready_count == 1); + send_motion(7760, 8200, 7560, false, true, false); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024); +} + +static void initializing_and_vendor_nunchuk_ids_at_connection(void) { + reset_fixture(0x0330, true, EXT_NUNCHUK); + // Some third-party revisions do not have the canonical 0000 prefix. + f.extension_identity[0] = 1; + f.initializing_extension_reads = 2; + f.transient_extension_read_errors = 1; + f.status_after_activation_ack = true; + connect_device(); + assert(f.mp_active && f.activation_mode == 5); + send_nunchuk_controls(true); + send_motion(7760, 8200, 7560, false, true, false); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024); + assert(f.device.controller.gamepad.axis_x == 170 && f.device.controller.gamepad.buttons & BUTTON_X); + + // An all-zero suffix is not proof of absence: SDL and upstream recognize + // the zero-filled ID documented for a replugged wireless BladeFX adapter. + reset_fixture(0x0306, false, EXT_NUNCHUK); + memset(f.extension_identity, 0, sizeof(f.extension_identity)); + connect_device(); + send_nunchuk_controls(false); +} + +static void motionplus_nunchuk_hotplug_and_detach(void) { + reset_fixture(0x0330, true, EXT_NONE); + connect_device(); + assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL); + f.deactivation_status_before_ack = true; + f.status_after_activation_ack = true; + f.extension = EXT_NUNCHUK; + // Downstream hotplug is carried in MP data, not the ordinary status bit. + send_motion(7760, 8200, 7560, false, true, false); + finish_setup(); + assert(f.mp_active && f.activation_mode == 5 && f.ready_count == 1); + send_nunchuk_controls(true); + + // Unsolicited status starts a mapped-ID check. A detach during that pending + // read must be deferred, not overwrite its address-less transaction state. + send_status(false); + transaction_t verify = stop_at_read(0xa400fa); + f.extension = EXT_NONE; + uint8_t gyro[22] = {0x35}; + put_accel_report(gyro, 614, 442, 534); + put_gyro_report(gyro + 6, 7760, 8200, 7560, false, true, false); + feed(gyro, sizeof(gyro)); + assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0); + assert(f.device.controller.gamepad.axis_x == 0 && f.device.controller.gamepad.axis_y == 0); + assert(f.device.controller.gamepad.buttons == 0); + assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024); + answer_transaction(&verify); + finish_setup(); + assert(f.mp_active && f.activation_mode == 4 && f.ready_count == 1); + send_motion(7760, 8200, 7560, false, true, false); + assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0); + assert(f.device.controller.gamepad.axis_x == 0 && f.device.controller.gamepad.axis_y == 0); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024); + expect_accel(); +} + +static void external_motionplus_hotplug_retains_selected_orientation(void) { + reset_fixture(0x0306, true, EXT_NONE); + f.connection_buttons = 0x10; + connect_device(); + f.deactivation_status_after_ack = true; + f.status_before_activation_ack = true; + f.extension = EXT_NUNCHUK; + send_motion(7760, 8200, 7560, false, true, false); + finish_setup(); + send_nunchuk_controls(true); + f.extension = EXT_NONE; + send_motion(7760, 8200, 7560, false, true, false); + finish_setup(); + assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL); + assert(f.mp_active && f.activation_mode == 4 && f.ready_count == 1); + send_motion(7760, 8200, 7560, false, true, false); + assert(f.device.controller.gamepad.dpad == DPAD_LEFT); + expect_vector(f.device.controller.gamepad.gyro, 360 * 1024, -120 * 1024, 40 * 1024); + + // Removing the external MP itself clears its stale angular velocity, while + // the Remote's independently calibrated accelerometer remains available. + f.motionplus = false; + f.mp_active = false; + send_status(false); + finish_setup(); + uint8_t remote[6] = {0x31, 1, 0x0a}; + put_accel_report(remote, 614, 442, 534); + feed(remote, sizeof(remote)); + assert(f.device.controller.gamepad.dpad == DPAD_LEFT); + assert(f.device.controller.gamepad.buttons == (BUTTON_B | BUTTON_X)); + expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0); + expect_accel(); + assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL); +} + +static void plain_nunchuk_hotplug_and_setup_race(void) { + reset_fixture(0x0306, false, EXT_NONE); + uni_hid_parser_wii_setup(&f.device); + transaction_t accel = stop_at_read(0x16); + // An attachment status while EEPROM is being read must not lose that read. + f.extension = EXT_NUNCHUK; + send_status(true); + answer_transaction(&accel); + finish_setup(); + send_nunchuk_controls(false); + f.extension = EXT_NONE; + send_status(false); + assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0); + assert(f.device.controller.gamepad.axis_x == 0 && f.device.controller.gamepad.axis_y == 0); + assert(f.device.controller.gamepad.buttons == 0); + finish_setup(); + assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL); + send_core_and_accel(); + expect_accel(); + + uni_hid_parser_wii_set_mode(&f.device, WII_MODE_VERTICAL); + finish_setup(); + f.extension = EXT_NUNCHUK; + send_status(true); + finish_setup(); + send_nunchuk_controls(false); + f.extension = EXT_NONE; + send_status(false); + finish_setup(); + assert(f.device.controller_subtype == CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL); + assert(f.ready_count == 1); +} + +static void expect_nunchuk_stick(uint8_t x, uint8_t y, int expected_x, int expected_y) { + uint8_t report[22] = {0x35}; + put_accel_report(report, 614, 442, 534); + report[6] = x; + report[7] = y; + report[10] = 1; // Extension connected. + report[11] = f.mp_active ? 12 : 3; // C/Z released in the selected format. + feed(report, sizeof(report)); + assert(f.device.controller.gamepad.axis_x == expected_x); + assert(f.device.controller.gamepad.axis_y == expected_y); + assert(f.device.controller.gamepad.axis_rx == 0 && f.device.controller.gamepad.axis_ry == 0); +} + +static void calibrated_nunchuk_left_stick_endpoints_and_replacement(void) { + reset_fixture(0x0330, true, EXT_NUNCHUK); + 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, 126, 511, 0); + expect_nunchuk_stick(40, 126, -512, 0); + expect_nunchuk_stick(124, 210, 0, -512); + expect_nunchuk_stick(124, 30, 0, 511); + expect_nunchuk_stick(172, 168, 256, -256); + expect_nunchuk_stick(82, 78, -256, 256); + expect_nunchuk_stick(255, 0, 511, 511); + expect_nunchuk_stick(0, 255, -512, -512); + send_motion(7760, 8200, 7560, false, true, false); + assert(f.device.controller.gamepad.axis_x == -512 && f.device.controller.gamepad.axis_y == -512); + f.extension = EXT_NONE; + send_motion(7760, 8200, 7560, false, true, false); + finish_setup(); + assert(f.device.controller.gamepad.axis_x == 0 && f.device.controller.gamepad.axis_y == 0); + // A different Nunchuk must not inherit the previous extension's center/range. + set_nunchuk_stick_calibration((const uint8_t[]){230, 50, 130, 220, 40, 120}); + f.extension = EXT_NUNCHUK; + send_motion(7760, 8200, 7560, false, true, false); + finish_setup(); + expect_nunchuk_stick(130, 120, 0, 0); + expect_nunchuk_stick(230, 40, 511, 511); +} + +static void unavailable_nunchuk_calibration_keeps_safe_nominal_stick(void) { + reset_fixture(0x0306, false, EXT_NUNCHUK); + f.fail_read_address = 0xa40020; + connect_device(); + expect_nunchuk_stick(128, 128, 0, 0); + expect_nunchuk_stick(224, 32, 511, 511); + reset_fixture(0x0330, true, EXT_NUNCHUK); + set_nunchuk_stick_calibration((const uint8_t[]){128, 32, 128, 224, 32, 128}); + connect_device(); // Correct checksum but zero positive span. + expect_nunchuk_stick(160, 96, 170, 170); + reset_fixture(0x0330, true, EXT_NUNCHUK); + f.nunchuk_calibration[15] ^= 1; + connect_device(); + expect_nunchuk_stick(128, 128, 0, 0); + expect_nunchuk_stick(224, 32, 511, 511); +} + +static void run_case(const char* name, void (*test)(void)) { + printf("Wii parser: %s\n", name); + fflush(stdout); + test(); +} + +int main(void) { + 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); + run_case("external MotionPlus with absent extension status bit", external_motionplus_without_extension_status); + run_case("absent MotionPlus retains calibrated remote", absent_motionplus_keeps_calibrated_remote); + run_case("setup read/write errors leave buttons ready", setup_read_and_write_errors_leave_buttons_ready); + run_case("invalid calibration suppresses only unavailable motion", failed_calibration_never_fabricates_motion); + run_case("accelerometer backup and EEPROM errors", accel_backup_and_memory_error_recovery); + run_case("malformed and mismatched setup replies", malformed_and_mismatched_setup_replies_do_not_advance); + run_case("short reports reject partial motion samples", short_motion_reports_do_not_publish_partial_samples); + run_case("Nunchuk passthrough retains and releases held controls", nunchuk_passthrough_retains_controls_between_samples); + run_case("legacy plain Nunchuk and Wii U Pro mappings", legacy_plain_nunchuk_and_wii_u_pro); + run_case("connection-time plus selects vertical motion mode", plus_selects_vertical_without_disabling_motion); + run_case("unsolicited status restores reporting exactly once", unsolicited_status_restores_stream_without_duplicate_ready); + run_case("initializing and vendor Nunchuk identities", initializing_and_vendor_nunchuk_ids_at_connection); + run_case("integrated MotionPlus downstream hotplug and detach", motionplus_nunchuk_hotplug_and_detach); + run_case("external MotionPlus preserves selected standalone orientation", external_motionplus_hotplug_retains_selected_orientation); + run_case("plain Nunchuk hotplug and setup-time status race", plain_nunchuk_hotplug_and_setup_race); + puts("Wii parser native contracts passed"); + return 0; +}