switch-pico/tests/wii_ir_pointer_test.cpp
Joey Yakimowich-Payne 9f6dddb790 WIP: checkpoint Wii native IMU and upstream IR bridge
Pause Wii pointing work with firmware 0.33-wii-trace installed. Preserve the pinned libogc IR pipeline, calibrated native IMU, software BOOTSEL, and standard camera sensitivity trial.

Tracking instability, tracking loss, and ineffective vertical movement remain unresolved. Level-2 camera filtering is not hardware-qualified. Nine targeted regression tests pass; firmware and persistent storage were verified after flashing.
2026-09-11 17:05:54 -06:00

489 lines
22 KiB
C++

#include "input/wii_ir_pointer.h"
#include <cstdint>
#include <cmath>
#include <limits>
#include <iostream>
static uint32_t clock_us;
uint32_t time_us_32() { return clock_us; }
namespace {
int failures;
void expect(bool okay, const char* message) {
if (!okay) { std::cerr << message << '\n'; ++failures; }
}
void observe(uint8_t slot, uint32_t connection_generation, uint32_t sequence,
int dx, int dy, uint16_t buttons = 0, uint8_t mask = 3,
bool nunchuk_c = false) {
clock_us += 5000;
const uint16_t x[4] = {static_cast<uint16_t>(400 + dx), static_cast<uint16_t>(624 + dx), 0, 0};
const uint16_t y[4] = {static_cast<uint16_t>(384 + dy), static_cast<uint16_t>(384 + dy), 0, 0};
wii_ir_pointer_observe(slot, connection_generation, sequence, buttons, x, y, mask, nunchuk_c,
0.0f, false);
}
struct Rig {
uint8_t slot = 2;
uint32_t connection_generation = 41;
uint32_t sequence = 0;
int horizontal = 0;
int vertical = 0;
Rig() {
wii_ir_pointer_init();
wii_ir_pointer_reset();
#if SWITCH2_BRIDGE_WII_INPUT
expect(wii_ir_pointer_configure_screen(660, 370, 0, 0, 1320, 740),
"explicit native viewport and count spans must be configurable");
#endif
wii_ir_mouse_set_output_enabled(true);
clock_us = 100000;
#if SWITCH2_BRIDGE_WII_INPUT
// Prime the real upstream startup/glitch/smoothing history, then
// consume its finite startup tail before comparing relative motion.
settle(0, 0);
#endif
WiiIrMouseReport release{};
if (wii_ir_mouse_peek(&release)) wii_ir_mouse_commit(release);
input(0, 0);
input(0, 0);
}
void input(int dx, int dy, uint16_t buttons = 0, uint8_t mask = 3,
bool nunchuk_c = false) {
horizontal = dx;
vertical = dy;
observe(slot, connection_generation, ++sequence, dx, dy, buttons, mask, nunchuk_c);
}
void baseline(int dx, int dy) {
#if SWITCH2_BRIDGE_WII_INPUT
for (unsigned i = 0; i < 16; ++i) {
input(dx, dy);
if (peek().tracking) return;
}
expect(false, "fresh stationary full-pair reports must establish a native baseline");
#else
input(dx, dy);
input(dx, dy);
#endif
}
void settle(int dx, int dy, uint16_t buttons = 0) {
for (int i = 0; i < 80; ++i) input(dx, dy, buttons);
}
void move_to(int dx, int dy) {
const int from_x = horizontal;
const int from_y = vertical;
for (int i = 1; i <= 20; ++i) {
input(from_x + (dx - from_x) * i / 20, from_y + (dy - from_y) * i / 20);
}
settle(dx, dy);
}
WiiIrMouseReport peek() {
WiiIrMouseReport report{};
wii_ir_mouse_peek(&report, 32767);
return report;
}
};
void native_movement_and_legacy_limits_conserve_output() {
Rig rig;
rig.move_to(100, -100);
const auto native = rig.peek();
expect(native.dx < -127 && native.dy > 127,
"large two-axis motion must remain signed and exceed the legacy 8-bit range");
const auto retry = rig.peek();
expect(retry.dx == native.dx && retry.dy == native.dy && retry.generation == native.generation,
"peeking an unsubmitted native report must not consume movement");
WiiIrMouseReport legacy{};
expect(wii_ir_mouse_peek(&legacy) && legacy.dx == -127 && legacy.dy == 127,
"legacy callers must retain the signed 8-bit HID movement limits");
WiiIrMouseReport bounded{};
expect(wii_ir_mouse_peek(&bounded, 31) && bounded.dx == -31 && bounded.dy == 31,
"both signed axes must obey the caller's movement bound");
expect(!wii_ir_mouse_peek(&bounded, 0) && bounded.dx == 0 && bounded.dy == 0 && bounded.tracking,
"a zero movement allowance must leave tracking live without consuming displacement");
wii_ir_mouse_commit(legacy);
const auto remainder = rig.peek();
expect(remainder.dx == native.dx - legacy.dx && remainder.dy == native.dy - legacy.dy,
"committing a bounded legacy report must leave its full native remainder");
wii_ir_mouse_commit(remainder);
expect(!wii_ir_mouse_peek(&bounded, 32767) && bounded.dx == 0 && bounded.dy == 0 && bounded.tracking,
"committed movement must not be sent again and idle tracking must remain visible");
}
void prepared_report_preserves_newer_observations() {
Rig rig;
rig.move_to(80, -80);
const auto prepared = rig.peek();
rig.move_to(120, -120);
const auto accumulated = rig.peek();
expect(accumulated.dx < prepared.dx && accumulated.dy > prepared.dy,
"fresh observations must continue accumulating while a USB report waits");
wii_ir_mouse_commit(prepared);
const auto remaining = rig.peek();
expect(remaining.dx == accumulated.dx - prepared.dx && remaining.dy == accumulated.dy - prepared.dy,
"a successful cached report must consume only its own displacement, not newer samples");
}
void disabled_output_rebaselines_without_losing_owner() {
Rig rig;
rig.move_to(100, -100);
const auto old = rig.peek();
wii_ir_mouse_set_output_enabled(false);
WiiIrMouseReport disabled{};
expect(!wii_ir_mouse_peek(&disabled, 32767) && disabled.dx == 0 && disabled.dy == 0 && !disabled.tracking,
"disabling output must immediately discard pending movement and tracking");
expect(disabled.owner == rig.slot && disabled.connection_generation == rig.connection_generation &&
disabled.generation != old.generation,
"gating must invalidate prepared reports without relinquishing the selected owner");
rig.settle(-150, 100, 0x000c);
expect(!wii_ir_mouse_peek(&disabled, 32767) && disabled.dx == 0 && disabled.dy == 0 && disabled.buttons == 3,
"disabled camera observations must retain legacy button diagnostics but never queue output");
uint8_t diagnostics[WII_IR_MOUSE_DIAGNOSTIC_SIZE]{};
wii_ir_pointer_diagnostics(diagnostics, sizeof(diagnostics));
expect(diagnostics[2] == 3 && diagnostics[32] == 0x0c && diagnostics[34] == 3,
"disabled output must continue reporting observed spots and physical buttons in diagnostics");
wii_ir_mouse_set_output_enabled(true);
const auto reopened = rig.peek();
expect(reopened.dx == 0 && reopened.dy == 0 && !reopened.tracking &&
reopened.owner == rig.slot && reopened.generation != disabled.generation,
"reopening output must await a fresh camera baseline without replaying disabled movement");
rig.baseline(-150, 100);
const auto baseline = rig.peek();
expect(baseline.tracking && baseline.dx == 0 && baseline.dy == 0,
"fresh observations after reopening must establish a motionless baseline");
rig.move_to(-120, 70);
const auto resumed = rig.peek();
expect(resumed.dx < 0 && resumed.dx > -127 && resumed.dy > 0 && resumed.dy < 127,
"reopened output must contain the small new motion, not the larger disabled repositioning");
wii_ir_mouse_commit(old);
const auto after_late_commit = rig.peek();
expect(after_late_commit.dx == resumed.dx && after_late_commit.dy == resumed.dy,
"a late commit from before disabling must not consume newly acquired movement");
wii_ir_mouse_set_output_enabled(false);
wii_ir_pointer_reset();
rig.slot = 4;
rig.connection_generation = 99;
rig.settle(0, 0);
expect(!wii_ir_mouse_peek(&disabled, 32767) && !disabled.tracking && disabled.owner == 4 &&
disabled.connection_generation == 99 && disabled.dx == 0 && disabled.dy == 0,
"reset and source reselection must not silently reopen a disabled output gate");
}
void repeated_enable_preserves_live_tracking() {
Rig rig;
rig.move_to(50, -50);
const auto before = rig.peek();
wii_ir_mouse_set_output_enabled(true);
const auto unchanged = rig.peek();
expect(unchanged.tracking && unchanged.generation == before.generation &&
unchanged.dx == before.dx && unchanged.dy == before.dy,
"repeated enable must neither reset live tracking nor discard pending displacement");
for (int i = 1; i <= 20; ++i) {
wii_ir_mouse_set_output_enabled(true);
rig.input(50 + i, -50 - i);
}
const auto moved = rig.peek();
expect(moved.tracking && moved.generation == before.generation && moved.dx < before.dx && moved.dy > before.dy,
"a continuously asserted readiness gate must allow subsequent camera motion");
}
void duplicate_samples_cannot_refresh_stale_tracking() {
Rig rig;
rig.move_to(80, -80);
const auto prepared = rig.peek();
for (int i = 0; i < 29; ++i) {
observe(rig.slot, rig.connection_generation, rig.sequence, -100, 100, 0x000c);
}
const auto fresh = rig.peek();
expect(fresh.tracking && fresh.dx == prepared.dx && fresh.dy == prepared.dy && fresh.buttons == 0,
"duplicate sequences must not update camera movement, buttons, or the freshness deadline");
observe(rig.slot, rig.connection_generation, rig.sequence, -100, 100, 0x000c);
WiiIrMouseReport stale{};
expect(!wii_ir_mouse_peek(&stale, 32767) && !stale.tracking && stale.dx == 0 && stale.dy == 0,
"tracking and queued movement must expire at 150ms despite duplicate observations");
expect(stale.owner == rig.slot && stale.connection_generation == rig.connection_generation &&
stale.generation != prepared.generation,
"stale tracking must invalidate prepared movement without forgetting the connected owner");
rig.baseline(-100, 100);
const auto reacquired = rig.peek();
expect(reacquired.tracking && reacquired.dx == 0 && reacquired.dy == 0,
"reacquisition after stale tracking must not include the stale interval");
rig.move_to(-70, 70);
const auto current = rig.peek();
wii_ir_mouse_commit(prepared);
const auto after = rig.peek();
expect(after.dx == current.dx && after.dy == current.dy,
"an expired report cannot consume movement from a fresh tracking generation");
}
void owner_generation_and_tracking_loss_invalidate_reports() {
Rig rig;
WiiIrMouseReport idle{};
expect(!wii_ir_mouse_peek(&idle, 32767) && idle.tracking && idle.owner == rig.slot &&
idle.connection_generation == rig.connection_generation,
"a stationary source must expose tracking and connection identity even without pending output");
rig.move_to(80, -80);
const auto old = rig.peek();
observe(7, 900, 1, -100, 100, 0x000c);
wii_ir_pointer_disconnect(7);
const auto owned = rig.peek();
expect(owned.owner == rig.slot && owned.connection_generation == rig.connection_generation &&
owned.dx == old.dx && owned.dy == old.dy && owned.buttons == 0,
"an unrelated source or disconnect must not steal ownership or alter movement");
++rig.connection_generation;
rig.sequence = 0;
rig.baseline(-100, 100);
const auto reconnected = rig.peek();
expect(reconnected.tracking && reconnected.dx == 0 && reconnected.dy == 0 &&
reconnected.connection_generation == rig.connection_generation && reconnected.generation != old.generation,
"reusing a slot with a new connection generation must discard the old association and backlog");
rig.move_to(-70, 70);
const auto current = rig.peek();
wii_ir_mouse_commit(old);
const auto after = rig.peek();
expect(after.dx == current.dx && after.dy == current.dy,
"a previous connection's report cannot consume current movement");
#if SWITCH2_BRIDGE_WII_INPUT
// Upstream holds eight missing reports; only the ninth is true loss.
for (unsigned i = 0; i < 8; ++i) rig.input(-70, 70, 0, 0);
#endif
rig.input(-70, 70, 0, 0);
expect(!wii_ir_mouse_peek(&idle, 32767) && !idle.tracking && idle.dx == 0 && idle.dy == 0,
"true camera tracking loss must drop unsent movement");
wii_ir_pointer_disconnect(rig.slot);
expect(!wii_ir_mouse_peek(&idle, 32767) && !idle.tracking && idle.owner == 0xff && idle.connection_generation == 0,
"disconnect must clear tracking and connection identity even without pending movement");
}
void legacy_buttons_and_clutch_remain_independent_of_movement() {
Rig rig;
rig.input(0, 0, 0x000c);
WiiIrMouseReport buttons{};
expect(wii_ir_mouse_peek(&buttons) && buttons.buttons == 3 && buttons.dx == 0 && buttons.dy == 0,
"legacy mouse buttons must remain observable independently of camera movement");
wii_ir_mouse_commit(buttons);
expect(!wii_ir_mouse_peek(&buttons), "committing stationary legacy buttons must clear their pending change");
#if SWITCH2_BRIDGE_WII_INPUT
for (int i = 1; i <= 20; ++i) rig.input(i, -i, 0x0003, 3, true);
const auto held = rig.peek();
expect(held.tracking && held.dx < 0 && held.dy > 0 && held.buttons == 0,
"native controller buttons and Nunchuk C must not activate either legacy clutch chord");
#else
rig.input(20, -20, 0x0002);
expect(wii_ir_mouse_peek(&buttons) && buttons.buttons == 0 && !buttons.tracking && buttons.dx == 0 && buttons.dy == 0,
"the legacy 1-button clutch must suppress movement while still releasing buttons");
wii_ir_mouse_commit(buttons);
rig.settle(100, -100, 0x0002);
rig.input(100, -100);
rig.input(100, -100);
expect(!wii_ir_mouse_peek(&buttons) && buttons.tracking && buttons.dx == 0 && buttons.dy == 0,
"releasing the legacy clutch must rebaseline rather than replay repositioning");
rig.move_to(120, -120);
const auto moved = rig.peek();
expect(moved.dx < 0 && moved.dy > 0, "movement must resume normally after the legacy clutch is released");
#endif
}
#if SWITCH2_BRIDGE_WII_INPUT
void native_cold_baseline_and_unique_camera_reports() {
Rig rig;
wii_ir_pointer_reset();
const uint16_t x[4] = {300, 524, 0, 0};
const uint16_t y[4] = {384, 384, 0, 0};
clock_us += 5000;
wii_ir_pointer_observe(rig.slot, rig.connection_generation, ++rig.sequence,
0, x, y, 3, false, 0.0f, true);
for (unsigned i = 0; i < 64; ++i) {
wii_ir_pointer_observe(rig.slot, rig.connection_generation, rig.sequence,
0, x, y, 3, false, 0.0f, true);
const auto held = rig.peek();
expect(!held.tracking && held.dx == 0 && held.dy == 0,
"polls and duplicate sequences must not advance upstream's initial glitch hold");
}
rig.baseline(-100, 0);
const auto first = rig.peek();
expect(first.tracking && first.dx == 0 && first.dy == 0 && first.optical_valid,
"first accepted upstream position must become a motionless relative baseline");
expect(first.optical_sequence == rig.sequence && first.optical_received_us == clock_us &&
std::fabs(first.optical_yaw_radians - std::atan(99.0f * 0.39f / 512.0f)) < 0.00001f,
"optical heading must use mirrored raw X with upstream calc_yaw in radians");
uint8_t diagnostics[WII_IR_MOUSE_DIAGNOSTIC_SIZE]{};
expect(wii_ir_pointer_diagnostics(diagnostics, sizeof(diagnostics)) == 48 &&
diagnostics[16] == 0x2c && diagnostics[17] == 0x01 &&
diagnostics[18] == 0x80 && diagnostics[19] == 0x01 &&
(diagnostics[3] & 0x19) == 0x19,
"raw48 diagnostics must retain original unmirrored pixels and upstream tracking flags");
}
void native_glitch_hold_excludes_optical_heading() {
Rig rig;
const auto before = rig.peek();
for (unsigned frame = 0; frame < 6; ++frame) {
rig.input(250, 0);
const auto held = rig.peek();
expect(held.tracking && !held.optical_valid && held.dx == 0 && held.dy == 0 &&
held.generation == before.generation,
"upstream glitch-held raw geometry must not move the pointer or feed optical heading");
}
rig.input(250, 0);
const auto accepted = rig.peek();
expect(accepted.tracking && accepted.optical_valid && accepted.dx < 0 &&
accepted.generation == before.generation,
"upstream accepting a persistent glitch must restore heading confidence without a custom rebase");
}
void native_missing_report_grace_and_true_loss() {
Rig rig;
rig.input(20, -20); // Leave an upstream smoothing tail when the dots vanish.
const auto settled = rig.peek();
wii_ir_mouse_commit(settled);
for (unsigned missing = 1; missing <= 8; ++missing) {
rig.input(20, -20, 0, 0);
const auto held = rig.peek();
expect(held.tracking && !held.optical_valid && held.dx == 0 && held.dy == 0 &&
held.generation == settled.generation,
"upstream's eight missing-frame grace must hold position without movement or rebasing");
// Core0 polling cannot age the frame-count error policy.
for (unsigned poll = 0; poll < 16; ++poll) {
const auto polled = rig.peek();
expect(polled.tracking && polled.dx == 0 && polled.dy == 0,
"USB peeks must neither age missing-frame grace nor synthesize movement");
}
}
rig.input(20, -20, 0, 0);
const auto lost = rig.peek();
expect(!lost.tracking && !lost.optical_valid && lost.dx == 0 && lost.dy == 0 &&
lost.generation != settled.generation,
"ninth missing camera report must expose upstream's real loss and invalidate pending generations");
rig.input(-100, 100);
const auto acquired = rig.peek();
expect(acquired.tracking && acquired.optical_valid && acquired.dx == 0 && acquired.dy == 0,
"upstream loss recovery must rebaseline rather than bridge the absent camera interval");
rig.move_to(-70, 70);
expect(rig.peek().dx < 0 && rig.peek().dy > 0,
"real movement must resume after loss recovery");
}
void native_single_end_does_not_supply_full_optical_heading() {
Rig rig;
int vertical = 0;
for (int i = 1; i <= 80; ++i) {
rig.input(0, i, 0, 1);
const auto report = rig.peek();
expect(report.tracking && !report.optical_valid,
"upstream single-end continuation must not claim a full-pair optical heading");
vertical += report.dy;
wii_ir_mouse_commit(report);
}
expect(vertical < -140,
"continuous single-end observations must retain upstream's relative movement");
rig.input(0, 80);
expect(rig.peek().optical_valid,
"a full pair returning must restore the native optical observer metadata");
}
void native_roll_convention_and_invalid_gravity_retention() {
Rig rig;
wii_ir_pointer_reset();
// A raw +90-degree bar becomes horizontal only after mirrored camera X
// and upstream's +90-degree rotation. Raw X travel becomes corrected Y.
int horizontal = 0, vertical = 0;
for (unsigned i = 0; i < 100; ++i) {
const uint16_t x[4] = {static_cast<uint16_t>(512 + (i > 30 ? 30 : 0)),
static_cast<uint16_t>(512 + (i > 30 ? 30 : 0)), 0, 0};
const uint16_t y[4] = {272, 496, 0, 0};
clock_us += 5000;
wii_ir_pointer_observe(rig.slot, rig.connection_generation, ++rig.sequence,
0, x, y, 3, false,
i == 0 ? 1.57079632679f : std::numeric_limits<float>::quiet_NaN(),
(i % 2) == 0);
const auto report = rig.peek();
if (i > 30) {
expect(report.tracking && report.optical_valid,
"invalid new gravity must retain the last valid roll for upstream full-pair tracking");
horizontal += report.dx;
vertical += report.dy;
}
wii_ir_mouse_commit(report);
}
expect(vertical > 0 && vertical > 2 * std::abs(horizontal),
"raw +90-degree roll must map travel predominantly to shared positive Y despite smoothing history");
}
void native_relative_motion_is_not_clipped_to_viewport() {
Rig rig;
wii_ir_pointer_reset();
for (int i = 0; i < 80; ++i) {
rig.input(0, 220);
wii_ir_mouse_commit(rig.peek());
}
int vertical = 0;
bool tracked = true;
for (int i = 1; i <= 110; ++i) {
rig.input(0, 220 + (i < 30 ? i : 30));
const auto report = rig.peek();
tracked = tracked && report.tracking;
vertical += report.dy;
wii_ir_mouse_commit(report);
}
expect(tracked && vertical < -40 && vertical > -80,
"visible vertical motion outside the viewport must still reach a relative mouse");
expect(wii_ir_pointer_configure_screen(1, 1, 0, 0, 32767, 32767),
"the narrowest supported viewport and largest count spans must be accepted");
for (int i = 0; i < 80; ++i) {
rig.input(300, 0);
wii_ir_mouse_commit(rig.peek());
}
int horizontal = 0;
bool bounded = true;
for (int i = 0; i < 24; ++i) {
rig.input(290, 0);
const auto report = rig.peek();
bounded = bounded && report.dx >= 0 && report.dx <= 4096 &&
report.dy >= -4096 && report.dy <= 4096;
horizontal += report.dx;
wii_ir_mouse_commit(report);
}
expect(bounded && horizontal > 0,
"wide off-viewport coordinates must not overflow or reverse bounded relative movement");
}
#endif
} // namespace
int main() {
native_movement_and_legacy_limits_conserve_output();
prepared_report_preserves_newer_observations();
disabled_output_rebaselines_without_losing_owner();
repeated_enable_preserves_live_tracking();
duplicate_samples_cannot_refresh_stale_tracking();
owner_generation_and_tracking_loss_invalidate_reports();
legacy_buttons_and_clutch_remain_independent_of_movement();
#if SWITCH2_BRIDGE_WII_INPUT
native_cold_baseline_and_unique_camera_reports();
native_glitch_hold_excludes_optical_heading();
native_missing_report_grace_and_true_loss();
native_single_end_does_not_supply_full_optical_heading();
native_roll_convention_and_invalid_gravity_retention();
native_relative_motion_is_not_clipped_to_viewport();
#endif
if (failures) return 1;
std::cout << "IR pointer output contract passed\n";
}