From f8de986717dcf57d31d49e141acc2523ce87e1e5 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 11 Aug 2026 12:23:07 +0900 Subject: [PATCH] Implement Switch2 reports Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- switch2_reports.cpp | 89 ++++++++++++ tests/firmware/test_switch2_reports.cpp | 186 ++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 switch2_reports.cpp create mode 100644 tests/firmware/test_switch2_reports.cpp diff --git a/switch2_reports.cpp b/switch2_reports.cpp new file mode 100644 index 0000000..70b171c --- /dev/null +++ b/switch2_reports.cpp @@ -0,0 +1,89 @@ +#include "switch2_reports.h" + +// Report layouts are pinned to: +// https://github.com/ndeadly/switch2_controller_research/blob/d1c5a7f7ba298f83017fae84952a4e6d2ef8fc92/hid_reports.md +namespace { + +void pack_stick(uint8_t* destination, uint16_t x, uint16_t y) { + const uint16_t packed_x = x >> 4; + const uint16_t packed_y = y >> 4; + destination[0] = static_cast(packed_x & 0xFF); + destination[1] = static_cast( + ((packed_x >> 8) & 0x0F) | ((packed_y & 0x0F) << 4)); + destination[2] = static_cast(packed_y >> 4); +} + +} // namespace + +Switch2InputReport switch2_build_input_report( + Switch2InputReportId id, + const SwitchInputState& state, + uint32_t counter) { + Switch2InputReport report{id, {}}; + std::array& payload = report.payload; + + switch (id) { + case Switch2InputReportId::Common: + payload[0] = static_cast(counter); + payload[1] = static_cast(counter >> 8); + payload[2] = static_cast(counter >> 16); + payload[3] = static_cast(counter >> 24); + payload[4] = static_cast( + (state.button_zr ? 0x80 : 0) | + (state.button_r ? 0x40 : 0) | + (state.button_a ? 0x08 : 0) | + (state.button_b ? 0x04 : 0) | + (state.button_x ? 0x02 : 0) | + (state.button_y ? 0x01 : 0)); + payload[5] = static_cast( + (state.button_capture ? 0x20 : 0) | + (state.button_home ? 0x10 : 0) | + (state.button_l3 ? 0x08 : 0) | + (state.button_r3 ? 0x04 : 0) | + (state.button_plus ? 0x02 : 0) | + (state.button_minus ? 0x01 : 0)); + payload[6] = static_cast( + (state.button_zl ? 0x80 : 0) | + (state.button_l ? 0x40 : 0) | + (state.dpad_left ? 0x08 : 0) | + (state.dpad_right ? 0x04 : 0) | + (state.dpad_up ? 0x02 : 0) | + (state.dpad_down ? 0x01 : 0)); + pack_stick(&payload[10], state.lx, state.ly); + pack_stick(&payload[13], state.rx, state.ry); + payload[0x29] = 0x01; + break; + + case Switch2InputReportId::Pro: + payload[0] = static_cast(counter); + // USB external power is known; charging and battery bits stay zero. + payload[1] = 0x01; + payload[2] = static_cast( + (state.button_r3 ? 0x80 : 0) | + (state.button_plus ? 0x40 : 0) | + (state.button_zr ? 0x20 : 0) | + (state.button_r ? 0x10 : 0) | + (state.button_x ? 0x08 : 0) | + (state.button_y ? 0x04 : 0) | + (state.button_a ? 0x02 : 0) | + (state.button_b ? 0x01 : 0)); + payload[3] = static_cast( + (state.button_l3 ? 0x80 : 0) | + (state.button_minus ? 0x40 : 0) | + (state.button_zl ? 0x20 : 0) | + (state.button_l ? 0x10 : 0) | + (state.dpad_up ? 0x08 : 0) | + (state.dpad_left ? 0x04 : 0) | + (state.dpad_right ? 0x02 : 0) | + (state.dpad_down ? 0x01 : 0)); + payload[4] = static_cast( + (state.button_capture ? 0x02 : 0) | + (state.button_home ? 0x01 : 0)); + pack_stick(&payload[5], state.lx, state.ly); + pack_stick(&payload[8], state.rx, state.ry); + payload[11] = 0x30; + break; + } + + return report; +} diff --git a/tests/firmware/test_switch2_reports.cpp b/tests/firmware/test_switch2_reports.cpp new file mode 100644 index 0000000..1496bc7 --- /dev/null +++ b/tests/firmware/test_switch2_reports.cpp @@ -0,0 +1,186 @@ +#include "test_support.h" + +#include +#include + +#include "../../switch2_reports.h" + +namespace { + +SwitchInputState neutral_state() { + SwitchInputState state{}; + state.lx = SWITCH_PRO_JOYSTICK_MID; + state.ly = SWITCH_PRO_JOYSTICK_MID; + state.rx = SWITCH_PRO_JOYSTICK_MID; + state.ry = SWITCH_PRO_JOYSTICK_MID; + return state; +} + +bool bytes_are_zero( + const std::array& payload, + std::size_t begin, + std::size_t end) { + for (std::size_t index = begin; index < end; ++index) { + if (payload[index] != 0) return false; + } + return true; +} + +bool switch2_neutral_reports_pack_documented_constants() { + // Given: neutral normalized input and distinct counters. + const SwitchInputState state = neutral_state(); + + // When: common and Pro reports are built. + const Switch2InputReport common = switch2_build_input_report( + Switch2InputReportId::Common, state, 0x78563412); + const Switch2InputReport pro = switch2_build_input_report( + Switch2InputReportId::Pro, state, 0x1234); + + // Then: IDs, counters, neutral sticks, and documented constants are exact. + CHECK(static_cast(common.id) == 0x05); + CHECK(static_cast(pro.id) == 0x09); + CHECK(common.payload.size() + 1 == 64 && pro.payload.size() + 1 == 64); + CHECK(common.payload[0] == 0x12 && common.payload[1] == 0x34); + CHECK(common.payload[2] == 0x56 && common.payload[3] == 0x78); + CHECK(common.payload[10] == 0xFF && common.payload[11] == 0xF7 && common.payload[12] == 0x7F); + CHECK(common.payload[13] == 0xFF && common.payload[14] == 0xF7 && common.payload[15] == 0x7F); + CHECK(common.payload[0x29] == 0x01); + CHECK(pro.payload[0] == 0x34); + CHECK(pro.payload[1] == 0x01); + CHECK(pro.payload[5] == 0xFF && pro.payload[6] == 0xF7 && pro.payload[7] == 0x7F); + CHECK(pro.payload[8] == 0xFF && pro.payload[9] == 0xF7 && pro.payload[10] == 0x7F); + CHECK(pro.payload[11] == 0x30); + return true; +} + +bool switch2_report_counters_roll_over_deterministically() { + // Given: counters at and beyond the Pro report's eight-bit boundary. + const SwitchInputState state = neutral_state(); + + // When: reports are built at 255 and 256. + const Switch2InputReport common = switch2_build_input_report( + Switch2InputReportId::Common, state, 0xFFFFFFFF); + const Switch2InputReport pro_255 = switch2_build_input_report( + Switch2InputReportId::Pro, state, 255); + const Switch2InputReport pro_256 = switch2_build_input_report( + Switch2InputReportId::Pro, state, 256); + + // Then: common remains LE32 while Pro uses the low eight bits. + CHECK(common.payload[0] == 0xFF && common.payload[1] == 0xFF); + CHECK(common.payload[2] == 0xFF && common.payload[3] == 0xFF); + CHECK(pro_255.payload[0] == 0xFF); + CHECK(pro_256.payload[0] == 0x00); + return true; +} + +bool switch2_reports_map_every_shared_button() { + struct ButtonCase { + bool SwitchInputState::*field; + uint8_t common_offset; + uint8_t common_mask; + uint8_t pro_offset; + uint8_t pro_mask; + }; + static constexpr ButtonCase cases[] = { + {&SwitchInputState::button_y, 4, 0x01, 2, 0x04}, + {&SwitchInputState::button_b, 4, 0x04, 2, 0x01}, + {&SwitchInputState::button_a, 4, 0x08, 2, 0x02}, + {&SwitchInputState::button_x, 4, 0x02, 2, 0x08}, + {&SwitchInputState::button_r, 4, 0x40, 2, 0x10}, + {&SwitchInputState::button_zr, 4, 0x80, 2, 0x20}, + {&SwitchInputState::button_minus, 5, 0x01, 3, 0x40}, + {&SwitchInputState::button_plus, 5, 0x02, 2, 0x40}, + {&SwitchInputState::button_r3, 5, 0x04, 2, 0x80}, + {&SwitchInputState::button_l3, 5, 0x08, 3, 0x80}, + {&SwitchInputState::button_home, 5, 0x10, 4, 0x01}, + {&SwitchInputState::button_capture, 5, 0x20, 4, 0x02}, + {&SwitchInputState::dpad_down, 6, 0x01, 3, 0x01}, + {&SwitchInputState::dpad_up, 6, 0x02, 3, 0x08}, + {&SwitchInputState::dpad_right, 6, 0x04, 3, 0x02}, + {&SwitchInputState::dpad_left, 6, 0x08, 3, 0x04}, + {&SwitchInputState::button_l, 6, 0x40, 3, 0x10}, + {&SwitchInputState::button_zl, 6, 0x80, 3, 0x20}, + }; + + // Given/When: every shared button is built independently in both reports. + for (const ButtonCase& button : cases) { + SwitchInputState state = neutral_state(); + state.*(button.field) = true; + const Switch2InputReport common = switch2_build_input_report( + Switch2InputReportId::Common, state, 0); + const Switch2InputReport pro = switch2_build_input_report( + Switch2InputReportId::Pro, state, 0); + + // Then: only the captured byte and bit for that button is set. + for (uint8_t offset = 4; offset <= 7; ++offset) { + CHECK(common.payload[offset] == + (offset == button.common_offset ? button.common_mask : 0)); + } + for (uint8_t offset = 2; offset <= 4; ++offset) { + CHECK(pro.payload[offset] == + (offset == button.pro_offset ? button.pro_mask : 0)); + } + } + return true; +} + +bool switch2_reports_pack_twelve_bit_stick_extremes() { + // Given: four distinct normalized axis values. + SwitchInputState state{}; + state.lx = 0x0000; + state.ly = 0xFFFF; + state.rx = 0x1234; + state.ry = 0xABCD; + + // When: both report formats are built. + const Switch2InputReport common = switch2_build_input_report( + Switch2InputReportId::Common, state, 0); + const Switch2InputReport pro = switch2_build_input_report( + Switch2InputReportId::Pro, state, 0); + + // Then: axes are reduced and packed in captured 12-bit little-endian form. + const uint8_t expected_left[] = {0x00, 0xF0, 0xFF}; + const uint8_t expected_right[] = {0x23, 0xC1, 0xAB}; + for (uint8_t index = 0; index < 3; ++index) { + CHECK(common.payload[10 + index] == expected_left[index]); + CHECK(common.payload[13 + index] == expected_right[index]); + CHECK(pro.payload[5 + index] == expected_left[index]); + CHECK(pro.payload[8 + index] == expected_right[index]); + } + return true; +} + +bool switch2_reports_zero_unknowns_and_ignore_imu() { + // Given: populated IMU input that has no known Switch 2 packing. + SwitchInputState state = neutral_state(); + state.imu_sample_count = 3; + state.imu_samples[0] = {1, 2, 3, 4, 5, 6}; + state.imu_samples[1] = {7, 8, 9, 10, 11, 12}; + state.imu_samples[2] = {13, 14, 15, 16, 17, 18}; + + // When: both reports are built twice from identical input. + const Switch2InputReport common = switch2_build_input_report( + Switch2InputReportId::Common, state, 7); + const Switch2InputReport common_again = switch2_build_input_report( + Switch2InputReportId::Common, state, 7); + const Switch2InputReport pro = switch2_build_input_report( + Switch2InputReportId::Pro, state, 7); + + // Then: unknown power/sensor/motion fields stay deterministic and zero. + CHECK(common.payload == common_again.payload); + CHECK(bytes_are_zero(common.payload, 8, 10)); + CHECK(bytes_are_zero(common.payload, 16, 0x29)); + CHECK(bytes_are_zero(common.payload, 0x2A, 63)); + CHECK(bytes_are_zero(pro.payload, 12, 63)); + return true; +} + +} // namespace + +void run_switch2_report_tests(TestRunner& runner) { + runner.run("Switch 2 neutral report constants", switch2_neutral_reports_pack_documented_constants); + runner.run("Switch 2 report counter rollover", switch2_report_counters_roll_over_deterministically); + runner.run("Switch 2 report button mapping", switch2_reports_map_every_shared_button); + runner.run("Switch 2 report stick packing", switch2_reports_pack_twelve_bit_stick_extremes); + runner.run("Switch 2 report unknown fields", switch2_reports_zero_unknowns_and_ignore_imu); +}