Add Wii MotionPlus support and calibrated Nunchuk controls

This commit is contained in:
Joey Yakimowich-Payne 2026-09-08 12:39:44 -06:00
commit fa38123873
27 changed files with 3003 additions and 57 deletions

View file

@ -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 27 and 813 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 16 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 0255 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.

View file

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

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,9 @@
#include <pico/multicore.h>
#include <pico/stdlib.h>
#include <uni.h>
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<uint8_t>(&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 ||

View file

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

View file

@ -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<uint8_t>(snapshot.controller_layout) >
static_cast<uint8_t>(Bluepad32ControllerLayout::kWiiNunchuk)) {
static_cast<uint8_t>(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;

View file

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

View file

@ -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("<IB", connection_generation, mode),
)
def parse_profile_playtest(envelope: Envelope) -> 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")

View file

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

View file

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

View file

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

View file

@ -154,6 +154,18 @@
</select>
<p class="field-help" id="controllerLayoutHelp">Preview changes only this editor's source labels and diagram, never firmware topology or saved mappings.</p>
</div>
<div class="wii-orientation-control" id="wiiOrientationControl" hidden>
<label class="field-label" for="wiiOrientationSelect">Wii orientation</label>
<div class="wii-orientation-actions">
<select class="select" id="wiiOrientationSelect" aria-describedby="wiiOrientationHelp wiiOrientationStatus" disabled>
<option value="horizontal">Horizontal · sideways D-pad &amp; 1/2 buttons</option>
<option value="vertical">Vertical · upright pointer orientation</option>
</select>
<button class="button button-secondary" id="applyWiiOrientationButton" type="button" disabled>Apply orientation</button>
</div>
<p class="field-help" id="wiiOrientationHelp">Applies to the active connection only (not saved in profile slots). Nunchuk automatically locks orientation to vertical.</p>
<p class="wii-orientation-status" id="wiiOrientationStatus" data-state="idle" role="status" aria-live="polite"></p>
</div>
<div class="controller-viewport" tabindex="0" role="region" aria-label="Controller diagram; scroll horizontally on narrow screens">
<div class="controller-photo-wrap" id="controllerPhotoWrap">
<div class="controller-stage" id="controllerStage">

View file

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

View file

@ -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<LocalRumbleEvent> local_rumble_events;
struct WiiModeEvent {
uni_hid_device_t* device;
wii_mode_t mode;
};
std::vector<WiiModeEvent> 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<btstack_timer_source_t*> 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") {

View file

@ -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<uint8_t, UNI_PSMOVE_ZCM1_CALIBRATION_SIZE> 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();

View file

@ -0,0 +1,19 @@
#pragma once
#include <uni.h>
#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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Bluepad32ControllerLayout>(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<Bluepad32ControllerLayout>(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<uint8_t> 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<uint16_t>(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) {

View file

@ -0,0 +1,913 @@
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#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;
}