diff --git a/.gitignore b/.gitignore index b95424e..38023be 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ Switch-Fightstick GP2040-CE build +build-aio debug .pycache *.egg-info diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..18b03e8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/bluepad32"] + path = external/bluepad32 + url = https://github.com/ricardoquesada/bluepad32.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 10a816f..aea6efe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,7 +24,20 @@ if (EXISTS ${picoVscode}) endif() # ==================================================================================== option(SWITCH_PICO_LOG "Enable UART debug logging" OFF) +set(SWITCH_PICO_INPUT_BACKEND "UART" CACHE STRING "Controller input backend") +set_property(CACHE SWITCH_PICO_INPUT_BACKEND PROPERTY STRINGS UART BLUEPAD32) +if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "UART" + AND NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") + message(FATAL_ERROR + "Unknown SWITCH_PICO_INPUT_BACKEND='${SWITCH_PICO_INPUT_BACKEND}'. " + "Expected UART or BLUEPAD32.") +endif() set(PICO_BOARD pico CACHE STRING "Board type") +if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32" + AND NOT PICO_BOARD STREQUAL "pico2_w") + message(FATAL_ERROR + "SWITCH_PICO_INPUT_BACKEND=BLUEPAD32 requires PICO_BOARD=pico2_w") +endif() # Pull in Raspberry Pi Pico SDK (must be before project) include(pico_sdk_import.cmake) @@ -34,12 +47,51 @@ project(switch-pico C CXX ASM) # Initialise the Raspberry Pi Pico SDK pico_sdk_init() +# Configure BLUEPAD32 input backend if selected +if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") + # Ensure Python3 is available and execute patch preparation + find_package(Python3 REQUIRED COMPONENTS Interpreter) + + set(BLUEPAD32_PREP_SCRIPT ${CMAKE_CURRENT_LIST_DIR}/tools/prepare_bluepad32.py) + execute_process( + COMMAND ${Python3_EXECUTABLE} ${BLUEPAD32_PREP_SCRIPT} + WORKING_DIRECTORY ${CMAKE_CURRENT_LIST_DIR} + RESULT_VARIABLE BLUEPAD32_PREP_RESULT + OUTPUT_VARIABLE BLUEPAD32_PREP_OUTPUT + ERROR_VARIABLE BLUEPAD32_PREP_ERROR + ) + + if(NOT BLUEPAD32_PREP_RESULT EQUAL 0) + message(FATAL_ERROR + "Failed to prepare Bluepad32: Patch application or validation failed. " + "Details: ${BLUEPAD32_PREP_ERROR}") + endif() + + # Configure Bluepad32 include paths and subdirectory + set(BLUEPAD32_ROOT ${CMAKE_CURRENT_LIST_DIR}/external/bluepad32) + set(BTSTACK_ROOT ${PICO_SDK_PATH}/lib/btstack) + include_directories( + ${CMAKE_CURRENT_LIST_DIR}/bluepad32_config + ${BTSTACK_ROOT}/3rd-party/bluedroid/encoder/include + ${BTSTACK_ROOT}/3rd-party/bluedroid/decoder/include + ${BTSTACK_ROOT}/src + ) + add_subdirectory( + ${BLUEPAD32_ROOT}/src/components/bluepad32 + ${CMAKE_CURRENT_BINARY_DIR}/libbluepad32 + ) +endif() + # Add executable. Default name is the project name, version 0.1 add_executable(switch-pico switch-pico.cpp switch_pro_driver.cpp ) +if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") + target_sources(switch-pico PRIVATE bluepad32_input_backend.cpp) + target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_BLUEPAD32=1) +endif() pico_set_program_name(switch-pico "switch-pico") pico_set_program_version(switch-pico "0.1") @@ -57,6 +109,16 @@ target_link_libraries(switch-pico hardware_uart pico_rand ) +if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") + target_link_libraries(switch-pico + bluepad32 + pico_cyw43_arch_none + pico_btstack_ble + pico_btstack_classic + pico_btstack_cyw43 + pico_multicore + ) +endif() if (SWITCH_PICO_LOG) target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_LOG=1) diff --git a/bluepad32_config/btstack_config.h b/bluepad32_config/btstack_config.h new file mode 100644 index 0000000..95072bf --- /dev/null +++ b/bluepad32_config/btstack_config.h @@ -0,0 +1,82 @@ +#ifndef _PICO_BTSTACK_BTSTACK_CONFIG_H +#define _PICO_BTSTACK_BTSTACK_CONFIG_H + +// Based on Bluepad32's official Pico W configuration. ENABLE_BLE and +// ENABLE_CLASSIC are supplied by the corresponding Pico SDK BTstack targets. +#define ENABLE_LOG_INFO +#define ENABLE_LOG_ERROR +#define ENABLE_PRINTF_HEXDUMP +#define ENABLE_SCO_OVER_HCI + +#ifdef ENABLE_BLE +#define ENABLE_GATT_CLIENT_PAIRING +#define ENABLE_L2CAP_LE_CREDIT_BASED_FLOW_CONTROL_MODE +#define ENABLE_LE_CENTRAL +#define ENABLE_LE_DATA_LENGTH_EXTENSION +#define ENABLE_LE_PERIPHERAL +#define ENABLE_LE_PRIVACY_ADDRESS_RESOLUTION +#define ENABLE_LE_SECURE_CONNECTIONS +#else +#error "BP32: ENABLE_BLE should be defined" +#endif + +#ifdef ENABLE_CLASSIC +#define ENABLE_L2CAP_ENHANCED_RETRANSMISSION_MODE +#define ENABLE_GOEP_L2CAP +#else +#error "BP32: ENABLE_CLASSIC should be defined" +#endif + +#if defined(ENABLE_CLASSIC) && defined(ENABLE_BLE) +#define ENABLE_CROSS_TRANSPORT_KEY_DERIVATION +#endif + +#define HCI_OUTGOING_PRE_BUFFER_SIZE 4 +#define HCI_ACL_PAYLOAD_SIZE (1691 + 4) +#define HCI_ACL_CHUNK_SIZE_ALIGNMENT 4 +#define MAX_NR_AVDTP_CONNECTIONS 1 +#define MAX_NR_AVDTP_STREAM_ENDPOINTS 1 +#define MAX_NR_AVRCP_CONNECTIONS 2 +#define MAX_NR_BNEP_CHANNELS 1 +#define MAX_NR_BNEP_SERVICES 1 +#define MAX_NR_BTSTACK_LINK_KEY_DB_MEMORY_ENTRIES 2 +#define MAX_NR_GATT_CLIENTS 1 +#define MAX_NR_HCI_CONNECTIONS 4 +#define MAX_NR_HID_HOST_CONNECTIONS 1 +#define MAX_NR_HIDS_CLIENTS 1 +#define MAX_NR_HFP_CONNECTIONS 1 +#define MAX_NR_L2CAP_CHANNELS 6 +#define MAX_NR_L2CAP_SERVICES 5 +#define MAX_NR_RFCOMM_CHANNELS 1 +#define MAX_NR_RFCOMM_MULTIPLEXERS 1 +#define MAX_NR_RFCOMM_SERVICES 1 +#define MAX_NR_SERVICE_RECORD_ITEMS 4 +#define MAX_NR_SM_LOOKUP_ENTRIES 3 +#define MAX_NR_WHITELIST_ENTRIES 16 +#define MAX_NR_LE_DEVICE_DB_ENTRIES 16 + +// Keep controller buffers and controller-to-host flow control enabled to avoid +// overrunning the shared CYW43 bus. +#define MAX_NR_CONTROLLER_ACL_BUFFERS 3 +#define MAX_NR_CONTROLLER_SCO_PACKETS 3 +#define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL +#define HCI_HOST_ACL_PACKET_LEN 1024 +#define HCI_HOST_ACL_PACKET_NUM 3 +#define HCI_HOST_SCO_PACKET_LEN 120 +#define HCI_HOST_SCO_PACKET_NUM 3 + +// Persistent Classic and BLE pairing databases use Pico flash-backed TLV. +#define NVM_NUM_DEVICE_DB_ENTRIES 16 +#define NVM_NUM_LINK_KEYS 16 + +// Bluepad32 does not provide malloc to BTstack. +#define MAX_ATT_DB_SIZE 512 + +#define HAVE_EMBEDDED_TIME_MS +#define HAVE_ASSERT +#define HCI_RESET_RESEND_TIMEOUT_MS 1000 +#define ENABLE_SOFTWARE_AES128 +#define ENABLE_MICRO_ECC_FOR_LE_SECURE_CONNECTIONS +#define HAVE_BTSTACK_STDIN + +#endif // _PICO_BTSTACK_BTSTACK_CONFIG_H diff --git a/bluepad32_config/sdkconfig.h b/bluepad32_config/sdkconfig.h new file mode 100644 index 0000000..83002a9 --- /dev/null +++ b/bluepad32_config/sdkconfig.h @@ -0,0 +1,13 @@ +#pragma once + +// Bluepad32's Pico W example configuration, limited to one live controller. +#define CONFIG_BLUEPAD32_MAX_DEVICES 1 +#define CONFIG_BLUEPAD32_MAX_ALLOWLIST 1 +#define CONFIG_BLUEPAD32_GAP_SECURITY 1 +#define CONFIG_BLUEPAD32_ENABLE_BLE_BY_DEFAULT 1 + +#define CONFIG_BLUEPAD32_PLATFORM_CUSTOM +#define CONFIG_TARGET_PICO_W + +// 2 == Info +#define CONFIG_BLUEPAD32_LOG_LEVEL 2 diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp new file mode 100644 index 0000000..6bd3c2f --- /dev/null +++ b/bluepad32_input_backend.cpp @@ -0,0 +1,394 @@ +#include "bluepad32_input_backend.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr uint16_t kStickMidpoint = 32768; +constexpr int32_t kAxisMinimum = -512; +constexpr int32_t kAxisMaximum = 511; +constexpr int32_t kTriggerMaximum = 1023; +constexpr int32_t kTriggerThreshold = (kTriggerMaximum * 35) / 100; +constexpr uint16_t kRumbleDurationMs = 50; +constexpr uint32_t kRumblePollIntervalMs = 5; +constexpr uint kRumbleQueueDepth = 8; + +struct RumblePacket { + uint8_t bytes[8]; +}; + +critical_section_t g_state_lock; +queue_t g_rumble_queue; +SwitchInputState g_shared_state; +bool g_shared_controller_active = false; +uint32_t g_shared_generation = 0; + +// These generations are only read or written by Core 0. +uint32_t g_consumed_generation = 0; +uint32_t g_last_snapshot_generation = 0; +bool g_initialized = false; +bool g_started = false; + +// This pointer and the timer are only read or written by Core 1 / BTstack. +uni_hid_device_t* g_active_device = nullptr; +btstack_timer_source_t g_rumble_timer{}; + +SwitchInputState make_neutral_state() { + SwitchInputState state{}; + state.lx = kStickMidpoint; + state.ly = kStickMidpoint; + state.rx = kStickMidpoint; + state.ry = kStickMidpoint; + return state; +} + +void publish_state(const SwitchInputState& state, bool controller_active) { + critical_section_enter_blocking(&g_state_lock); + g_shared_state = state; + g_shared_controller_active = controller_active; + ++g_shared_generation; + critical_section_exit(&g_state_lock); +} + +int32_t clamp_axis(int32_t value) { + if (value < kAxisMinimum) { + return kAxisMinimum; + } + if (value > kAxisMaximum) { + return kAxisMaximum; + } + return value; +} + +uint16_t scale_stick(int32_t value) { + value = clamp_axis(value); + if (value <= 0) { + return static_cast( + (static_cast(value - kAxisMinimum) * kStickMidpoint) / -kAxisMinimum); + } + return static_cast( + kStickMidpoint + (static_cast(value) * (UINT16_MAX - kStickMidpoint)) / kAxisMaximum); +} + +int16_t clamp_int16(int64_t value) { + if (value < INT16_MIN) { + return INT16_MIN; + } + if (value > INT16_MAX) { + return INT16_MAX; + } + return static_cast(value); +} + +int64_t divide_round_nearest(int64_t numerator, int64_t denominator) { + if (numerator >= 0) { + return (numerator + denominator / 2) / denominator; + } + return -((-numerator + denominator / 2) / denominator); +} + +int16_t convert_accel(int64_t q13_value) { + return clamp_int16(q13_value / 2); +} + +int16_t convert_gyro(int64_t q10_value) { + constexpr int64_t kNumeratorScale = 13371; + constexpr int64_t kDenominator = 1024 * 936; + return clamp_int16(divide_round_nearest(q10_value * kNumeratorScale, kDenominator)); +} + +bool has_motion(const uni_gamepad_t& gamepad) { + for (size_t i = 0; i < 3; ++i) { + if (gamepad.accel[i] != 0 || gamepad.gyro[i] != 0) { + return true; + } + } + return false; +} + +SwitchInputState map_gamepad(const uni_gamepad_t& gamepad) { + SwitchInputState state = make_neutral_state(); + + state.dpad_up = (gamepad.dpad & DPAD_UP) != 0; + state.dpad_down = (gamepad.dpad & DPAD_DOWN) != 0; + state.dpad_left = (gamepad.dpad & DPAD_LEFT) != 0; + state.dpad_right = (gamepad.dpad & DPAD_RIGHT) != 0; + + // Bluepad32's A/B/X/Y are positional: south/east/west/north. + state.button_b = (gamepad.buttons & BUTTON_A) != 0; + state.button_a = (gamepad.buttons & BUTTON_B) != 0; + state.button_y = (gamepad.buttons & BUTTON_X) != 0; + state.button_x = (gamepad.buttons & BUTTON_Y) != 0; + state.button_l = (gamepad.buttons & BUTTON_SHOULDER_L) != 0; + state.button_r = (gamepad.buttons & BUTTON_SHOULDER_R) != 0; + state.button_zl = (gamepad.buttons & BUTTON_TRIGGER_L) != 0 || gamepad.brake >= kTriggerThreshold; + state.button_zr = (gamepad.buttons & BUTTON_TRIGGER_R) != 0 || gamepad.throttle >= kTriggerThreshold; + state.button_l3 = (gamepad.buttons & BUTTON_THUMB_L) != 0; + state.button_r3 = (gamepad.buttons & BUTTON_THUMB_R) != 0; + + state.button_minus = (gamepad.misc_buttons & MISC_BUTTON_SELECT) != 0; + state.button_plus = (gamepad.misc_buttons & MISC_BUTTON_START) != 0; + state.button_home = (gamepad.misc_buttons & MISC_BUTTON_SYSTEM) != 0; + state.button_capture = (gamepad.misc_buttons & MISC_BUTTON_CAPTURE) != 0; + + state.lx = scale_stick(gamepad.axis_x); + state.ly = scale_stick(gamepad.axis_y); + state.rx = scale_stick(gamepad.axis_rx); + state.ry = scale_stick(gamepad.axis_ry); + + if (has_motion(gamepad)) { + // Dependency patches normalize both arrays to SDL3 PlayStation axes. + SwitchImuSample sample{}; + sample.accel_x = convert_accel(-static_cast(gamepad.accel[2])); + sample.accel_y = convert_accel(-static_cast(gamepad.accel[0])); + sample.accel_z = convert_accel(gamepad.accel[1]); + sample.gyro_x = convert_gyro(-static_cast(gamepad.gyro[2])); + sample.gyro_y = convert_gyro(-static_cast(gamepad.gyro[0])); + sample.gyro_z = convert_gyro(gamepad.gyro[1]); + state.imu_sample_count = 3; + for (SwitchImuSample& destination : state.imu_samples) { + destination = sample; + } + } + + return state; +} + +void decode_rumble(const uint8_t bytes[8], uint8_t* left_magnitude, uint8_t* right_magnitude) { + static constexpr uint8_t kNeutralPacket[8] = {0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40}; + if (memcmp(bytes, kNeutralPacket, sizeof(kNeutralPacket)) == 0) { + *left_magnitude = 0; + *right_magnitude = 0; + return; + } + + uint16_t right_raw = static_cast(((bytes[1] & 0x03) << 8) | bytes[0]); + uint16_t left_raw = static_cast(((bytes[5] & 0x03) << 8) | bytes[4]); + if (left_raw < 8 && right_raw < 8) { + left_raw = 0; + right_raw = 0; + } + + *left_magnitude = static_cast((left_raw * UINT8_MAX + 511) / 1023); + *right_magnitude = static_cast((right_raw * UINT8_MAX + 511) / 1023); +} + +void process_rumble_timer(btstack_timer_source_t* timer) { + RumblePacket packet{}; + RumblePacket latest{}; + bool have_packet = false; + while (queue_try_remove(&g_rumble_queue, &packet)) { + latest = packet; + have_packet = true; + } + + if (have_packet && g_active_device != nullptr && + g_active_device->report_parser.play_dual_rumble != nullptr) { + uint8_t left_magnitude = 0; + uint8_t right_magnitude = 0; + decode_rumble(latest.bytes, &left_magnitude, &right_magnitude); + // Bluepad orders the weak (high-frequency) motor before the strong + // (low-frequency) motor; the project decoder names those right/left. + g_active_device->report_parser.play_dual_rumble( + g_active_device, 0, kRumbleDurationMs, right_magnitude, left_magnitude); + } + + btstack_run_loop_set_timer(timer, kRumblePollIntervalMs); + btstack_run_loop_add_timer(timer); +} + +void platform_init(int argc, const char** argv) { + (void)argc; + (void)argv; +} + +void platform_on_init_complete() { + btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer); + btstack_run_loop_set_timer(&g_rumble_timer, kRumblePollIntervalMs); + btstack_run_loop_add_timer(&g_rumble_timer); + + uni_bt_allow_incoming_connections(true); + uni_bt_start_scanning_and_autoconnect_unsafe(); +} + +uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, uint16_t cod, uint8_t rssi) { + (void)addr; + (void)name; + (void)cod; + (void)rssi; + return g_active_device == nullptr ? UNI_ERROR_SUCCESS : UNI_ERROR_IGNORE_DEVICE; +} + +void platform_on_device_connected(uni_hid_device_t* device) { + (void)device; +} + +void resume_connections() { + uni_bt_allow_incoming_connections(true); + uni_bt_start_scanning_and_autoconnect_unsafe(); +} + +void platform_on_device_disconnected(uni_hid_device_t* device) { + if (device == g_active_device) { + g_active_device = nullptr; + publish_state(make_neutral_state(), false); + resume_connections(); + } else if (g_active_device == nullptr) { + resume_connections(); + } +} + +uni_error_t platform_on_device_ready(uni_hid_device_t* device) { + if (!uni_hid_device_is_gamepad(device)) { + return UNI_ERROR_INVALID_CONTROLLER; + } + if (g_active_device != nullptr && g_active_device != device) { + return UNI_ERROR_NO_SLOTS; + } + + g_active_device = device; + publish_state(make_neutral_state(), true); + uni_bt_stop_scanning_unsafe(); + uni_bt_allow_incoming_connections(false); + return UNI_ERROR_SUCCESS; +} + +void platform_on_controller_data(uni_hid_device_t* device, uni_controller_t* controller) { + if (device != g_active_device || controller == nullptr || controller->klass != UNI_CONTROLLER_CLASS_GAMEPAD) { + return; + } + publish_state(map_gamepad(controller->gamepad), true); +} + +const uni_property_t* platform_get_property(uni_property_idx_t index) { + (void)index; + return nullptr; +} + +void platform_on_oob_event(uni_platform_oob_event_t event, void* data) { + (void)event; + (void)data; +} + +uni_platform* get_platform() { + static uni_platform platform = { + "Switch Pico", + platform_init, + platform_on_init_complete, + platform_on_device_discovered, + platform_on_device_connected, + platform_on_device_disconnected, + platform_on_device_ready, + nullptr, + platform_on_controller_data, + platform_get_property, + platform_on_oob_event, + nullptr, + nullptr, + }; + return &platform; +} + +[[noreturn]] void core1_main() { + if (cyw43_arch_init() != 0) { + publish_state(make_neutral_state(), false); + while (true) { + tight_loop_contents(); + } + } + + uni_platform_set_custom(get_platform()); + if (uni_init(0, nullptr) != 0) { + publish_state(make_neutral_state(), false); + while (true) { + tight_loop_contents(); + } + } + + btstack_run_loop_execute(); + while (true) { + tight_loop_contents(); + } +} + +} // namespace + +void bluepad32_input_backend_init() { + if (g_initialized) { + return; + } + + critical_section_init(&g_state_lock); + queue_init(&g_rumble_queue, sizeof(RumblePacket), kRumbleQueueDepth); + g_shared_state = make_neutral_state(); + g_shared_controller_active = false; + g_shared_generation = 0; + g_consumed_generation = 0; + g_last_snapshot_generation = 0; + g_initialized = true; +} + +void bluepad32_input_backend_start() { + if (!g_initialized) { + bluepad32_input_backend_init(); + } + if (g_started) { + return; + } + + g_started = true; + multicore_launch_core1(core1_main); +} + +bool bluepad32_input_backend_snapshot(SwitchInputState* out) { + if (out == nullptr) { + return false; + } + if (!g_initialized) { + *out = make_neutral_state(); + return false; + } + + critical_section_enter_blocking(&g_state_lock); + *out = g_shared_state; + const bool controller_active = g_shared_controller_active; + const uint32_t generation = g_shared_generation; + critical_section_exit(&g_state_lock); + + if (generation == g_consumed_generation) { + out->imu_sample_count = 0; + } + g_last_snapshot_generation = generation; + return controller_active; +} + +void bluepad32_input_backend_report_sent() { + if (!g_initialized) { + return; + } + g_consumed_generation = g_last_snapshot_generation; +} + +void bluepad32_input_backend_queue_rumble(const uint8_t rumble[8]) { + if (!g_initialized || rumble == nullptr) { + return; + } + + RumblePacket packet{}; + memcpy(packet.bytes, rumble, sizeof(packet.bytes)); + if (!queue_try_add(&g_rumble_queue, &packet)) { + RumblePacket discarded{}; + (void)queue_try_remove(&g_rumble_queue, &discarded); + (void)queue_try_add(&g_rumble_queue, &packet); + } +} diff --git a/bluepad32_input_backend.h b/bluepad32_input_backend.h new file mode 100644 index 0000000..df5cc56 --- /dev/null +++ b/bluepad32_input_backend.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +#include "switch_pro_driver.h" + +void bluepad32_input_backend_init(); +void bluepad32_input_backend_start(); +bool bluepad32_input_backend_snapshot(SwitchInputState* out); +void bluepad32_input_backend_report_sent(); +void bluepad32_input_backend_queue_rumble(const uint8_t rumble[8]); diff --git a/build.py b/build.py index 95c537c..8b728d3 100644 --- a/build.py +++ b/build.py @@ -12,9 +12,12 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent CONFIG_FILE = SCRIPT_DIR / "controller_color_config.h" BUILD_DIR = SCRIPT_DIR / "build" +AIO_BUILD_DIR = SCRIPT_DIR / "build-aio" FIRMWARE_DIR = SCRIPT_DIR / "firmware" FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico.elf" FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico.uf2" +AIO_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-aio.elf" +AIO_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-aio.uf2" ELF_PATH = Path(os.environ.get("ELF_PATH", BUILD_DIR / "switch-pico.elf")).expanduser() UF2_PATH = Path(os.environ.get("UF2_PATH", BUILD_DIR / "switch-pico.uf2")).expanduser() @@ -34,6 +37,11 @@ def parse_args(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Default behavior leaves controller_color_config.h unchanged.", ) + parser.add_argument( + "--aio", + action="store_true", + help="Build and flash the Pico 2 W Bluepad32 all-in-one firmware.", + ) group = parser.add_mutually_exclusive_group() group.add_argument( "--random-grip-color", @@ -111,41 +119,68 @@ def resolve_picotool(): sys.stderr.write("Error: picotool not found. Put it on your PATH or set PICOTOOL_PATH.\n") sys.exit(1) -def build(): +def build( + aio, + build_dir, + elf_path, + uf2_path, + firmware_elf_path, + firmware_uf2_path, +): + if aio: + run_cmd([sys.executable, str(SCRIPT_DIR / "tools" / "prepare_bluepad32.py")]) + definitions = [ + "-DSWITCH_PICO_LOG=OFF", + "-DPICO_BOARD=pico2_w", + "-DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32", + ] + else: + definitions = [ + "-DSWITCH_PICO_LOG=OFF", + "-DPICO_BOARD=pico", + "-DSWITCH_PICO_INPUT_BACKEND=UART", + ] + run_cmd( [ "cmake", "-S", str(SCRIPT_DIR), "-B", - str(BUILD_DIR), - "-DSWITCH_PICO_LOG=OFF", + str(build_dir), + *definitions, ] ) - run_cmd(["cmake", "--build", str(BUILD_DIR)]) + run_cmd(["cmake", "--build", str(build_dir)]) - missing_artifacts = [path for path in (ELF_PATH, UF2_PATH) if not path.is_file()] + missing_artifacts = [ + path for path in (elf_path, uf2_path) if not path.is_file() + ] if missing_artifacts: missing = ", ".join(str(path) for path in missing_artifacts) sys.stderr.write(f"Error: Build did not produce required artifact(s): {missing}\n") sys.exit(1) FIRMWARE_DIR.mkdir(parents=True, exist_ok=True) - shutil.copy2(ELF_PATH, FIRMWARE_ELF_PATH) - shutil.copy2(UF2_PATH, FIRMWARE_UF2_PATH) + shutil.copy2(elf_path, firmware_elf_path) + shutil.copy2(uf2_path, firmware_uf2_path) - print(f"Built ELF: {ELF_PATH}") - print(f"Built UF2: {UF2_PATH}") - print(f"Copied ELF: {FIRMWARE_ELF_PATH}") - print(f"Copied UF2: {FIRMWARE_UF2_PATH}") + print(f"Built ELF: {elf_path}") + print(f"Built UF2: {uf2_path}") + print(f"Copied ELF: {firmware_elf_path}") + print(f"Copied UF2: {firmware_uf2_path}") -def flash(): + +def flash(elf_path, allow_elf_override): picotool = resolve_picotool() - if not ELF_PATH.exists(): - sys.stderr.write( - f"Error: Cannot find ELF at {ELF_PATH}. Set ELF_PATH to override.\n" - ) + if not elf_path.exists(): + if allow_elf_override: + sys.stderr.write( + f"Error: Cannot find ELF at {elf_path}. Set ELF_PATH to override.\n" + ) + else: + sys.stderr.write(f"Error: Cannot find ELF at {elf_path}.\n") sys.exit(1) - run_cmd([str(picotool), "load", str(ELF_PATH), "-fx"]) + run_cmd([str(picotool), "load", str(elf_path), "-fx"]) def main(): args = parse_args() @@ -164,8 +199,28 @@ def main(): update_grip_colors(color) print(f"Grip color set to #{color} in {CONFIG_FILE.name}") - build() - flash() + if args.aio: + build_dir = AIO_BUILD_DIR + elf_path = AIO_BUILD_DIR / "switch-pico.elf" + uf2_path = AIO_BUILD_DIR / "switch-pico.uf2" + firmware_elf_path = AIO_FIRMWARE_ELF_PATH + firmware_uf2_path = AIO_FIRMWARE_UF2_PATH + else: + build_dir = BUILD_DIR + elf_path = ELF_PATH + uf2_path = UF2_PATH + firmware_elf_path = FIRMWARE_ELF_PATH + firmware_uf2_path = FIRMWARE_UF2_PATH + + build( + args.aio, + build_dir, + elf_path, + uf2_path, + firmware_elf_path, + firmware_uf2_path, + ) + flash(elf_path, allow_elf_override=not args.aio) if __name__ == "__main__": main() diff --git a/external/bluepad32 b/external/bluepad32 new file mode 160000 index 0000000..6efa712 --- /dev/null +++ b/external/bluepad32 @@ -0,0 +1 @@ +Subproject commit 6efa7123fe8badf5a40ad1205743a80b31c00ea4 diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf new file mode 100755 index 0000000..166e1b1 Binary files /dev/null and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 new file mode 100644 index 0000000..8e4968d Binary files /dev/null and b/firmware/switch-pico-aio.uf2 differ diff --git a/patches/bluepad32-sdl3-imu.patch b/patches/bluepad32-sdl3-imu.patch new file mode 100644 index 0000000..7060f78 --- /dev/null +++ b/patches/bluepad32-sdl3-imu.patch @@ -0,0 +1,154 @@ +diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds4.c b/src/components/bluepad32/parser/uni_hid_parser_ds4.c +index ea063b8..7670caf 100644 +--- a/src/components/bluepad32/parser/uni_hid_parser_ds4.c ++++ b/src/components/bluepad32/parser/uni_hid_parser_ds4.c +@@ -297,17 +297,17 @@ void uni_hid_parser_ds4_parse_feature_report(uni_hid_device_t* d, const uint8_t* + // Set gyroscope calibration and normalization parameters. + // Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s. + speed_2x = r->gyro_speed_plus + r->gyro_speed_minus; +- ins->gyro_calib_data[0].bias = 0; ++ ins->gyro_calib_data[0].bias = r->gyro_pitch_bias; + ins->gyro_calib_data[0].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S; + ins->gyro_calib_data[0].sens_denom = + abs(r->gyro_pitch_plus - r->gyro_pitch_bias) + abs(r->gyro_pitch_minus + r->gyro_pitch_bias); + +- ins->gyro_calib_data[1].bias = 0; ++ ins->gyro_calib_data[1].bias = r->gyro_yaw_bias; + ins->gyro_calib_data[1].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S; + ins->gyro_calib_data[1].sens_denom = + abs(r->gyro_yaw_plus - r->gyro_yaw_bias) + abs(r->gyro_yaw_minus - r->gyro_yaw_bias); + +- ins->gyro_calib_data[2].bias = 0; ++ ins->gyro_calib_data[2].bias = r->gyro_roll_bias; + ins->gyro_calib_data[2].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S; + ins->gyro_calib_data[2].sens_denom = + abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias); +@@ -476,7 +476,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor + + // Gyro + for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) { +- int32_t raw_data = (int16_t)r->gyro[i]; ++ int32_t raw_data = (int16_t)r->gyro[i] - ins->gyro_calib_data[i].bias; + int32_t calib_data = + mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom); + ctl->gamepad.gyro[i] = calib_data; +@@ -484,7 +484,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor + + // Accel + for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) { +- int32_t raw_data = (int16_t)r->accel[i]; ++ int32_t raw_data = (int16_t)r->accel[i] - ins->accel_calib_data[i].bias; + int32_t calib_data = + mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom); + ctl->gamepad.accel[i] = calib_data; +diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds5.c b/src/components/bluepad32/parser/uni_hid_parser_ds5.c +index a22ef26..3d5ecef 100644 +--- a/src/components/bluepad32/parser/uni_hid_parser_ds5.c ++++ b/src/components/bluepad32/parser/uni_hid_parser_ds5.c +@@ -487,17 +487,17 @@ void uni_hid_parser_ds5_parse_feature_report(uni_hid_device_t* d, const uint8_t* + // Set gyroscope calibration and normalization parameters. + // Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s. + speed_2x = r->gyro_speed_plus + r->gyro_speed_minus; +- ins->gyro_calib_data[0].bias = 0; ++ ins->gyro_calib_data[0].bias = r->gyro_pitch_bias; + ins->gyro_calib_data[0].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S; + ins->gyro_calib_data[0].sens_denom = + abs(r->gyro_pitch_plus - r->gyro_pitch_bias) + abs(r->gyro_pitch_minus + r->gyro_pitch_bias); + +- ins->gyro_calib_data[1].bias = 0; ++ ins->gyro_calib_data[1].bias = r->gyro_yaw_bias; + ins->gyro_calib_data[1].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S; + ins->gyro_calib_data[1].sens_denom = + abs(r->gyro_yaw_plus - r->gyro_yaw_bias) + abs(r->gyro_yaw_minus - r->gyro_yaw_bias); + +- ins->gyro_calib_data[2].bias = 0; ++ ins->gyro_calib_data[2].bias = r->gyro_roll_bias; + ins->gyro_calib_data[2].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S; + ins->gyro_calib_data[2].sens_denom = + abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias); +@@ -622,7 +622,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r + + // Gyro + for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) { +- int32_t raw_data = (int16_t)r->gyro[i]; ++ int32_t raw_data = (int16_t)r->gyro[i] - ins->gyro_calib_data[i].bias; + int32_t calib_data = + mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom); + ctl->gamepad.gyro[i] = calib_data; +@@ -630,7 +630,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r + + // Accel + for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) { +- int32_t raw_data = (int16_t)r->accel[i]; ++ int32_t raw_data = (int16_t)r->accel[i] - ins->accel_calib_data[i].bias; + int32_t calib_data = + mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom); + ctl->gamepad.accel[i] = calib_data; +diff --git a/src/components/bluepad32/parser/uni_hid_parser_switch.c b/src/components/bluepad32/parser/uni_hid_parser_switch.c +index 599fc35..c72f056 100644 +--- a/src/components/bluepad32/parser/uni_hid_parser_switch.c ++++ b/src/components/bluepad32/parser/uni_hid_parser_switch.c +@@ -51,7 +51,8 @@ static const int16_t DEFAULT_ACCEL_OFFSET = 0; + static const int16_t DEFAULT_ACCEL_SCALE = 16384; + static const int16_t DEFAULT_GYRO_OFFSET = 0; + static const int16_t DEFAULT_GYRO_SCALE = 13371; +-#define SWITCH_IMU_PREC_RANGE_SCALE 1000 ++#define SWITCH_IMU_GYRO_RES_PER_DEG_S 1024 ++#define SWITCH_IMU_ACCEL_RES_PER_G 8192 + + #define SWITCH_FACTORY_IMU_CAL_DATA_SIZE 24 + static const uint16_t SWITCH_FACTORY_IMU_CAL_DATA_ADDR = 0x6020; +@@ -823,19 +824,26 @@ static void parse_imu(uni_hid_device_t* d, const struct switch_imu_data_s* r) { + switch_instance_t* ins = get_switch_instance(d); + uni_controller_t* ctl = &d->controller; + +- int accel[3]; +- int gyro[3]; ++ int32_t accel[3]; ++ int32_t gyro[3]; + + for (int i = 0; i < 3; i++) { +- if (ins->imu_cal_accel_divisor[i] == 0) +- accel[i] = r->accel[i]; +- else +- accel[i] = (r->accel[i] * ins->cal_accel.scale[i]) / ins->imu_cal_accel_divisor[i]; +- gyro[i] = mult_frac((SWITCH_IMU_PREC_RANGE_SCALE * (r->gyro[i] - ins->cal_gyro.offset[i])), +- ins->cal_gyro.scale[i], ins->imu_cal_gyro_divisor[i]); ++ if (ins->imu_cal_accel_divisor[i] == 0) { ++ accel[i] = r->accel[i] * 2; ++ } else { ++ accel[i] = mult_frac(r->accel[i], 4 * SWITCH_IMU_ACCEL_RES_PER_G, ins->imu_cal_accel_divisor[i]); ++ } ++ ++ if (ins->imu_cal_gyro_divisor[i] == 0) { ++ gyro[i] = mult_frac(r->gyro[i], 936 * SWITCH_IMU_GYRO_RES_PER_DEG_S, DEFAULT_GYRO_SCALE); ++ } else { ++ gyro[i] = mult_frac(r->gyro[i] - ins->cal_gyro.offset[i], ++ 936 * SWITCH_IMU_GYRO_RES_PER_DEG_S, ++ ins->imu_cal_gyro_divisor[i]); ++ } + } + +- // Right joycon has Y and Z axes negated. ++ // Right Joy-Con has native Y and Z axes negated. + if (ins->controller_type == SWITCH_CONTROLLER_TYPE_JCR) { + accel[1] = -accel[1]; + accel[2] = -accel[2]; +@@ -843,10 +851,13 @@ static void parse_imu(uni_hid_device_t* d, const struct switch_imu_data_s* r) { + gyro[2] = -gyro[2]; + } + +- for (int i = 0; i < 3; i++) { +- ctl->gamepad.accel[i] = accel[i]; +- ctl->gamepad.gyro[i] = gyro[i]; +- } ++ // Match SDL3's PlayStation-oriented sensor coordinate convention. ++ ctl->gamepad.accel[0] = -accel[1]; ++ ctl->gamepad.accel[1] = accel[2]; ++ ctl->gamepad.accel[2] = -accel[0]; ++ ctl->gamepad.gyro[0] = -gyro[1]; ++ ctl->gamepad.gyro[1] = gyro[2]; ++ ctl->gamepad.gyro[2] = -gyro[0]; + } + + // Process 0x30 input report: SWITCH_INPUT_IMU_DATA diff --git a/switch-pico.cpp b/switch-pico.cpp index 22beaef..22a3083 100644 --- a/switch-pico.cpp +++ b/switch-pico.cpp @@ -1,10 +1,14 @@ #include #include #include "bsp/board.h" -#include "hardware/uart.h" #include "pico/stdlib.h" #include "tusb.h" #include "switch_pro_driver.h" +#ifndef SWITCH_PICO_BLUEPAD32 +#include "hardware/uart.h" +#else +#include "bluepad32_input_backend.h" +#endif #ifdef SWITCH_PICO_LOG #define LOG_PRINTF(...) printf(__VA_ARGS__) @@ -12,6 +16,7 @@ #define LOG_PRINTF(...) ((void)0) #endif +#ifndef SWITCH_PICO_BLUEPAD32 // UART1 is reserved for external input frames from the host PC. #define UART_ID uart1 #define BAUD_RATE 921600 @@ -19,6 +24,7 @@ #define UART_RX_PIN 5 #define UART_RUMBLE_HEADER 0xBB #define UART_RUMBLE_RUMBLE_TYPE 0x01 +#endif static bool g_last_mounted = false; static bool g_last_ready = false; @@ -26,12 +32,14 @@ static bool g_last_ready = false; // Track the latest state provided by UART or the autopilot. static SwitchInputState g_user_state; +#ifndef SWITCH_PICO_BLUEPAD32 static void init_uart_input() { uart_init(UART_ID, BAUD_RATE); gpio_set_function(UART_TX_PIN, GPIO_FUNC_UART); gpio_set_function(UART_RX_PIN, GPIO_FUNC_UART); uart_set_format(UART_ID, 8, 1, UART_PARITY_NONE); } +#endif static SwitchInputState neutral_input() { SwitchInputState state{}; @@ -42,6 +50,7 @@ static SwitchInputState neutral_input() { return state; } +#ifndef SWITCH_PICO_BLUEPAD32 static void send_rumble_uart_frame(const uint8_t rumble[8]) { uint8_t frame[11]; frame[0] = UART_RUMBLE_HEADER; @@ -55,11 +64,17 @@ static void send_rumble_uart_frame(const uint8_t rumble[8]) { frame[10] = checksum; uart_write_blocking(UART_ID, frame, sizeof(frame)); } +#endif static void on_rumble_from_switch(const uint8_t rumble[8]) { +#ifdef SWITCH_PICO_BLUEPAD32 + bluepad32_input_backend_queue_rumble(rumble); +#else send_rumble_uart_frame(rumble); +#endif } +#ifndef SWITCH_PICO_BLUEPAD32 // Consume UART bytes and forward complete frames to the Switch Pro driver. static bool poll_uart_frames() { static uint8_t buffer[64]; @@ -134,6 +149,7 @@ static bool poll_uart_frames() { return new_data; } +#endif static void log_usb_state() { bool mounted = tud_mounted(); @@ -153,7 +169,11 @@ int main() { board_init(); stdio_init_all(); +#ifdef SWITCH_PICO_BLUEPAD32 + bluepad32_input_backend_init(); +#else init_uart_input(); +#endif tusb_init(); switch_pro_init(); @@ -161,17 +181,32 @@ int main() { g_user_state = neutral_input(); switch_pro_set_input(g_user_state); +#ifdef SWITCH_PICO_BLUEPAD32 + bluepad32_input_backend_start(); + LOG_PRINTF("[BOOT] switch-pico starting (Bluepad32 wireless @ 115200)\n"); +#else LOG_PRINTF("[BOOT] switch-pico starting (UART0 log @ 115200)\n"); LOG_PRINTF("[INFO] UART1 pins TX=%d RX=%d baud=%d\n", UART_TX_PIN, UART_RX_PIN, BAUD_RATE); +#endif while (true) { tud_task(); // USB device tasks +#ifdef SWITCH_PICO_BLUEPAD32 + bluepad32_input_backend_snapshot(&g_user_state); +#else bool new_data = poll_uart_frames(); // Pull controller state from UART1 (void)new_data; +#endif SwitchInputState state = g_user_state; switch_pro_set_input(state); - switch_pro_task(); // Push state to the Switch host +#ifdef SWITCH_PICO_BLUEPAD32 + if (switch_pro_task()) { + bluepad32_input_backend_report_sent(); + } +#else + (void)switch_pro_task(); +#endif log_usb_state(); } } diff --git a/switch_pro_driver.cpp b/switch_pro_driver.cpp index 4d26922..6417c68 100644 --- a/switch_pro_driver.cpp +++ b/switch_pro_driver.cpp @@ -753,9 +753,10 @@ void switch_pro_set_input(const SwitchInputState& state) { g_input_state = state; } -void switch_pro_task() { +bool switch_pro_task() { uint32_t now = to_ms_since_boot(get_absolute_time()); report_sent = false; + bool regular_report_sent = false; update_switch_report_from_state(); @@ -784,6 +785,7 @@ void switch_pro_task() { memcpy(last_report, inputReport, report_size); g_input_state.imu_sample_count = 0; report_sent = true; + regular_report_sent = true; } last_report_timer = now; @@ -799,6 +801,7 @@ void switch_pro_task() { last_report_timer = now; } } + return regular_report_sent; } bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length, SwitchInputState* out_state) { diff --git a/switch_pro_driver.h b/switch_pro_driver.h index 951d68b..6a4f8bb 100644 --- a/switch_pro_driver.h +++ b/switch_pro_driver.h @@ -55,8 +55,9 @@ void switch_pro_init(); // Update the desired controller state for the next USB report. void switch_pro_set_input(const SwitchInputState& state); -// Drive the Switch Pro USB state machine; call this frequently in the main loop. -void switch_pro_task(); +// Drive the Switch Pro USB state machine; returns true only when a regular +// 0x30 input report was successfully queued. +bool switch_pro_task(); // Convert a packed UART message into controller state (returns true if parsed). // If out_state is null the parsed state is written directly to the driver. diff --git a/tests/test_prepare_bluepad32.py b/tests/test_prepare_bluepad32.py new file mode 100644 index 0000000..3e4673f --- /dev/null +++ b/tests/test_prepare_bluepad32.py @@ -0,0 +1,339 @@ +""" +Tests for prepare_bluepad32.py patch preparation tool. + +Tests cover: +- Fresh patch application +- Idempotence (second invocation succeeds without changing content) +- Missing paths validation +- Diverged/ambiguous repository states +""" + +import subprocess +import tempfile +import pytest +from pathlib import Path +from unittest.mock import patch as mock_patch + +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / "tools")) + +from prepare_bluepad32 import ( + PatchError, + resolve_paths, + check_paths, + is_patch_applied, + apply_patch, + prepare_bluepad32, +) + + +@pytest.fixture +def temp_repo_structure(): + """Create a temporary directory structure with git repositories.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + + # Create bluepad32 repo + bp_dir = root / "external" / "bluepad32" + bp_dir.mkdir(parents=True) + subprocess.run(["git", "init"], cwd=bp_dir, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=bp_dir, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=bp_dir, check=True, capture_output=True) + + # Create a file to patch + test_file = bp_dir / "test.txt" + test_file.write_text("line 1\n") + subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "initial"], cwd=bp_dir, check=True, capture_output=True) + + # Create patches dir + patches_dir = root / "patches" + patches_dir.mkdir() + + yield root, bp_dir, patches_dir + + +def create_simple_patch(repo_path: Path, patch_path: Path, file_to_patch: str = "test.txt") -> str: + """ + Create a simple patch file that modifies a file in the repository. + + Returns the patch content as a string. + """ + # Create the modification + test_file = repo_path / file_to_patch + original_content = test_file.read_text() + modified_content = original_content + "line 2\n" + + # Generate patch using git diff + test_file.write_text(modified_content) + result = subprocess.run( + ["git", "diff", file_to_patch], + cwd=repo_path, + capture_output=True, + text=True, + check=True, + ) + patch_content = result.stdout + + # Reset the file to original state + test_file.write_text(original_content) + + # Write patch to file + patch_path.write_text(patch_content) + return patch_content + + +def test_resolve_paths_with_defaults(): + """Test that resolve_paths returns expected default paths.""" + with tempfile.TemporaryDirectory() as tmpdir: + bp_path, patch_path = resolve_paths(Path(tmpdir)) + assert bp_path == Path(tmpdir) / "external" / "bluepad32" + assert patch_path == Path(tmpdir) / "patches" / "bluepad32-sdl3-imu.patch" + + +def test_resolve_paths_no_root(): + """Test resolve_paths with no root uses current directory.""" + bp_path, patch_path = resolve_paths() + assert bp_path.is_absolute() + assert patch_path.is_absolute() + + +def test_check_paths_missing_bluepad32(temp_repo_structure): + """Test that check_paths fails if bluepad32 dir is missing.""" + root, bp_dir, patches_dir = temp_repo_structure + + # Remove bluepad32 + import shutil + shutil.rmtree(bp_dir) + + patch_file = patches_dir / "test.patch" + patch_file.write_text("dummy") + + with pytest.raises(PatchError, match="bluepad32 directory does not exist"): + check_paths(bp_dir, patch_file) + + +def test_check_paths_missing_patch(temp_repo_structure): + """Test that check_paths fails if patch file is missing.""" + root, bp_dir, patches_dir = temp_repo_structure + + patch_file = patches_dir / "nonexistent.patch" + + with pytest.raises(PatchError, match="patch file does not exist"): + check_paths(bp_dir, patch_file) + + +def test_check_paths_bluepad32_not_git_repo(temp_repo_structure): + """Test that check_paths fails if bluepad32 is not a git repo.""" + root, bp_dir, patches_dir = temp_repo_structure + + # Remove .git to make it not a git repo + import shutil + shutil.rmtree(bp_dir / ".git") + + patch_file = patches_dir / "test.patch" + patch_file.write_text("dummy") + + with pytest.raises(PatchError, match="not a git repository"): + check_paths(bp_dir, patch_file) + + +def test_fresh_patch_application(temp_repo_structure): + """Test applying a fresh patch to a clean repository.""" + root, bp_dir, patches_dir = temp_repo_structure + + patch_file = patches_dir / "test.patch" + create_simple_patch(bp_dir, patch_file, "test.txt") + + # Verify test.txt before patch + test_file = bp_dir / "test.txt" + original = test_file.read_text() + assert "line 2" not in original + + # Apply patch + apply_patch(bp_dir, patch_file) + + # Verify test.txt after patch + patched = test_file.read_text() + assert "line 2" in patched + + +def test_idempotent_patch_application(temp_repo_structure): + """Test that applying the same patch twice succeeds (idempotence).""" + root, bp_dir, patches_dir = temp_repo_structure + + patch_file = patches_dir / "test.patch" + create_simple_patch(bp_dir, patch_file, "test.txt") + + # First application + apply_patch(bp_dir, patch_file) + test_file = bp_dir / "test.txt" + after_first = test_file.read_text() + + # Second application should succeed without changing content + apply_patch(bp_dir, patch_file) + after_second = test_file.read_text() + + assert after_first == after_second + + +def test_is_patch_applied_not_applied(temp_repo_structure): + """Test is_patch_applied returns False for unapplied patch.""" + root, bp_dir, patches_dir = temp_repo_structure + + patch_file = patches_dir / "test.patch" + create_simple_patch(bp_dir, patch_file, "test.txt") + + # Patch not applied yet + assert is_patch_applied(bp_dir, patch_file) is False + + +def test_is_patch_applied_already_applied(temp_repo_structure): + """Test is_patch_applied returns True for already applied patch.""" + root, bp_dir, patches_dir = temp_repo_structure + + patch_file = patches_dir / "test.patch" + create_simple_patch(bp_dir, patch_file, "test.txt") + + # Apply patch first + apply_patch(bp_dir, patch_file) + + # Now check should detect it's applied + assert is_patch_applied(bp_dir, patch_file) is True + + +def test_diverged_repository_state(temp_repo_structure): + """Test that diverged repository (patch doesn't apply cleanly) is rejected.""" + root, bp_dir, patches_dir = temp_repo_structure + + patch_file = patches_dir / "test.patch" + create_simple_patch(bp_dir, patch_file, "test.txt") + + # Diverge the repository by modifying the file such that the patch conflicts + test_file = bp_dir / "test.txt" + test_file.write_text("completely different line 1\n") + subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "divergence"], cwd=bp_dir, check=True, capture_output=True) + + # Try to apply patch - should fail because file content doesn't match + with pytest.raises(PatchError, match="Patch validation failed"): + apply_patch(bp_dir, patch_file) + + +def test_missing_bluepad32_path(temp_repo_structure): + """Test prepare_bluepad32 fails gracefully with missing bluepad32.""" + root, bp_dir, patches_dir = temp_repo_structure + + import shutil + shutil.rmtree(bp_dir) + + patch_file = patches_dir / "test.patch" + patch_file.write_text("dummy") + + with pytest.raises(PatchError, match="bluepad32 directory does not exist"): + prepare_bluepad32(bp_dir, patch_file) + + +def test_missing_patch_file(temp_repo_structure): + """Test prepare_bluepad32 fails gracefully with missing patch file.""" + root, bp_dir, patches_dir = temp_repo_structure + + patch_file = patches_dir / "nonexistent.patch" + + with pytest.raises(PatchError, match="patch file does not exist"): + prepare_bluepad32(bp_dir, patch_file) + + +def test_prepare_bluepad32_full_workflow(temp_repo_structure): + """Test complete prepare_bluepad32 workflow: apply then idempotent re-apply.""" + root, bp_dir, patches_dir = temp_repo_structure + + patch_file = patches_dir / "test.patch" + create_simple_patch(bp_dir, patch_file, "test.txt") + + test_file = bp_dir / "test.txt" + original = test_file.read_text() + + # First prepare (should apply patch) + prepare_bluepad32(bp_dir, patch_file) + after_first = test_file.read_text() + assert after_first != original + assert "line 2" in after_first + + # Second prepare (should be idempotent) + prepare_bluepad32(bp_dir, patch_file) + after_second = test_file.read_text() + assert after_first == after_second + + +def test_prepare_bluepad32_with_defaults(temp_repo_structure): + """Test prepare_bluepad32 uses correct defaults when paths not provided.""" + root, bp_dir, patches_dir = temp_repo_structure + + patch_file = patches_dir / "bluepad32-sdl3-imu.patch" + create_simple_patch(bp_dir, patch_file, "test.txt") + + # Change to root directory and call with defaults + import os + original_cwd = os.getcwd() + try: + os.chdir(root) + prepare_bluepad32() # Use defaults + finally: + os.chdir(original_cwd) + + # Verify patch was applied + test_file = bp_dir / "test.txt" + assert "line 2" in test_file.read_text() + + +def test_patch_application_with_conflicting_content(temp_repo_structure): + """Test that patch with conflicting content is rejected.""" + root, bp_dir, patches_dir = temp_repo_structure + + # Create a patch that adds a specific change + patch_content = """--- a/test.txt ++++ b/test.txt +@@ -1 +1,3 @@ + line 1 ++line 2 ++line 3 +""" + + patch_file = patches_dir / "conflict.patch" + patch_file.write_text(patch_content) + + # Modify the file to have different content that won't match the patch context + test_file = bp_dir / "test.txt" + test_file.write_text("modified line 1\n") + subprocess.run(["git", "add", "test.txt"], cwd=bp_dir, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "modify"], cwd=bp_dir, check=True, capture_output=True) + + # Try to apply patch - should fail due to context mismatch + with pytest.raises(PatchError): + apply_patch(bp_dir, patch_file) + + +def test_cli_with_explicit_paths(temp_repo_structure): + """Test CLI argument parsing with explicit paths.""" + root, bp_dir, patches_dir = temp_repo_structure + + patch_file = patches_dir / "test.patch" + create_simple_patch(bp_dir, patch_file, "test.txt") + + # Simulate CLI call + sys.argv = [ + "prepare_bluepad32.py", + "--bluepad32", str(bp_dir), + "--patch", str(patch_file), + ] + + from prepare_bluepad32 import main + + # Should not raise + try: + main() + except SystemExit as e: + # main() calls sys.exit on success, which we need to catch + if e.code != 0: + raise diff --git a/tools/prepare_bluepad32.py b/tools/prepare_bluepad32.py new file mode 100755 index 0000000..981d40f --- /dev/null +++ b/tools/prepare_bluepad32.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Apply the project Bluepad32 patch exactly once.""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +class PatchError(RuntimeError): + pass + + +def resolve_paths(repo_root: Path | None = None) -> tuple[Path, Path]: + root = Path.cwd() if repo_root is None else Path(repo_root) + return ( + root / "external" / "bluepad32", + root / "patches" / "bluepad32-sdl3-imu.patch", + ) + + +def check_paths(bluepad32_path: Path, patch_path: Path) -> None: + if not bluepad32_path.is_dir(): + raise PatchError(f"bluepad32 directory does not exist: {bluepad32_path}") + if not (bluepad32_path / ".git").exists(): + raise PatchError(f"Bluepad32 is not a git repository: {bluepad32_path}") + if not patch_path.is_file(): + raise PatchError(f"patch file does not exist: {patch_path}") + + +def git_apply(bluepad32_path: Path, patch_path: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(bluepad32_path), "apply", *args, str(patch_path)], + capture_output=True, + text=True, + check=False, + ) + + +def is_patch_applied(bluepad32_path: Path, patch_path: Path) -> bool: + return git_apply(bluepad32_path, patch_path, "--reverse", "--check").returncode == 0 + + +def apply_patch(bluepad32_path: Path, patch_path: Path) -> None: + if is_patch_applied(bluepad32_path, patch_path): + return + + check = git_apply(bluepad32_path, patch_path, "--check") + if check.returncode != 0: + detail = check.stderr.strip() or "patch does not apply" + raise PatchError(f"Patch validation failed (repository may be diverged):\n{detail}") + + result = git_apply(bluepad32_path, patch_path) + if result.returncode != 0: + detail = result.stderr.strip() or "git apply failed" + raise PatchError(f"Could not patch Bluepad32: {detail}") + + +def prepare_bluepad32( + bluepad32_path: Path | None = None, + patch_path: Path | None = None, +) -> None: + default_bluepad32, default_patch = resolve_paths() + dependency = Path(bluepad32_path or default_bluepad32) + patch = Path(patch_path or default_patch) + check_paths(dependency, patch) + apply_patch(dependency, patch) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bluepad32", type=Path) + parser.add_argument("--patch", type=Path) + args = parser.parse_args() + try: + prepare_bluepad32(args.bluepad32, args.patch) + except PatchError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())