From 1e61fcf3f0cb346534e7daf77e8c6d0fb30e52aa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:39:15 +0000 Subject: [PATCH 1/7] Add native Switch 2 controller setup, rumble and calibrated motion on macOS Embed the pinned controller SDK; add per-slot recommended mappings, persistent device identity, safe profile replacement and timed rumble testing. Preserve ordinary controller APIs and disabled builds. Add host, mapping and transaction regressions and a local build helper. --- .gitmodules | 4 + CMakeLists.txt | 22 ++- cmake/SignSwitch2KitBundle.cmake | 13 ++ dependencies/Switch2Kit | 1 + docs/Switch2Kit.md | 109 +++++++++++ scripts/build-switch2kit.sh | 20 ++ src/CMakeLists.txt | 13 +- src/gui/wxgui/CMakeLists.txt | 5 + src/gui/wxgui/input/InputAPIAddWindow.cpp | 26 +++ src/gui/wxgui/input/InputAPIAddWindow.h | 6 + src/gui/wxgui/input/InputSettings2.cpp | 140 +++++++++++++- src/gui/wxgui/input/InputSettings2.h | 8 + src/gui/wxgui/input/Switch2KitSetup.cpp | 151 +++++++++++++++ src/gui/wxgui/input/Switch2KitSetup.h | 7 + .../wxgui/input/Switch2KitSetupTransaction.h | 25 +++ .../settings/DefaultControllerSettings.cpp | 66 ++++++- .../settings/DefaultControllerSettings.h | 3 + src/input/CMakeLists.txt | 4 + src/input/ControllerFactory.cpp | 4 + src/input/InputManager.cpp | 42 +++-- src/input/InputManager.h | 6 +- src/input/api/Controller.cpp | 18 +- src/input/api/Controller.h | 82 +-------- src/input/api/ControllerButtons.h | 75 ++++++++ src/input/api/SDL/SDLController.cpp | 172 ++++++++++++++++-- src/input/api/SDL/SDLController.h | 33 +++- src/input/api/SDL/SDLControllerProvider.cpp | 125 ++++++++++++- src/input/api/SDL/SDLControllerProvider.h | 22 ++- src/input/api/SDL/Switch2KitIdentity.h | 15 ++ src/input/api/SDL/Switch2KitMapping.h | 80 ++++++++ src/input/api/SDL/Switch2KitSession.h | 41 +++++ src/input/emulated/ClassicController.cpp | 11 ++ src/input/emulated/EmulatedController.cpp | 19 +- src/input/emulated/EmulatedController.h | 4 +- src/input/emulated/ProController.cpp | 11 ++ src/input/emulated/VPADController.cpp | 15 +- src/input/emulated/WPADController.cpp | 8 +- src/resource/MacOSXBundleInfo.plist.in | 1 + tests/switch2kit/PolicyTests.cpp | 163 +++++++++++++++++ tests/switch2kit/run.py | 60 ++++++ tests/switch2kit/test_wiring.py | 61 +++++++ 41 files changed, 1546 insertions(+), 145 deletions(-) create mode 100644 cmake/SignSwitch2KitBundle.cmake create mode 160000 dependencies/Switch2Kit create mode 100644 docs/Switch2Kit.md create mode 100644 scripts/build-switch2kit.sh create mode 100644 src/gui/wxgui/input/Switch2KitSetup.cpp create mode 100644 src/gui/wxgui/input/Switch2KitSetup.h create mode 100644 src/gui/wxgui/input/Switch2KitSetupTransaction.h create mode 100644 src/input/api/ControllerButtons.h create mode 100644 src/input/api/SDL/Switch2KitIdentity.h create mode 100644 src/input/api/SDL/Switch2KitMapping.h create mode 100644 src/input/api/SDL/Switch2KitSession.h create mode 100644 tests/switch2kit/PolicyTests.cpp create mode 100644 tests/switch2kit/run.py create mode 100644 tests/switch2kit/test_wiring.py diff --git a/.gitmodules b/.gitmodules index 82e532096b..49ffa6cd37 100644 --- a/.gitmodules +++ b/.gitmodules @@ -25,3 +25,7 @@ [submodule "dependencies/xbyak_aarch64"] path = dependencies/xbyak_aarch64 url = https://github.com/fujitsu/xbyak_aarch64 + +[submodule "dependencies/Switch2Kit"] + path = dependencies/Switch2Kit + url = https://github.com/jmonster/Switch2Kit.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 644032ce2a..5de6f7a94b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,8 @@ cmake_minimum_required(VERSION 3.21.1) option(ENABLE_VCPKG "Enable the vcpkg package manager" ON) +option(ENABLE_SWITCH2KIT "Use in-process Switch2Kit controllers on macOS 15+" OFF) +set(SWITCH2KIT_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dependencies/Switch2Kit" CACHE PATH "Switch2Kit source checkout") option(MACOS_BUNDLE "The executable when built on macOS will be created as an application bundle" OFF) option(ALLOW_PORTABLE "Allow Cemu to be run in portable mode" ON) @@ -99,7 +101,9 @@ endif() if (APPLE) enable_language(OBJC OBJCXX) - set(CMAKE_OSX_DEPLOYMENT_TARGET "13.4") + if(NOT CMAKE_OSX_DEPLOYMENT_TARGET) + set(CMAKE_OSX_DEPLOYMENT_TARGET "13.4") + endif() endif() if (UNIX AND NOT APPLE) @@ -168,6 +172,22 @@ if(ENABLE_SDL) add_compile_definitions(HAS_SDL) endif() +if(ENABLE_SWITCH2KIT) + if(NOT APPLE OR NOT ENABLE_SDL OR NOT MACOS_BUNDLE) + message(FATAL_ERROR "Switch2Kit requires macOS, ENABLE_SDL and MACOS_BUNDLE") + endif() + if(CMAKE_OSX_DEPLOYMENT_TARGET VERSION_LESS 15.0) + message(FATAL_ERROR "Switch2Kit requires CMAKE_OSX_DEPLOYMENT_TARGET=15.0 or newer") + endif() + if(NOT EXISTS "${SWITCH2KIT_SOURCE_DIR}/Integrations/SDL3/CMakeLists.txt") + message(FATAL_ERROR "Switch2Kit is missing. Run git submodule update --init --recursive") + endif() + add_subdirectory("${SWITCH2KIT_SOURCE_DIR}/Integrations/SDL3" "${CMAKE_BINARY_DIR}/switch2kit") + add_compile_definitions(HAVE_SWITCH2KIT) + include_directories("${SWITCH2KIT_SOURCE_DIR}/Integrations/Emulators") + set(SWITCH2KIT_BLUETOOTH_USAGE "NSBluetoothAlwaysUsageDescriptionCemu uses Bluetooth to connect to the controllers you select.") +endif() + # glslang versions older than 11.11.0 define targets without a namespace if (NOT TARGET glslang::SPIRV AND TARGET SPIRV) add_library(glslang::SPIRV ALIAS SPIRV) diff --git a/cmake/SignSwitch2KitBundle.cmake b/cmake/SignSwitch2KitBundle.cmake new file mode 100644 index 0000000000..ea822dc22f --- /dev/null +++ b/cmake/SignSwitch2KitBundle.cmake @@ -0,0 +1,13 @@ +# Development signing only, after all bundle relocation. Not notarization. +if(NOT DEFINED APP OR NOT EXISTS "${APP}/Contents/Info.plist") + message(FATAL_ERROR "A complete Cemu application bundle is required") +endif() +file(GLOB libraries "${APP}/Contents/Frameworks/*.dylib") +foreach(image IN LISTS libraries) + execute_process(COMMAND /usr/bin/codesign --force --sign - --timestamp=none "${image}" + COMMAND_ERROR_IS_FATAL ANY) +endforeach() +execute_process(COMMAND /usr/bin/codesign --force --sign - --timestamp=none "${APP}" + COMMAND_ERROR_IS_FATAL ANY) +execute_process(COMMAND /usr/bin/codesign --verify --deep --strict "${APP}" + COMMAND_ERROR_IS_FATAL ANY) diff --git a/dependencies/Switch2Kit b/dependencies/Switch2Kit new file mode 160000 index 0000000000..8088ce3ef6 --- /dev/null +++ b/dependencies/Switch2Kit @@ -0,0 +1 @@ +Subproject commit 8088ce3ef6845fe90d8ff7a579e6498668bd65de diff --git a/docs/Switch2Kit.md b/docs/Switch2Kit.md new file mode 100644 index 0000000000..9c2b536cab --- /dev/null +++ b/docs/Switch2Kit.md @@ -0,0 +1,109 @@ +# Switch 2 controllers on macOS + +This build connects Switch 2 controllers directly inside Cemu. No standalone +Switch2Kit app, SDL override, virtual-HID driver or Accessibility permission is +needed. Bluetooth input, motor rumble and calibrated motion use the pinned +Switch2Kit library included as a submodule. + +## Build and open + +Use macOS 15 or newer and Xcode 26 or newer with Swift 6.2. Open Xcode once to +finish setup, and select it under Settings > Locations > Command Line Tools. +Install the build tools once: + +```sh +brew install cmake ninja nasm automake libtool molten-vk +``` + +In your Cemu checkout: + +```sh +bash scripts/build-switch2kit.sh --run +``` + +The script builds `bin/Cemu_release.app` and opens it. Open that app again normally +for later sessions. To update, quit Cemu, run `git pull --ff-only`, and run the +script again. The script updates submodules to the versions selected by Cemu. +It does not replace existing settings or install a system driver. + +## Connect and play + +Close any other app managing the same controllers. Open **Options > Input +settings**, click **Find Switch 2 Controllers**, allow Bluetooth, and hold the +controller's **Sync** button. Scanning lasts 60 seconds. + +Choose the connected **GameCube** or **Pro Controller 2** in the new dropdown +beside **Emulated controller** on the desired controller tab. Cemu applies the +button and stick mappings automatically. An empty first slot becomes a Wii U +GamePad; other empty slots become Wii U Pro Controllers. Existing GamePad, Pro +or Classic controller types are retained. Choose a type that the game supports. + +The shortcut enables rumble for a new attachment. Open the physical controller's +**Settings**, adjust **Rumble**, and click **Test rumble** for a half-second test. +GameCube rumble is on/off; Pro rumble uses its existing HD-rumble path. + +Replacing a populated slot asks first and saves a **Before Switch2Kit-…** backup +in Profile > Load. Cancelling, a failed backup/save, or changing the slot during +confirmation keeps the old configuration. Refreshing or reconnecting does not +reset custom mappings. A physical controller already assigned to another slot +must be removed there before using the shortcut. + +Assignments persist by physical identity, including when identical controllers +reconnect in a different order. Use **Find** again after restarting Cemu. Use +**Disconnect Switch 2 Controllers** to stop this backend without erasing profiles. + +## Controller differences + +GameCube and Pro mappings preserve printed Nintendo A/B/X/Y labels. On GameCube, +C supplies Minus and Capture supplies the GamePad microphone button. Its physical +sticks do not have click buttons; games requiring L-stick/R-stick clicks need +those actions mapped to spare buttons or an additional keyboard source. Stick +travel and trigger axes remain available in manual mappings. + +The Pro controller maps +/−, both stick clicks, shoulders, triggers and D-pad. +Capture supplies the GamePad microphone button and C supplies Show Screen. GL/GR +and other spare inputs remain available for custom bindings. + +For Joy-Con 2, pair both halves, choose the emulated controller type, and use +**+ > SDLController** to add each half to the same slot. Each receives its own +complementary mappings; adding one does not overwrite the other's assignments. +This uses Cemu's multiple-source model, not a system-wide virtual paired device. +The quick dropdown is for GameCube and Pro controllers. Wiimote mappings remain +manual. A controller does not replace the Wii U touchscreen; use Cemu's normal +mouse/touchscreen facilities when a game requires them. + +## Motion + +The in-process sensor path includes GamePad/KPAD motion integration, but **motion +is not automatically calibrated**. In the physical controller's Settings, choose +a measured, device-matching `.s2kmotion` profile and enable **Use motion**, then +click OK. The status explains missing, disabled, disconnected or stale input. +An invalid profile leaves the previous selection intact. Profile import/removal +is an explicit action and the path is saved with the controller configuration. + +Do not use test-fixture profiles for gameplay. Calibration must match the real +device and its sensor setup; the SDK documents the measurement procedure in +`dependencies/Switch2Kit/docs/switch2kit/motion-profiles.md`. Without valid calibration the +backend does not invent sensor values or integrate across a disconnect or gap. + +## Build scope and checks + +`ENABLE_SWITCH2KIT` remains off by default for ordinary cross-platform builds. +Enabled builds require SDL, a macOS application bundle and deployment target 15.0 +or newer. The normal disabled build keeps the upstream deployment requirements +and does not compile or link Switch2Kit. The build helper enables these options, +embeds the SDK and its distribution notices, then ad-hoc signs the completed app. +These are development builds, not notarized releases. + +`python3 tests/switch2kit/run.py --sanitize` runs mapping, identity, session and +backup/rollback policies after the native build. Use `--sdl /path/to/SDL-source` +and `--sdk /path/to/Switch2Kit` for a separate source checkout. Policy tests use +real enum definitions with controlled host/storage boundaries; they do not claim +Bluetooth or GUI interaction. Native CI compiles the full Cemu app on Apple +silicon and Intel, checks its bundle/signature, and launches, normally quits and +relaunches the exact ZIP with build dependencies denied. The SDK job also tests +real SDL, motor packets and the Cemu motion consumer. + +Physical pairing, input, reconnect, multiplayer, rumble start/stop, measured +motion and gameplay still require a real Mac and controller. There is no claim +that CI tests exercise physical hardware or downloaded-app Gatekeeper approval. diff --git a/scripts/build-switch2kit.sh b/scripts/build-switch2kit.sh new file mode 100644 index 0000000000..250153f74b --- /dev/null +++ b/scripts/build-switch2kit.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Build the checked-out revision and its pinned controller library. +set -euo pipefail +cd "$(dirname "$0")/.." +if [ "$(uname -s)" != Darwin ]; then echo "This build requires macOS 15 or newer." >&2; exit 1; fi +if [ "$(sysctl -in sysctl.proc_translated 2>/dev/null || true)" = 1 ]; then + echo "Use a native Terminal, not Rosetta, on Apple silicon." >&2; exit 1 +fi +for tool in cmake ninja git xcrun; do command -v "$tool" >/dev/null || { echo "Missing build tool: $tool" >&2; exit 1; }; done +xcrun swift --version +export VCPKG_MAX_CONCURRENCY="${VCPKG_MAX_CONCURRENCY:-3}" +git submodule update --init --recursive +if [ ! -x dependencies/vcpkg/vcpkg ]; then bash dependencies/vcpkg/bootstrap-vcpkg.sh; fi +cmake -S . -B build-switch2kit -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ + -DCMAKE_OSX_ARCHITECTURES="$(uname -m)" \ + -DENABLE_SWITCH2KIT=ON -DENABLE_SDL=ON -DMACOS_BUNDLE=ON -DENABLE_VULKAN=ON +cmake --build build-switch2kit --target CemuBin --parallel 3 +echo "Built: $PWD/bin/Cemu_release.app" +if [ "${1:-}" = --run ]; then open "$PWD/bin/Cemu_release.app"; fi diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d8378c3ab5..10902ea213 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -110,7 +110,7 @@ if (MACOS_BUNDLE) set(MACOSX_BUNDLE_COPYRIGHT "Copyright © ${CURRENT_YEAR} Cemu Project") set(MACOSX_BUNDLE_CATEGORY "public.app-category.games") - set(MACOSX_MINIMUM_SYSTEM_VERSION "13.4") + set(MACOSX_MINIMUM_SYSTEM_VERSION "${CMAKE_OSX_DEPLOYMENT_TARGET}") set(MACOSX_BUNDLE_TYPE_EXTENSION "wua") set_target_properties(CemuBin PROPERTIES @@ -118,6 +118,10 @@ if (MACOS_BUNDLE) RESOURCE "${RESOURCE_FILES}" ) + if(ENABLE_SWITCH2KIT) + switch2kit_embed(CemuBin) + endif() + set(FOLDERS gameProfiles resources) foreach(folder ${FOLDERS}) add_custom_command (TARGET CemuBin POST_BUILD @@ -174,6 +178,13 @@ if (MACOS_BUNDLE) "$" ) endif() + if(ENABLE_SWITCH2KIT) + add_custom_command(TARGET CemuBin POST_BUILD + COMMAND ${CMAKE_COMMAND} "-DAPP=$" + -P "${CMAKE_SOURCE_DIR}/cmake/SignSwitch2KitBundle.cmake" + VERBATIM) + endif() + else() if(APPLE) if (ENABLE_VULKAN) diff --git a/src/gui/wxgui/CMakeLists.txt b/src/gui/wxgui/CMakeLists.txt index 079a78cd55..f0eef1c404 100644 --- a/src/gui/wxgui/CMakeLists.txt +++ b/src/gui/wxgui/CMakeLists.txt @@ -196,3 +196,8 @@ endif() if(ALLOW_PORTABLE) target_compile_definitions(CemuWxGui PRIVATE CEMU_ALLOW_PORTABLE) endif () + +if(ENABLE_SWITCH2KIT) + target_sources(CemuWxGui PRIVATE input/Switch2KitSetup.cpp input/Switch2KitSetup.h) + target_link_libraries(CemuWxGui PRIVATE Switch2Kit::SDL3) +endif() diff --git a/src/gui/wxgui/input/InputAPIAddWindow.cpp b/src/gui/wxgui/input/InputAPIAddWindow.cpp index b10a2c7525..e8d92db951 100644 --- a/src/gui/wxgui/input/InputAPIAddWindow.cpp +++ b/src/gui/wxgui/input/InputAPIAddWindow.cpp @@ -15,6 +15,9 @@ #include #include "input/ControllerFactory.h" +#ifdef HAVE_SWITCH2KIT +#include "input/api/SDL/SDLControllerProvider.h" +#endif wxDEFINE_EVENT(wxControllersRefreshed, wxCommandEvent); @@ -107,6 +110,29 @@ InputAPIAddWindow::InputAPIAddWindow(wxWindow* parent, const wxPoint& position, sizer->Add(m_settings_panel, 1, wxEXPAND); } +#ifdef HAVE_SWITCH2KIT + auto* find = new wxButton(this, wxID_ANY, _("Find Switch 2 Controllers")); + auto* status = new wxStaticText(this, wxID_ANY, _("Select Find, then hold Sync.")); + sizer->Add(find, 0, wxALL | wxEXPAND, 5); + sizer->Add(status, 0, wxALL | wxEXPAND, 5); + find->Bind(wxEVT_BUTTON, [this, status](wxCommandEvent&) { + const auto result = SDLControllerProvider::FindSwitch2Controllers(); + if (result == 0) + { + m_input_api->SetStringSelection(wxString::FromUTF8(to_string(InputAPI::SDLController))); + wxCommandEvent selection; + on_api_selected(selection); + on_controller_dropdown(selection); + } + status->SetLabel(result == 0 ? _("Hold Sync, then open the Controller list to refresh.") : + wxString::Format(_("Controller discovery could not start (%d)."), result)); + }); + m_switch2kit_timer = std::make_unique(this); + Bind(wxEVT_TIMER, [status](wxTimerEvent&) { + status->SetLabel(wxString::FromUTF8(SDLControllerProvider::Switch2ControllerStatus())); + }, m_switch2kit_timer->GetId()); + m_switch2kit_timer->Start(500); +#endif this->SetSizer(sizer); this->Layout(); sizer->Fit(this); diff --git a/src/gui/wxgui/input/InputAPIAddWindow.h b/src/gui/wxgui/input/InputAPIAddWindow.h index 1e17a8db50..562ecc8129 100644 --- a/src/gui/wxgui/input/InputAPIAddWindow.h +++ b/src/gui/wxgui/input/InputAPIAddWindow.h @@ -5,6 +5,9 @@ #include #include #include +#ifdef HAVE_SWITCH2KIT +#include +#endif #include "wxgui/helpers/wxCustomData.h" #include "input/api/Controller.h" @@ -41,6 +44,9 @@ class InputAPIAddWindow : public wxDialog void discard_thread_result(); +#ifdef HAVE_SWITCH2KIT + std::unique_ptr m_switch2kit_timer; +#endif wxChoice* m_input_api; wxComboBox* m_controller_list; wxButton* m_ok_button; diff --git a/src/gui/wxgui/input/InputSettings2.cpp b/src/gui/wxgui/input/InputSettings2.cpp index d77e143094..abfaeaf7cb 100644 --- a/src/gui/wxgui/input/InputSettings2.cpp +++ b/src/gui/wxgui/input/InputSettings2.cpp @@ -16,6 +16,13 @@ #include #include #include +#ifdef HAVE_SWITCH2KIT +#include +#include +#include "input/api/SDL/SDLController.h" +#include "input/api/SDL/Switch2KitMapping.h" +#include "wxgui/input/Switch2KitSetup.h" +#endif #include "config/ActiveSettings.h" #include "wxgui/input/InputAPIAddWindow.h" @@ -44,6 +51,9 @@ using wxControllerData = wxCustomData; struct ControllerPage { EmulatedControllerPtr m_controller; +#ifdef HAVE_SWITCH2KIT + wxChoice* m_switch2Choice = nullptr; +#endif // profiles wxComboBox* m_profiles; @@ -78,6 +88,23 @@ InputSettings2::InputSettings2(wxWindow* parent) m_low_battery = wxHelper::LoadThemedBitmapFromPNG(INPUT_LOW_BATTERY_png, sizeof(INPUT_LOW_BATTERY_png), wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT)); auto* sizer = new wxBoxSizer(wxVERTICAL); +#ifdef HAVE_SWITCH2KIT + auto* discovery = new wxBoxSizer(wxHORIZONTAL); + auto* find = new wxButton(this, wxID_ANY, _("Find Switch 2 Controllers")); + auto* stop = new wxButton(this, wxID_ANY, _("Disconnect Switch 2 Controllers")); + discovery->Add(find, 0, wxALL, 5); + discovery->Add(stop, 0, wxALL, 5); + m_switch2Status = new wxStaticText(this, wxID_ANY, _("Hold Sync while searching, then choose your controller beside Emulated controller.")); + m_switch2Status->Wrap(600); + sizer->Add(discovery, 0, wxEXPAND); + sizer->Add(m_switch2Status, 0, wxLEFT | wxRIGHT | wxBOTTOM, 5); + find->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + const int result = SDLControllerProvider::FindSwitch2Controllers(); + if (result != 0) + wxMessageBox(wxString::Format(_("Discovery could not start (%d). Allow Bluetooth access and close other controller apps. After Disconnect, wait a moment before retrying."), result), _("Switch 2 Controllers"), wxOK | wxICON_WARNING, this); + }); + stop->Bind(wxEVT_BUTTON, [](wxCommandEvent&) { SDLControllerProvider::DisconnectSwitch2Controllers(); }); +#endif m_notebook = new wxNotebook(this, wxID_ANY); for(size_t i = 0; i < InputManager::kMaxController; ++i) @@ -113,10 +140,19 @@ InputSettings2::InputSettings2(wxWindow* parent) update_state(); - Bind(wxEVT_TIMER, &InputSettings2::on_timer, this); - m_timer = new wxTimer(this); + Bind(wxEVT_TIMER, &InputSettings2::on_timer, this, m_timer->GetId()); m_timer->Start(25); +#ifdef HAVE_SWITCH2KIT + m_switch2Timer = new wxTimer(this); + Bind(wxEVT_TIMER, [this](wxTimerEvent&) { + m_switch2Status->SetLabel(wxString::FromUTF8(SDLControllerProvider::Switch2ControllerStatus())); + if (m_switch2DevicesChanged.exchange(false)) + on_controller_changed(); + }, m_switch2Timer->GetId()); + m_switch2Timer->Start(500); + RefreshSwitch2Controllers(); +#endif m_controller_changed = EventService::instance().connect(&InputSettings2::on_controller_changed, this); } @@ -127,6 +163,9 @@ InputSettings2::~InputSettings2() g_inputConfigWindowHasFocus = false; m_timer->Stop(); +#ifdef HAVE_SWITCH2KIT + m_switch2Timer->Stop(); +#endif InputManager::instance().save(); } @@ -222,6 +261,27 @@ wxWindow* InputSettings2::initialize_page(size_t index) sizer->Add(econtroller_box, wxGBPosition(2, 1), wxDefaultSpan, wxALIGN_CENTER_VERTICAL | wxALL | wxEXPAND, 5); page_data.m_emulated_controller = econtroller_box; +#ifdef HAVE_SWITCH2KIT + auto* choice = page_data.m_switch2Choice = new wxChoice(page, wxID_ANY); + choice->SetName(_("Switch 2 controller quick setup")); + choice->SetToolTip(_("Choose a connected GameCube or Pro controller to configure this slot automatically. Use + below to combine Joy-Con halves or add other input devices.")); + choice->Append(_("Choose Switch 2 controller...")); + choice->SetSelection(0); + choice->SetMinSize(wxSize(260, -1)); + choice->Bind(wxEVT_CHOICE, [this, choice, index](wxCommandEvent&) { + const int selected = choice->GetSelection(); + if (selected <= 0) + return; + const auto* data = static_cast(choice->GetClientObject(selected)); + // Copy before entering a modal dialog; hotplug can replace choice data. + const ControllerPtr source = data ? data->GetData() : ControllerPtr{}; + if (ApplySwitch2KitSetup(this, index, source)) + static_cast(m_notebook->GetPage(index)->GetClientObject())->ref().m_controller = InputManager::instance().get_controller(index); + update_state(); + RefreshSwitch2Controllers(); + }); + sizer->Add(choice, wxGBPosition(2, 2), wxGBSpan(1, 3), wxALIGN_CENTER_VERTICAL | wxALL | wxEXPAND, 5); +#endif } sizer->Add(new wxStaticLine(page), wxGBPosition(3, 0), wxGBSpan(1, 6), wxEXPAND); @@ -293,6 +353,17 @@ wxWindow* InputSettings2::initialize_page(size_t index) page_data.m_controller_calibrate = calibrate; page_data.m_controller_clear = clear; page_data.m_controller_connected = connected_button; +#ifdef HAVE_SWITCH2KIT + auto* recommended = new wxButton(page, wxID_ANY, _("Use recommended mapping")); + recommended->Bind(wxEVT_BUTTON, [this, index](wxCommandEvent&) { + const auto controller = get_active_controller(); + if (ApplySwitch2KitSetup(this, index, controller)) + static_cast(m_notebook->GetPage(index)->GetClientObject())->ref().m_controller = InputManager::instance().get_controller(index); + update_state(); + RefreshSwitch2Controllers(); + }); + sizer->Add(recommended, wxGBPosition(5, 3), wxGBSpan(1, 3), wxALIGN_CENTER_VERTICAL | wxALL, 5); +#endif } @@ -342,6 +413,9 @@ void InputSettings2::update_state() auto* page_data_ptr = (wxControllerPageData*)page->GetClientObject(); wxASSERT(page_data_ptr); auto& page_data = page_data_ptr->ref(); +#ifdef HAVE_SWITCH2KIT + RefreshSwitch2Controllers(); +#endif page_data.m_profile_status->Hide(); @@ -480,6 +554,14 @@ void InputSettings2::update_state() void InputSettings2::on_controller_changed() { +#ifdef HAVE_SWITCH2KIT + if (!wxIsMainThread()) + { + m_switch2DevicesChanged = true; + return; + } + RefreshSwitch2Controllers(); +#endif for(auto i = 0 ; i < m_notebook->GetPageCount(); ++i) { auto* page = m_notebook->GetPage(i); @@ -870,6 +952,11 @@ void InputSettings2::on_controller_add(wxCommandEvent& event) const auto api_type = wnd.get_type(); controller->connect(); +#ifdef HAVE_SWITCH2KIT + if (const auto native = std::dynamic_pointer_cast(controller); + native && native->IsSwitch2Controller()) + native->set_rumble(1.0f); +#endif const int index = page_data.m_controllers->Append(fmt::format("{} [{}]", controller->display_name(), to_string(api_type)), new wxCustomData(controller)); page_data.m_controllers->Select(index); @@ -974,3 +1061,52 @@ void InputSettings2::on_controller_settings(wxCommandEvent& event) #endif } } + +#ifdef HAVE_SWITCH2KIT +void InputSettings2::RefreshSwitch2Controllers() +{ + const auto provider = InputManager::instance().get_api_provider(InputAPI::SDLController); + if (!provider) + return; + const auto available = provider->get_controllers(); + for (size_t i = 0; i < m_notebook->GetPageCount(); ++i) + { + const auto* data = static_cast(m_notebook->GetPage(i)->GetClientObject()); + if (!data || !data->GetData().m_switch2Choice) + continue; + auto* choice = data->GetData().m_switch2Choice; + std::string selectedKey; + const auto current = InputManager::instance().get_controller(i); + if (current) + for (const auto& source : current->get_controllers()) + if (const auto native = std::dynamic_pointer_cast(source); + native && native->IsSwitch2Controller()) + selectedKey = source->uuid(); + choice->Clear(); + choice->Append(_("Choose Switch 2 controller...")); + choice->SetSelection(0); + unsigned ordinal = 0; + for (const auto& source : available) + { + auto native = std::dynamic_pointer_cast(source); + if (!native || !native->IsSwitch2Controller()) + continue; + // Enumeration returns metadata without opening a gamepad or touching + // its rumble/sensors. Only explicit assignment connects a new source. + const auto model = native->GetSwitch2Model(); + if (!model || (*model != S2K_GAMECUBE && *model != S2K_PRO)) + continue; + ControllerPtr selectedSource = native; + if (current) + for (const auto& existing : current->get_controllers()) + if (*existing == *native) + selectedSource = existing; + const auto item = choice->Append(wxString::Format("%s (%u)", + wxString::FromUTF8(native->display_name()), ++ordinal), new wxControllerData(selectedSource)); + if (native->uuid() == selectedKey) + choice->SetSelection(item); + } + choice->Enable(choice->GetCount() > 1); + } +} +#endif diff --git a/src/gui/wxgui/input/InputSettings2.h b/src/gui/wxgui/input/InputSettings2.h index 1a3c8bb172..72d254faae 100644 --- a/src/gui/wxgui/input/InputSettings2.h +++ b/src/gui/wxgui/input/InputSettings2.h @@ -3,6 +3,7 @@ #include #include #include +#include #include "input/api/InputAPI.h" @@ -10,6 +11,7 @@ struct ControllerPage; class ControllerBase; +class wxStaticText; class InputSettings2 : public wxDialog { @@ -22,6 +24,12 @@ class InputSettings2 : public wxDialog wxNotebook* m_notebook; wxTimer* m_timer; +#ifdef HAVE_SWITCH2KIT + wxTimer* m_switch2Timer; + wxStaticText* m_switch2Status; + std::atomic m_switch2DevicesChanged{false}; + void RefreshSwitch2Controllers(); +#endif wxBitmap m_connected, m_disconnected, m_low_battery; diff --git a/src/gui/wxgui/input/Switch2KitSetup.cpp b/src/gui/wxgui/input/Switch2KitSetup.cpp new file mode 100644 index 0000000000..7a52280716 --- /dev/null +++ b/src/gui/wxgui/input/Switch2KitSetup.cpp @@ -0,0 +1,151 @@ +#include "wxgui/input/Switch2KitSetup.h" +#include "wxgui/input/Switch2KitSetupTransaction.h" + +#include +#include +#include "config/ActiveSettings.h" +#include "input/ControllerFactory.h" +#include "input/InputManager.h" +#include "input/api/SDL/SDLController.h" +#include "input/api/SDL/Switch2KitMapping.h" + +namespace +{ + bool AssignedElsewhere(size_t playerIndex, const ControllerPtr& controller) + { + for (size_t i = 0; i < InputManager::kMaxController; ++i) + { + const auto pad = InputManager::instance().get_controller(i); + if (i == playerIndex || !pad) + continue; + for (const auto& source : pad->get_controllers()) + { + if (*source == *controller) + return true; + } + } + return false; + } +} + +bool ApplySwitch2KitSetup(wxWindow* parent, size_t playerIndex, const ControllerPtr& controller) +{ + auto& manager = InputManager::instance(); + const auto native = std::dynamic_pointer_cast(controller); + if (playerIndex >= InputManager::kMaxController || !native || !native->IsSwitch2Controller()) + return false; + if (!native->connect()) + { + wxMessageBox(_("Connect the controller with Find Switch 2 Controllers first."), _("Switch 2 Controllers"), wxOK | wxICON_INFORMATION, parent); + return false; + } + const auto model = native->GetSwitch2Model(); + if (!model || (*model != S2K_GAMECUBE && *model != S2K_PRO)) + return false; + const auto before = manager.get_controller(playerIndex); + if (manager.is_gameprofile_set(playerIndex)) + { + wxMessageBox(_("A game profile controls this slot. Stop the game before using quick setup."), _("Switch 2 Controllers"), wxOK | wxICON_INFORMATION, parent); + return false; + } + const auto type = before ? before->type() : + (playerIndex == 0 ? EmulatedController::VPAD : EmulatedController::Pro); + if (type == EmulatedController::Wiimote) + { + wxMessageBox(_("Select Wii U GamePad, Wii U Pro Controller or Classic Controller for automatic mappings. Wiimote mappings remain available manually."), _("Switch 2 Controllers"), wxOK | wxICON_INFORMATION, parent); + return false; + } + if (AssignedElsewhere(playerIndex, native)) + { + wxMessageBox(_("This controller is assigned to another slot. Remove it there first."), _("Switch 2 Controllers"), wxOK | wxICON_INFORMATION, parent); + return false; + } + const auto snapshot = manager.ControllerConfigSnapshot(playerIndex); + if (before && !before->get_controllers().empty() && wxMessageBox( + _("Replace this slot's controller assignments and button mappings? A backup profile will be saved first. Use Profile > Load to restore it."), + _("Apply recommended mapping?"), wxYES_NO | wxNO_DEFAULT | wxICON_QUESTION, parent) != wxYES) + return false; + + // A modal question can dispatch controller disconnects or configuration events. + if (!native->is_connected() || manager.get_controller(playerIndex) != before || manager.ControllerConfigSnapshot(playerIndex) != snapshot || + manager.is_gameprofile_set(playerIndex) || AssignedElsewhere(playerIndex, native)) + { + wxMessageBox(_("The controller or slot changed. Connect it and try again."), _("Switch 2 Controllers"), wxOK | wxICON_INFORMATION, parent); + return false; + } + const auto [vpads, wpads] = manager.get_controller_count(); + if (!before && ((type == EmulatedController::VPAD && vpads >= InputManager::kMaxVPADControllers) || + (type != EmulatedController::VPAD && wpads >= InputManager::kMaxWPADControllers))) + { + wxMessageBox(_("No emulated controller slots of this type are available. Disable another slot first."), _("Switch 2 Controllers"), wxOK | wxICON_INFORMATION, parent); + return false; + } + + try + { + // Construct and validate the replacement before touching the active slot. + auto replacement = ControllerFactory::CreateEmulatedController(playerIndex, type); + if (before) + { + pugi::xml_document properties; + auto node = properties.append_child("emulated_controller"); + before->save(node); + replacement->load(node); + } + replacement->add_controller(native); + if (!replacement->set_default_mapping(native)) + return false; + + const auto previousSettings = native->get_settings(); + // Only a new attachment gets useful defaults. Existing strength, deadzones + // and motion choices are user preferences, not part of a button preset. + bool existingSource = false; + if (before) + for (const auto& source : before->get_controllers()) + existingSource |= source == native; + const bool committed = CemuSwitch2Kit::CommitSetup( + [&] { + if (before) + { + const auto directory = ActiveSettings::GetConfigPath("controllerProfiles"); + fs::create_directories(directory); + // Reserve a unique stem through wx's exclusive temporary-file helper. + const auto reserved = wxFileName::CreateTempFileName(wxString::FromUTF8(_pathToUtf8(directory / "Before Switch2Kit-"))); + if (reserved.empty()) + throw std::runtime_error("Could not reserve backup profile"); + const auto reservation = _utf8ToPath(reserved.utf8_string()); + const auto name = _pathToUtf8(reservation.filename()) + ".xml"; + bool backedUp = false; + try { backedUp = manager.save(playerIndex, name, false); } + catch (...) { std::error_code ec; fs::remove(reservation, ec); throw; } + std::error_code ec; + fs::remove(reservation, ec); + if (!backedUp) + return false; + } + + return true; + }, + [&] { + if (!existingSource) + native->set_rumble(1.0f); + manager.set_controller(replacement); + }, + [&] { return manager.save(playerIndex); }, + [&] { + native->set_settings(previousSettings); + if (before) + manager.set_controller(before); + else + manager.delete_controller(playerIndex); + }); + if (!committed) + throw std::runtime_error("Could not save controller settings"); + return true; + } + catch (const std::exception&) + { + wxMessageBox(_("Controller settings or their backup could not be saved. The previous slot configuration was retained."), _("Switch 2 Controllers"), wxOK | wxICON_ERROR, parent); + return false; + } +} diff --git a/src/gui/wxgui/input/Switch2KitSetup.h b/src/gui/wxgui/input/Switch2KitSetup.h new file mode 100644 index 0000000000..bd9cc11b1d --- /dev/null +++ b/src/gui/wxgui/input/Switch2KitSetup.h @@ -0,0 +1,7 @@ +#pragma once + +#include "input/api/Controller.h" +class wxWindow; + +// Explicit UI action. Returns true only after the new default profile is saved. +bool ApplySwitch2KitSetup(wxWindow* parent, size_t playerIndex, const ControllerPtr& controller); diff --git a/src/gui/wxgui/input/Switch2KitSetupTransaction.h b/src/gui/wxgui/input/Switch2KitSetupTransaction.h new file mode 100644 index 0000000000..d7f6bd5c92 --- /dev/null +++ b/src/gui/wxgui/input/Switch2KitSetupTransaction.h @@ -0,0 +1,25 @@ +#pragma once + +namespace CemuSwitch2Kit +{ +// Save the old profile before changing the active slot. A failed atomic save or +// an exception after applying the new slot restores the in-memory configuration. +// Callers validate identity/selection again after any modal confirmation. +template +bool CommitSetup(Backup backup, Apply apply, Save save, Rollback rollback) +{ + if (!backup()) + return false; + try + { + apply(); + if (save()) + return true; + } + catch (...) + { + } + rollback(); + return false; +} +} // namespace CemuSwitch2Kit diff --git a/src/gui/wxgui/input/settings/DefaultControllerSettings.cpp b/src/gui/wxgui/input/settings/DefaultControllerSettings.cpp index c10c15ceb0..e57266158b 100644 --- a/src/gui/wxgui/input/settings/DefaultControllerSettings.cpp +++ b/src/gui/wxgui/input/settings/DefaultControllerSettings.cpp @@ -8,6 +8,12 @@ #include #include #include +#ifdef HAVE_SWITCH2KIT +#include +#include +#include +#include "input/api/SDL/SDLController.h" +#endif #include "wxgui/helpers/wxControlObject.h" #include "wxgui/helpers/wxHelpers.h" @@ -37,6 +43,38 @@ DefaultControllerSettings::DefaultControllerSettings(wxWindow* parent, const wxP m_use_motion->Enable(m_controller->has_motion()); box_sizer->Add(m_use_motion, 0, wxEXPAND | wxALL, 5); +#ifdef HAVE_SWITCH2KIT + if (const auto native = std::dynamic_pointer_cast(m_controller); + native && native->uuid().starts_with("s2k:")) + { + auto* profile = new wxButton(box, wxID_ANY, _("Choose Switch2Kit motion profile...")); + auto* remove = new wxButton(box, wxID_ANY, _("Remove motion profile")); + auto* status = new wxStaticText(box, wxID_ANY, wxString::FromUTF8(native->motion_status())); + box_sizer->Add(profile, 0, wxALL | wxEXPAND, 5); + box_sizer->Add(remove, 0, wxALL | wxEXPAND, 5); + box_sizer->Add(status, 0, wxALL | wxEXPAND, 5); + profile->Bind(wxEVT_BUTTON, [this, native, status](wxCommandEvent&) { + wxFileDialog dialog(this, _("Choose measured device calibration"), {}, {}, + _("Switch2Kit profile (*.s2kmotion)|*.s2kmotion|All files|*"), wxFD_OPEN | wxFD_FILE_MUST_EXIST); + if (dialog.ShowModal() == wxID_OK) + { + native->set_motion_profile(dialog.GetPath().utf8_string()); + status->SetLabel(wxString::FromUTF8(native->motion_status())); + } + }); + remove->Bind(wxEVT_BUTTON, [native, status](wxCommandEvent&) { + native->set_motion_profile({}); + status->SetLabel(wxString::FromUTF8(native->motion_status())); + }); + m_motion_timer = std::make_unique(this); + Bind(wxEVT_TIMER, [this, native, status](wxTimerEvent&) { + native->connect(); + m_use_motion->Enable(native->has_motion()); + status->SetLabel(wxString::FromUTF8(native->motion_status())); + }, m_motion_timer->GetId()); + m_motion_timer->Start(500); + } +#endif // Vibration auto* rumbleSizer = new wxBoxSizer(wxHORIZONTAL); @@ -50,6 +88,32 @@ DefaultControllerSettings::DefaultControllerSettings(wxWindow* parent, const wxP m_rumble->Bind(wxEVT_SLIDER, &DefaultControllerSettings::on_rumble_change, this, wxID_ANY, wxID_ANY, new wxControlObject(text)); box_sizer->Add(rumbleSizer); +#ifdef HAVE_SWITCH2KIT + if (const auto native = std::dynamic_pointer_cast(m_controller); + native && native->IsSwitch2Controller()) + { + auto* test = new wxButton(box, wxID_ANY, _("Test rumble")); + test->Bind(wxEVT_BUTTON, [this, native](wxCommandEvent&) { + if (!native->connect() || !native->has_rumble()) + { + wxMessageBox(_("Connect the controller before testing rumble."), _("Switch 2 Controllers"), wxOK | wxICON_INFORMATION, this); + return; + } + if (m_settings.rumble <= 0) + { + wxMessageBox(_("Increase the Rumble slider above 0% to test."), _("Switch 2 Controllers"), wxOK | wxICON_INFORMATION, this); + return; + } + if (!native->TryRumble(m_settings.rumble)) + { + wxMessageBox(_("The rumble command was not accepted. Reconnect the controller and try again."), _("Switch 2 Controllers"), wxOK | wxICON_WARNING, this); + return; + } + m_rumble_time = std::chrono::steady_clock::now(); + }); + box_sizer->Add(test, 0, wxALL, 5); + } +#endif sizer->Add(box_sizer, 1, wxALL|wxEXPAND, 5); } @@ -215,7 +279,7 @@ DefaultControllerSettings::DefaultControllerSettings(wxWindow* parent, const wxP this->Bind(wxEVT_CLOSE_WINDOW, &DefaultControllerSettings::on_close, this); m_timer = new wxTimer(this); - Bind(wxEVT_TIMER, &DefaultControllerSettings::on_timer, this); + Bind(wxEVT_TIMER, &DefaultControllerSettings::on_timer, this, m_timer->GetId()); m_timer->Start(100); } diff --git a/src/gui/wxgui/input/settings/DefaultControllerSettings.h b/src/gui/wxgui/input/settings/DefaultControllerSettings.h index a5cb76efbe..d7dce8f3f9 100644 --- a/src/gui/wxgui/input/settings/DefaultControllerSettings.h +++ b/src/gui/wxgui/input/settings/DefaultControllerSettings.h @@ -16,6 +16,9 @@ class DefaultControllerSettings : public wxDialog ~DefaultControllerSettings(); private: +#ifdef HAVE_SWITCH2KIT + std::unique_ptr m_motion_timer; +#endif void update_settings(); ControllerPtr m_controller; diff --git a/src/input/CMakeLists.txt b/src/input/CMakeLists.txt index 4978ef0a01..a5f16b561a 100644 --- a/src/input/CMakeLists.txt +++ b/src/input/CMakeLists.txt @@ -108,3 +108,7 @@ target_link_libraries(CemuInput PRIVATE if (ENABLE_BLUEZ) target_link_libraries(CemuInput PRIVATE bluez::bluez) endif () + +if(ENABLE_SWITCH2KIT) + target_link_libraries(CemuInput PRIVATE Switch2Kit::SDL3) +endif() diff --git a/src/input/ControllerFactory.cpp b/src/input/ControllerFactory.cpp index 3827e1d48d..2ffeb142d3 100644 --- a/src/input/ControllerFactory.cpp +++ b/src/input/ControllerFactory.cpp @@ -65,6 +65,10 @@ ControllerPtr ControllerFactory::CreateController(InputAPI::Type api, std::strin #ifdef HAS_SDL case InputAPI::SDLController: { +#ifdef HAVE_SWITCH2KIT + if (uuid.starts_with("s2k:")) + return std::make_shared(uuid, display_name); +#endif // diid_guid const auto index = uuid.find_first_of('_'); if (index == std::string_view::npos) diff --git a/src/input/InputManager.cpp b/src/input/InputManager.cpp index 42b1db264b..ee6926d836 100644 --- a/src/input/InputManager.cpp +++ b/src/input/InputManager.cpp @@ -440,8 +440,10 @@ void InputManager::save() noexcept } } -bool InputManager::save(size_t player_index, std::string_view filename) +bool InputManager::save(size_t player_index, std::string_view filename, bool updateProfileName) { + if (player_index >= kMaxController) + return false; // dont overwrite files if set by gameprofile if (m_is_gameprofile_set[player_index]) return true; @@ -461,6 +463,20 @@ bool InputManager::save(size_t player_index, std::string_view filename) file_path.replace_extension(".xml"); // force .xml extension + const std::string profileName = !is_default_file && updateProfileName ? + std::string{filename} : emulated_controller->get_profile_name(); + std::string xmlStr = SerializeControllerProfile(emulated_controller, profileName); + if (!FileStream::WriteFileAtomic(file_path, + std::span(reinterpret_cast(xmlStr.data()), xmlStr.size()))) + return false; + if (!is_default_file && updateProfileName) + emulated_controller->m_profile_name = profileName; + return true; +} + +std::string InputManager::SerializeControllerProfile(const EmulatedControllerPtr& emulated_controller, const std::string& profileName) +{ + std::shared_lock lock(emulated_controller->m_mutex); pugi::xml_document doc; auto declaration_node = doc.append_child(pugi::node_declaration); declaration_node.append_attribute("version") = "1.0"; @@ -471,12 +487,8 @@ bool InputManager::save(size_t player_index, std::string_view filename) emulated_controller->type_string() }.c_str()); - if(!is_default_file) - emulated_controller->m_profile_name = std::string{filename}; - - if (emulated_controller->has_profile_name()) - emulated_controller_node.append_child("profile").append_child(pugi::node_pcdata).set_value( - emulated_controller->get_profile_name().c_str()); + if (!profileName.empty() && profileName != "default") + emulated_controller_node.append_child("profile").text().set(profileName.c_str()); // custom settings emulated_controller->save(emulated_controller_node); @@ -539,16 +551,18 @@ bool InputManager::save(size_t player_index, std::string_view filename) } } } - FileStream* fs = FileStream::createFile2(file_path); - if (!fs) - return false; std::stringstream xmlData; doc.save(xmlData); - std::string xmlStr = xmlData.str(); - fs->writeData(xmlStr.data(), xmlStr.size()); - delete fs; - return true; + return xmlData.str(); +} + +#ifdef HAVE_SWITCH2KIT +std::string InputManager::ControllerConfigSnapshot(size_t playerIndex) +{ + const auto controller = get_controller(playerIndex); + return controller ? SerializeControllerProfile(controller, controller->get_profile_name()) : std::string{}; } +#endif bool InputManager::is_gameprofile_set(size_t player_index) const { diff --git a/src/input/InputManager.h b/src/input/InputManager.h index b510474be5..6164f42cd7 100644 --- a/src/input/InputManager.h +++ b/src/input/InputManager.h @@ -42,9 +42,12 @@ class InputManager : public Singleton bool migrate_config(const fs::path& file_path); void save() noexcept; - bool save(size_t player_index, std::string_view filename = {}); + bool save(size_t player_index, std::string_view filename = {}, bool updateProfileName = true); bool is_gameprofile_set(size_t player_index) const; +#ifdef HAVE_SWITCH2KIT + std::string ControllerConfigSnapshot(size_t playerIndex); +#endif void Shutdown(); @@ -95,6 +98,7 @@ class InputManager : public Singleton std::atomic m_mouse_wheel; private: + static std::string SerializeControllerProfile(const EmulatedControllerPtr& controller, const std::string& profileName); void update_thread(); std::thread m_update_thread; diff --git a/src/input/api/Controller.cpp b/src/input/api/Controller.cpp index b913a49776..384947ff64 100644 --- a/src/input/api/Controller.cpp +++ b/src/input/api/Controller.cpp @@ -205,8 +205,13 @@ ControllerBase::Settings ControllerBase::get_settings() const void ControllerBase::set_settings(const Settings& settings) { - std::scoped_lock lock(m_settings_mutex); - m_settings = settings; + bool motionChanged; + { + std::scoped_lock lock(m_settings_mutex); + motionChanged = m_settings.motion != settings.motion; + m_settings = settings; + } + if (motionChanged) motion_settings_changed(); } void ControllerBase::set_axis_settings(const AxisSetting& settings) @@ -235,6 +240,11 @@ void ControllerBase::set_rumble(float rumble) void ControllerBase::set_use_motion(bool state) { - std::scoped_lock lock(m_settings_mutex); - m_settings.motion = state; + bool motionChanged; + { + std::scoped_lock lock(m_settings_mutex); + motionChanged = m_settings.motion != state; + m_settings.motion = state; + } + if (motionChanged) motion_settings_changed(); } diff --git a/src/input/api/Controller.h b/src/input/api/Controller.h index feae19a5f1..1f493d4499 100644 --- a/src/input/api/Controller.h +++ b/src/input/api/Controller.h @@ -9,77 +9,7 @@ namespace pugi class xml_node; } -enum Buttons2 : uint64 -{ - // General - kButton0, - kButton1, - kButton2, - kButton3, - kButton4, - kButton5, - kButton6, - kButton7, - kButton8, - kButton9, - kButton10, - kButton11, - kButton12, - kButton13, - kButton14, - kButton15, - kButton16, - kButton17, - kButton18, - kButton19, - kButton20, - kButton21, - kButton22, - kButton23, - kButton24, - kButton25, - kButton26, - kButton27, - kButton28, - kButton29, - kButton30, - kButton31, - - // Trigger - kButtonZL, - kButtonZR, - - // DPAD - kButtonUp, - kButtonDown, - kButtonLeft, - kButtonRight, - - // positive values - kAxisXP, - kAxisYP, - - kRotationXP, - kRotationYP, - - kTriggerXP, - kTriggerYP, - - // negative values - kAxisXN, - kAxisYN, - - kRotationXN, - kRotationYN, - - kTriggerXN, - kTriggerYN, - - kButtonMAX, - - kButtonNoneAxisMAX = kButtonRight, - kButtonAxisStart = kAxisXP, -}; +#include "input/api/ControllerButtons.h" class ControllerBase { @@ -112,9 +42,16 @@ class ControllerBase float get_axis_value(uint64 button) const; virtual bool has_axis() const { return true; } - bool use_motion() { return has_motion() && m_settings.motion; } + bool use_motion() { return has_motion() && get_settings().motion; } virtual bool has_motion() { return false; } virtual MotionSample get_motion_sample() { return {}; } + // Availability is separate from capability/settings. A missing native stream + // must not be passed to VPAD/KPAD as an invented zero-valued measurement. + virtual std::optional get_available_motion_sample() + { + if (use_motion()) return get_motion_sample(); + return {}; + } virtual bool has_position() { return false; } virtual glm::vec2 get_position() { return {}; } @@ -156,6 +93,7 @@ class ControllerBase bool operator!=(const ControllerBase& c) const { return !(*this == c); } protected: + virtual void motion_settings_changed() {} std::string m_uuid; std::string m_display_name; diff --git a/src/input/api/ControllerButtons.h b/src/input/api/ControllerButtons.h new file mode 100644 index 0000000000..5521829401 --- /dev/null +++ b/src/input/api/ControllerButtons.h @@ -0,0 +1,75 @@ +#pragma once + +#include + +enum Buttons2 : std::uint64_t +{ + // General + kButton0, + kButton1, + kButton2, + kButton3, + kButton4, + kButton5, + kButton6, + kButton7, + kButton8, + kButton9, + kButton10, + kButton11, + kButton12, + kButton13, + kButton14, + kButton15, + kButton16, + kButton17, + kButton18, + kButton19, + kButton20, + kButton21, + kButton22, + kButton23, + kButton24, + kButton25, + kButton26, + kButton27, + kButton28, + kButton29, + kButton30, + kButton31, + + // Trigger + kButtonZL, + kButtonZR, + + // DPAD + kButtonUp, + kButtonDown, + kButtonLeft, + kButtonRight, + + // positive values + kAxisXP, + kAxisYP, + + kRotationXP, + kRotationYP, + + kTriggerXP, + kTriggerYP, + + // negative values + kAxisXN, + kAxisYN, + + kRotationXN, + kRotationYN, + + kTriggerXN, + kTriggerYN, + + kButtonMAX, + + kButtonNoneAxisMAX = kButtonRight, + kButtonAxisStart = kAxisXP, +}; diff --git a/src/input/api/SDL/SDLController.cpp b/src/input/api/SDL/SDLController.cpp index 77b04172fd..814e6640bb 100644 --- a/src/input/api/SDL/SDLController.cpp +++ b/src/input/api/SDL/SDLController.cpp @@ -1,6 +1,10 @@ #include "input/api/SDL/SDLController.h" #include "input/api/SDL/SDLControllerProvider.h" +#ifdef HAVE_SWITCH2KIT +#include "CemuMotion.hpp" +#include "input/api/SDL/Switch2KitIdentity.h" +#endif SDLController::SDLController(const SDL_GUID& guid, size_t guid_index) : base_type(fmt::format("{}_", guid_index), fmt::format("Controller {}", guid_index + 1)), m_guid_index(guid_index), @@ -19,10 +23,23 @@ SDLController::SDLController(const SDL_GUID& guid, size_t guid_index, std::strin m_uuid += tmp; } +#ifdef HAVE_SWITCH2KIT +SDLController::SDLController(std::string_view physical_key, std::string_view display_name, unsigned model) + : base_type(std::string(physical_key), display_name.empty() ? "Switch2Kit controller" : display_name), + m_guid_index(0), m_guid{}, m_physical_key(physical_key), m_switch2Model(model) +{ + if (!CemuSwitch2Kit::ValidPhysicalKey(physical_key)) throw std::invalid_argument("Invalid Switch2Kit controller identity"); +} +#endif + SDLController::~SDLController() { +#ifdef HAVE_SWITCH2KIT + m_motion_policy.reset(); +#endif if (m_controller) { + SDL_RumbleGamepad(m_controller, 0, 0, 0); SDL_CloseGamepad(m_controller); m_controller = nullptr; } @@ -38,6 +55,9 @@ bool SDLController::is_connected() if (!SDL_GamepadConnected(m_controller)) { +#ifdef HAVE_SWITCH2KIT + m_motion_policy.reset(); +#endif SDL_CloseGamepad(m_controller); m_controller = nullptr; return false; @@ -48,17 +68,28 @@ bool SDLController::is_connected() bool SDLController::connect() { + std::scoped_lock connectionLock(m_controller_mutex); if (is_connected()) return true; - m_has_rumble = false; - const auto index = m_provider->get_index(m_guid_index, m_guid); - std::scoped_lock lock(m_controller_mutex); - + m_has_rumble = m_has_gyro = m_has_accel = false; + auto index = m_provider->get_index(m_guid_index, m_guid); +#ifdef HAVE_SWITCH2KIT + const auto native_instance = m_physical_key.empty() ? 0 : SDLControllerProvider::FindSwitch2Device(m_physical_key); + if (!m_physical_key.empty() && !native_instance) return false; +#endif int gamepad_count = 0; SDL_JoystickID *gamepad_ids = SDL_GetGamepads(&gamepad_count); +#ifdef HAVE_SWITCH2KIT + if (native_instance) + { + index = -1; + for (int i = 0; gamepad_ids && i < gamepad_count; ++i) + if (gamepad_ids[i] == native_instance) { index = i; break; } + } +#endif if (!gamepad_ids || index < 0 || index >= gamepad_count) { SDL_free(gamepad_ids); @@ -80,34 +111,53 @@ bool SDLController::connect() m_buttons[i] = SDL_GamepadHasButton(m_controller, (SDL_GamepadButton)i); for (size_t i = 0; i < SDL_GAMEPAD_AXIS_COUNT; ++i) m_axis[i] = SDL_GamepadHasAxis(m_controller, (SDL_GamepadAxis)i); - if (SDL_GamepadHasSensor(m_controller, SDL_SENSOR_ACCEL)) - m_has_accel = SDL_SetGamepadSensorEnabled(m_controller, SDL_SENSOR_ACCEL, true); - if (SDL_GamepadHasSensor(m_controller, SDL_SENSOR_GYRO)) - m_has_gyro = SDL_SetGamepadSensorEnabled(m_controller, SDL_SENSOR_GYRO, true); - m_has_rumble = SDL_RumbleGamepad(m_controller, 0, 0, 0); +#ifdef HAVE_SWITCH2KIT + if (!m_physical_key.empty()) + { + m_switch2Model = SDL_GetGamepadProduct(m_controller); + m_has_accel = SDL_GamepadHasSensor(m_controller, SDL_SENSOR_ACCEL); + m_has_gyro = SDL_GamepadHasSensor(m_controller, SDL_SENSOR_GYRO); + motion_settings_changed(); + } + else +#endif + { + if (SDL_GamepadHasSensor(m_controller, SDL_SENSOR_ACCEL)) + m_has_accel = SDL_SetGamepadSensorEnabled(m_controller, SDL_SENSOR_ACCEL, true); + if (SDL_GamepadHasSensor(m_controller, SDL_SENSOR_GYRO)) + m_has_gyro = SDL_SetGamepadSensorEnabled(m_controller, SDL_SENSOR_GYRO, true); + } + m_has_rumble = SDL_GetBooleanProperty(SDL_GetGamepadProperties(m_controller), + SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false); +#ifdef HAVE_SWITCH2KIT + if (m_playerIndex) + SDL_SetGamepadPlayerIndex(m_controller, *m_playerIndex); +#endif return true; } -void SDLController::start_rumble() +bool SDLController::TryRumble(float strength) { std::scoped_lock lock(m_controller_mutex); - if (is_connected() && !m_has_rumble) - return; - if (m_settings.rumble <= 0) - return; - SDL_RumbleGamepad(m_controller, (Uint16)(m_settings.rumble * 0xFFFF), (Uint16)(m_settings.rumble * 0xFFFF), 5 * 1000); + if (!is_connected() || !m_has_rumble || !std::isfinite(strength)) + return false; + const auto amplitude = static_cast(std::clamp(strength, 0.0f, 1.0f) * 0xFFFF); + return SDL_RumbleGamepad(m_controller, amplitude, amplitude, amplitude ? 5 * 1000 : 0); +} + +void SDLController::start_rumble() +{ + TryRumble(get_settings().rumble); } void SDLController::stop_rumble() { - std::scoped_lock lock(m_controller_mutex); - if (is_connected() && !m_has_rumble) - return; - SDL_RumbleGamepad(m_controller, 0, 0, 0); + TryRumble(0); } MotionSample SDLController::get_motion_sample() { + std::scoped_lock lock(m_controller_mutex); if (is_connected() && has_motion()) return m_provider->motion_sample(m_diid); return {}; @@ -147,3 +197,87 @@ ControllerState SDLController::raw_state() return result; } + +#ifdef HAVE_SWITCH2KIT +std::optional SDLController::GetSwitch2Model() +{ + std::scoped_lock lock(m_controller_mutex); + if (m_physical_key.empty() || !m_switch2Model) + return {}; + return m_switch2Model; +} + +void SDLController::SetPlayerIndex(size_t index) +{ + std::scoped_lock lock(m_controller_mutex); + if (m_physical_key.empty() || index >= 8) + return; + m_playerIndex = static_cast(index); + if (is_connected()) + SDL_SetGamepadPlayerIndex(m_controller, *m_playerIndex); +} + +std::optional SDLController::get_available_motion_sample() +{ + if (!use_motion()) return {}; + std::scoped_lock lock(m_controller_mutex); + if (!is_connected()) return {}; + if (m_physical_key.empty()) return get_motion_sample(); + return SDLControllerProvider::AvailableSwitch2Motion(m_diid); +} +void SDLController::motion_settings_changed() +{ + const bool enabled = get_settings().motion; + std::scoped_lock lock(m_controller_mutex); + if (m_physical_key.empty() || !m_controller || !SDL_GamepadConnected(m_controller)) return; + if (!m_motion_policy) m_motion_policy = std::make_unique(); + m_motion_policy_error = !m_motion_policy->update(m_controller, enabled && m_has_accel && m_has_gyro); +} +int SDLController::set_motion_profile(const std::string& path) +{ + if (m_physical_key.empty() || path.size() > 4096) return S2K_INVALID_ARGUMENT; + const int result = path.empty() ? (SDLControllerProvider::RemoveSwitch2MotionProfile(m_physical_key), S2K_OK) : + SDLControllerProvider::LoadSwitch2MotionProfile(path, m_physical_key); + std::scoped_lock lock(m_controller_mutex); + m_motion_profile_error = result != S2K_OK; + if (result == S2K_OK) m_motion_profile_path = path; + return result; +} +std::string SDLController::motion_status() const +{ + // Called by the settings UI; no persistent key or raw sensor data is logged. + { + std::scoped_lock lock(m_controller_mutex); + if (m_motion_profile_error) return "Invalid/unreadable calibration; previous selection retained"; + if (m_motion_policy_error) return "Motion sensor enablement failed; disable and reenable Use motion"; + if (!get_settings().motion) return "Motion disabled by Use motion setting"; + } + return SDLControllerProvider::Switch2MotionStatus(m_physical_key); +} +void SDLController::save(pugi::xml_node& node) +{ + std::scoped_lock lock(m_controller_mutex); + if (!m_physical_key.empty()) + { + node.remove_child("rumble"); + node.append_child("rumble").text().set(get_settings().rumble); + node.remove_child("motion"); + node.append_child("motion").text().set(get_settings().motion); + } + node.remove_child("switch2kit_motion_profile"); + if (!m_physical_key.empty() && !m_motion_profile_path.empty()) + node.append_child("switch2kit_motion_profile").text().set(m_motion_profile_path.c_str()); +} +void SDLController::load(const pugi::xml_node& node) +{ + if (m_physical_key.empty()) return; + const auto* value = node.child("switch2kit_motion_profile").child_value(); + const auto length = strnlen(value, 4097); + if (length > 4096) + { + std::scoped_lock lock(m_controller_mutex); + m_motion_profile_error = true; + } + else if (length) set_motion_profile(std::string(value, length)); +} +#endif diff --git a/src/input/api/SDL/SDLController.h b/src/input/api/SDL/SDLController.h index 1a4dd8dc36..7798c9eb64 100644 --- a/src/input/api/SDL/SDLController.h +++ b/src/input/api/SDL/SDLController.h @@ -5,12 +5,27 @@ #include +#ifdef HAVE_SWITCH2KIT +namespace Switch2Kit { class CemuSensorPolicy; } +#endif + class SDLController : public Controller { public: SDLController(const SDL_GUID& guid, size_t guid_index); SDLController(const SDL_GUID& guid, size_t guid_index, std::string_view display_name); +#ifdef HAVE_SWITCH2KIT + SDLController(std::string_view physical_key, std::string_view display_name, unsigned model = 0); + bool IsSwitch2Controller() const { return !m_physical_key.empty(); } + std::optional GetSwitch2Model(); + void SetPlayerIndex(size_t index); + int set_motion_profile(const std::string& path); + std::string motion_status() const; + std::optional get_available_motion_sample() override; + void save(pugi::xml_node& node) override; + void load(const pugi::xml_node& node) override; +#endif ~SDLController() override; std::string_view api_name() const override @@ -23,9 +38,11 @@ class SDLController : public Controller bool is_connected() override; bool connect() override; - bool has_motion() override { return m_has_gyro && m_has_accel; } - bool has_rumble() override { return m_has_rumble; } + bool has_motion() override { std::scoped_lock lock(m_controller_mutex); return m_has_gyro && m_has_accel; } + bool has_rumble() override { std::scoped_lock lock(m_controller_mutex); return m_has_rumble; } + // Reports command acceptance, not a physical hardware measurement. + bool TryRumble(float strength); void start_rumble() override; void stop_rumble() override; @@ -46,7 +63,17 @@ class SDLController : public Controller size_t m_guid_index; SDL_GUID m_guid; - std::recursive_mutex m_controller_mutex; +#ifdef HAVE_SWITCH2KIT + void motion_settings_changed() override; + std::string m_physical_key; + std::string m_motion_profile_path; + bool m_motion_profile_error = false; + bool m_motion_policy_error = false; + std::optional m_playerIndex; + unsigned m_switch2Model = 0; + std::unique_ptr m_motion_policy; +#endif + mutable std::recursive_mutex m_controller_mutex; SDL_Gamepad* m_controller = nullptr; SDL_JoystickID m_diid = -1; diff --git a/src/input/api/SDL/SDLControllerProvider.cpp b/src/input/api/SDL/SDLControllerProvider.cpp index 3aa146a450..88c97a2d32 100644 --- a/src/input/api/SDL/SDLControllerProvider.cpp +++ b/src/input/api/SDL/SDLControllerProvider.cpp @@ -5,6 +5,77 @@ #include #include +#ifdef HAVE_SWITCH2KIT +#include "CemuMotion.hpp" +#endif +struct SDLControllerProvider::MotionState +{ + WiiUMotionHandler handler; + MotionSample data; + MotionInfoTracking tracking; +#ifdef HAVE_SWITCH2KIT + bool nativeMotion = false; + Switch2Kit::CemuMotion native; +#endif +}; +std::unordered_map SDLControllerProvider::s_motion_states; + +#ifdef HAVE_SWITCH2KIT +#include "SDLHost.hpp" +#include "input/api/SDL/Switch2KitSession.h" +static Switch2KitSession& NativeSession() +{ + static Switch2KitSession session; + return session; +} +static Switch2Kit::SDLHost& nativeControllers() +{ + return NativeSession().GetHost(); +} +int SDLControllerProvider::FindSwitch2Controllers() { return NativeSession().Discover(); } +int SDLControllerProvider::DisconnectSwitch2Controllers() { return NativeSession().Stop(); } +std::string SDLControllerProvider::Switch2ControllerStatus() +{ + const auto state = nativeControllers().snapshot(); + if (!NativeSession().IsEnabled()) return "Select Find, then hold the controller Sync button."; + if (state.bluetooth == S2K_BT_UNAUTHORIZED) return "Allow Cemu Bluetooth access in System Settings."; + if (state.bluetooth == S2K_BT_OFF) return "Turn on Bluetooth in System Settings."; + if (state.bluetooth == S2K_BT_UNSUPPORTED) return "Bluetooth is unavailable on this Mac."; + return std::to_string(state.count) + " connected; " + + (state.discovery == S2K_DISCOVERY_SCANNING ? "searching" : "discovery idle"); +} +SDL_JoystickID SDLControllerProvider::FindSwitch2Device(std::string_view key) +{ + return nativeControllers().instance(std::string(key)); +} +int SDLControllerProvider::LoadSwitch2MotionProfile(const std::string& path, const std::string& key) +{ + return nativeControllers().loadMotionProfile(path, key); +} +void SDLControllerProvider::RemoveSwitch2MotionProfile(const std::string& key) +{ + nativeControllers().removeMotionProfile(key); +} +std::string SDLControllerProvider::Switch2MotionStatus(const std::string& key) +{ + const auto id = nativeControllers().instance(key); + if (!id) return "Controller disconnected; selection retained for reconnect"; + const auto state = Switch2Kit::SDL3Adapter::motionState(id); + auto status = state.status; + if (status == Switch2Kit::SDL3MotionStatus::Active && !AvailableSwitch2Motion(id)) + status = Switch2Kit::SDL3MotionStatus::Waiting; + return Switch2Kit::SDL3Adapter::motionStatusText(status); +} +std::optional SDLControllerProvider::AvailableSwitch2Motion(SDL_JoystickID id) +{ + const auto state = Switch2Kit::SDL3Adapter::motionState(id); + std::scoped_lock lock(s_mutex); + const auto it = s_motion_states.find(id); + if (it == s_motion_states.end() || !it->second.nativeMotion) return {}; + it->second.native.synchronize(state); + return it->second.native.availableSample(); +} +#endif struct SDL_JoystickGUIDHash { @@ -68,6 +139,15 @@ std::vector> SDLControllerProvider::get_controll { for (size_t i = 0; i < gamepad_count; ++i) { +#ifdef HAVE_SWITCH2KIT + const auto native_key = nativeControllers().identity(gamepad_ids[i]); + if (!native_key.empty()) + { + const char* name = SDL_GetGamepadNameForID(gamepad_ids[i]); + result.emplace_back(std::make_shared(native_key, name ? name : "Switch2Kit controller", SDL_GetGamepadProductForID(gamepad_ids[i]))); + continue; + } +#endif const auto guid = SDL_GetGamepadGUIDForID(gamepad_ids[i]); const auto it = guid_counter.try_emplace(guid, 0); if (const char* name = SDL_GetGamepadNameForID(gamepad_ids[i])) @@ -108,8 +188,20 @@ int SDLControllerProvider::get_index(size_t guid_index, const SDL_GUID& guid) co MotionSample SDLControllerProvider::motion_sample(SDL_JoystickID diid) { +#ifdef HAVE_SWITCH2KIT + // Query SDL before taking s_mutex: callbacks/enumeration use that lock order. + const auto native = Switch2Kit::SDL3Adapter::motionState(diid); + std::scoped_lock lock(s_mutex); + auto it = s_motion_states.find(diid); + if (it != s_motion_states.end() && it->second.nativeMotion) + { + it->second.native.synchronize(native); // Also resets during event-free inactivity. + return it->second.native.snapshot(); + } +#else std::shared_lock lock(s_mutex); auto it = s_motion_states.find(diid); +#endif return (it != s_motion_states.end()) ? it->second.data : MotionSample{}; } @@ -141,12 +233,19 @@ void SDLControllerProvider::InitSDL() void SDLControllerProvider::ShutdownSDL() { +#ifdef HAVE_SWITCH2KIT + NativeSession().Shutdown(); + { std::scoped_lock lock(s_mutex); s_motion_states.clear(); } +#endif SDL_QuitSubSystem(SDL_INIT_GAMEPAD | SDL_INIT_HAPTIC); } #if BOOST_OS_MACOS void SDLControllerProvider::PumpSDLEvents() { +#ifdef HAVE_SWITCH2KIT + NativeSession().Pump(); +#endif SDL_Event event; while (SDL_PollEvent(&event)) HandleSDLEvent(event); @@ -177,15 +276,17 @@ void SDLControllerProvider::HandleSDLEvent(SDL_Event& event) } case SDL_EVENT_GAMEPAD_ADDED: /**< A new Game controller has been inserted into the system */ { - std::scoped_lock _l(s_mutex); + // Reconnect/UI callbacks can query motion; never hold s_mutex here. InputManager::instance().on_device_changed(); break; } case SDL_EVENT_GAMEPAD_REMOVED: /**< An opened Game controller has been removed */ { - std::scoped_lock _l(s_mutex); + { + std::scoped_lock lock(s_mutex); + s_motion_states.erase(event.gdevice.which); + } InputManager::instance().on_device_changed(); - s_motion_states.erase(event.gdevice.which); break; } case SDL_EVENT_GAMEPAD_REMAPPED: /**< The controller mapping was updated */ @@ -207,9 +308,27 @@ void SDLControllerProvider::HandleSDLEvent(SDL_Event& event) case SDL_EVENT_GAMEPAD_SENSOR_UPDATE: /**< Game controller sensor was updated */ { SDL_JoystickID id = event.gsensor.which; +#ifdef HAVE_SWITCH2KIT + // Old queued events from a detached generation must not create state. + Switch2Kit::SDL3MotionState native; + { + TempState joystickLock(SDL_LockJoysticks, SDL_UnlockJoysticks); + auto* gamepad = SDL_GetGamepadFromID(id); + if (!gamepad || !SDL_GamepadConnected(gamepad)) break; + native = Switch2Kit::SDL3Adapter::motionStateAt(id, event.gsensor.sensor_timestamp); + } +#endif uint64_t ts = event.gsensor.timestamp; std::scoped_lock _l(s_mutex); auto& state = s_motion_states[id]; +#ifdef HAVE_SWITCH2KIT + if (native.owned || state.nativeMotion) + { + state.nativeMotion = true; + state.native.consume(native, event.gsensor); + break; + } +#endif auto& tracking = state.tracking; if (event.gsensor.sensor == SDL_SENSOR_ACCEL) diff --git a/src/input/api/SDL/SDLControllerProvider.h b/src/input/api/SDL/SDLControllerProvider.h index e67eb06795..98e3b2007d 100644 --- a/src/input/api/SDL/SDLControllerProvider.h +++ b/src/input/api/SDL/SDLControllerProvider.h @@ -23,6 +23,16 @@ class SDLControllerProvider : public ControllerProviderBase int get_index(size_t guid_index, const SDL_GUID& guid) const; MotionSample motion_sample(SDL_JoystickID diid); +#ifdef HAVE_SWITCH2KIT + static int FindSwitch2Controllers(); + static int DisconnectSwitch2Controllers(); + static std::string Switch2ControllerStatus(); + static SDL_JoystickID FindSwitch2Device(std::string_view key); + static int LoadSwitch2MotionProfile(const std::string& path, const std::string& key); + static void RemoveSwitch2MotionProfile(const std::string& key); + static std::string Switch2MotionStatus(const std::string& key); + static std::optional AvailableSwitch2Motion(SDL_JoystickID id); +#endif // exposed for manual event handling on macOS #if BOOST_OS_MACOS @@ -56,14 +66,6 @@ class SDLControllerProvider : public ControllerProviderBase glm::vec3 acc{}; }; - struct MotionState - { - WiiUMotionHandler handler; - MotionSample data; - MotionInfoTracking tracking; - - MotionState() = default; - }; - - inline static std::unordered_map s_motion_states{}; + struct MotionState; + static std::unordered_map s_motion_states; }; diff --git a/src/input/api/SDL/Switch2KitIdentity.h b/src/input/api/SDL/Switch2KitIdentity.h new file mode 100644 index 0000000000..c995e64924 --- /dev/null +++ b/src/input/api/SDL/Switch2KitIdentity.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +namespace CemuSwitch2Kit +{ +inline bool ValidPhysicalKey(std::string_view key) +{ + return key.size() == 36 && key.starts_with("s2k:") && + std::all_of(key.begin() + 4, key.end(), [](char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + }); +} +} // namespace CemuSwitch2Kit diff --git a/src/input/api/SDL/Switch2KitMapping.h b/src/input/api/SDL/Switch2KitMapping.h new file mode 100644 index 0000000000..4b674f93ff --- /dev/null +++ b/src/input/api/SDL/Switch2KitMapping.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "input/api/ControllerButtons.h" + +namespace CemuSwitch2Kit +{ +using Mapping = std::vector>; + +// The SDK preserves physical face-button positions for the asymmetrical GameCube +// layout. Translate once here to Wii U's printed Nintendo labels. Split Joy-Con +// mappings intentionally omit the other half so Cemu can combine two sources. +template +Mapping GamepadMapping(unsigned model) +{ + const bool gc = model == S2K_GAMECUBE; + const bool left = model != S2K_JOYCON_RIGHT; + const bool right = model != S2K_JOYCON_LEFT; + Mapping mapping; + if (right) + { + mapping = { + {Pad::kButtonId_A, gc ? SDL_GAMEPAD_BUTTON_SOUTH : SDL_GAMEPAD_BUTTON_EAST}, + {Pad::kButtonId_B, gc ? SDL_GAMEPAD_BUTTON_WEST : SDL_GAMEPAD_BUTTON_SOUTH}, + {Pad::kButtonId_X, gc ? SDL_GAMEPAD_BUTTON_EAST : SDL_GAMEPAD_BUTTON_NORTH}, + {Pad::kButtonId_Y, gc ? SDL_GAMEPAD_BUTTON_NORTH : SDL_GAMEPAD_BUTTON_WEST}, + {Pad::kButtonId_R, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER}, + {Pad::kButtonId_ZR, kTriggerYP}, + {Pad::kButtonId_Plus, SDL_GAMEPAD_BUTTON_START}, + {Pad::kButtonId_Home, SDL_GAMEPAD_BUTTON_GUIDE}, + {Pad::kButtonId_StickR_Up, kRotationYN}, + {Pad::kButtonId_StickR_Down, kRotationYP}, + {Pad::kButtonId_StickR_Left, kRotationXN}, + {Pad::kButtonId_StickR_Right, kRotationXP}, + }; + } + if (left) + { + const Mapping leftMapping = { + {Pad::kButtonId_L, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER}, + {Pad::kButtonId_ZL, kTriggerXP}, + {Pad::kButtonId_Minus, gc ? SDL_GAMEPAD_BUTTON_MISC2 : SDL_GAMEPAD_BUTTON_BACK}, + {Pad::kButtonId_Up, SDL_GAMEPAD_BUTTON_DPAD_UP}, + {Pad::kButtonId_Down, SDL_GAMEPAD_BUTTON_DPAD_DOWN}, + {Pad::kButtonId_Left, SDL_GAMEPAD_BUTTON_DPAD_LEFT}, + {Pad::kButtonId_Right, SDL_GAMEPAD_BUTTON_DPAD_RIGHT}, + {Pad::kButtonId_StickL_Up, kAxisYN}, + {Pad::kButtonId_StickL_Down, kAxisYP}, + {Pad::kButtonId_StickL_Left, kAxisXN}, + {Pad::kButtonId_StickL_Right, kAxisXP}, + }; + mapping.insert(mapping.end(), leftMapping.begin(), leftMapping.end()); + } + if constexpr (requires { Pad::kButtonId_StickL; Pad::kButtonId_StickR; }) + { + if (!gc && left) + mapping.emplace_back(Pad::kButtonId_StickL, SDL_GAMEPAD_BUTTON_LEFT_STICK); + if (!gc && right) + mapping.emplace_back(Pad::kButtonId_StickR, SDL_GAMEPAD_BUTTON_RIGHT_STICK); + } + if constexpr (requires { Pad::kButtonId_Mic; Pad::kButtonId_Screen; }) + { + if (left) + mapping.emplace_back(Pad::kButtonId_Mic, SDL_GAMEPAD_BUTTON_MISC1); + if (!gc && right) + mapping.emplace_back(Pad::kButtonId_Screen, SDL_GAMEPAD_BUTTON_MISC2); + } + return mapping; +} + +inline bool IsSupportedModel(unsigned model) +{ + return model == S2K_GAMECUBE || model == S2K_PRO || + model == S2K_JOYCON_LEFT || model == S2K_JOYCON_RIGHT; +} +} // namespace CemuSwitch2Kit diff --git a/src/input/api/SDL/Switch2KitSession.h b/src/input/api/SDL/Switch2KitSession.h new file mode 100644 index 0000000000..a4d762d0fb --- /dev/null +++ b/src/input/api/SDL/Switch2KitSession.h @@ -0,0 +1,41 @@ +#pragma once + +// Start/Stop/Pump are called on Cemu's macOS main thread. The SDK host serializes +// identity, profile and status access from controller/configuration threads. +// Keeping the state here also prevents a failed first start from enabling polling. +template +class Switch2KitSession +{ +public: + int Discover() + { + const int result = m_host.discover(); + if (result == 0) + m_enabled = true; + return result; + } + + int Stop() + { + m_enabled = false; + return m_host.stop(); + } + + int Pump() + { + return m_enabled ? m_host.pump() : 0; + } + + void Shutdown() + { + m_enabled = false; + m_host.shutdown(); + } + + bool IsEnabled() const { return m_enabled; } + Host& GetHost() { return m_host; } + +private: + Host m_host; + bool m_enabled = false; +}; diff --git a/src/input/emulated/ClassicController.cpp b/src/input/emulated/ClassicController.cpp index 8328a10d31..a5bf0f3a26 100644 --- a/src/input/emulated/ClassicController.cpp +++ b/src/input/emulated/ClassicController.cpp @@ -1,4 +1,7 @@ #include "input/emulated/ClassicController.h" +#ifdef HAVE_SWITCH2KIT +#include "input/api/SDL/Switch2KitMapping.h" +#endif #include "input/api/Controller.h" #ifdef HAS_SDL @@ -135,6 +138,14 @@ bool ClassicController::set_default_mapping(const std::shared_ptr(controller); +#ifdef HAVE_SWITCH2KIT + if (const auto model = sdl_controller->GetSwitch2Model(); + model && CemuSwitch2Kit::IsSupportedModel(*model)) + { + mapping = CemuSwitch2Kit::GamepadMapping(*model); + } + else +#endif if (sdl_controller->get_guid() == SDLController::kLeftJoyCon) { mapping = diff --git a/src/input/emulated/EmulatedController.cpp b/src/input/emulated/EmulatedController.cpp index 1e4db92af8..a98e363cb0 100644 --- a/src/input/emulated/EmulatedController.cpp +++ b/src/input/emulated/EmulatedController.cpp @@ -1,6 +1,9 @@ #include "input/emulated/EmulatedController.h" #include "input/api/Controller.h" +#ifdef HAVE_SWITCH2KIT +#include "input/api/SDL/SDLController.h" +#endif #ifdef SUPPORTS_WIIMOTE #include "input/api/Wiimote/NativeWiimoteController.h" @@ -110,13 +113,13 @@ bool EmulatedController::has_motion() const return std::any_of(m_controllers.cbegin(), m_controllers.cend(), [](const auto& c) {return c->use_motion(); }); } -MotionSample EmulatedController::get_motion_data() const +std::optional EmulatedController::get_motion_data() const { std::shared_lock lock(m_mutex); for (const auto& controller : m_controllers) { - if (controller->use_motion()) - return controller->get_motion_sample(); + if (auto sample = controller->get_available_motion_sample()) + return sample; } return {}; @@ -147,13 +150,13 @@ bool EmulatedController::has_second_motion() const return motion >= 2; } -MotionSample EmulatedController::get_second_motion_data() const +std::optional EmulatedController::get_second_motion_data() const { int motion = 0; std::shared_lock lock(m_mutex); for (const auto& controller : m_controllers) { - if (controller->use_motion()) + if (auto sample = controller->get_available_motion_sample()) { // if wiimote has nunchuck connected, we use its acceleration #ifdef SUPPORTS_WIIMOTE @@ -169,7 +172,7 @@ MotionSample EmulatedController::get_second_motion_data() const motion++; if(motion == 2) { - return controller->get_motion_sample(); + return sample; } } } @@ -221,6 +224,10 @@ PositionVisibility EmulatedController::GetPositionVisibility() const void EmulatedController::add_controller(std::shared_ptr controller) { controller->connect(); +#ifdef HAVE_SWITCH2KIT + if (const auto native = std::dynamic_pointer_cast(controller)) + native->SetPlayerIndex(m_player_index); +#endif #ifdef SUPPORTS_WIIMOTE if (const auto wiimote = std::dynamic_pointer_cast(controller)) { diff --git a/src/input/emulated/EmulatedController.h b/src/input/emulated/EmulatedController.h index 41baffb9e7..cdf428b5e0 100644 --- a/src/input/emulated/EmulatedController.h +++ b/src/input/emulated/EmulatedController.h @@ -58,11 +58,11 @@ class EmulatedController bool is_battery_low() const; bool has_motion() const; - MotionSample get_motion_data() const; + std::optional get_motion_data() const; // some controllers (nunchuck) provide extra motion data bool has_second_motion() const; - MotionSample get_second_motion_data() const; + std::optional get_second_motion_data() const; bool has_position() const; glm::vec2 get_position() const; diff --git a/src/input/emulated/ProController.cpp b/src/input/emulated/ProController.cpp index bb584c1e3f..f9eb390494 100644 --- a/src/input/emulated/ProController.cpp +++ b/src/input/emulated/ProController.cpp @@ -1,4 +1,7 @@ #include "input/emulated/ProController.h" +#ifdef HAVE_SWITCH2KIT +#include "input/api/SDL/Switch2KitMapping.h" +#endif #include "input/api/Controller.h" #ifdef HAS_SDL @@ -140,6 +143,14 @@ bool ProController::set_default_mapping(const std::shared_ptr& c #ifdef HAS_SDL case InputAPI::SDLController: { const auto sdl_controller = std::static_pointer_cast(controller); +#ifdef HAVE_SWITCH2KIT + if (const auto model = sdl_controller->GetSwitch2Model(); + model && CemuSwitch2Kit::IsSupportedModel(*model)) + { + mapping = CemuSwitch2Kit::GamepadMapping(*model); + } + else +#endif if (sdl_controller->get_guid() == SDLController::kLeftJoyCon) { mapping = diff --git a/src/input/emulated/VPADController.cpp b/src/input/emulated/VPADController.cpp index a791c5fe57..06519dfc72 100644 --- a/src/input/emulated/VPADController.cpp +++ b/src/input/emulated/VPADController.cpp @@ -1,4 +1,7 @@ #include "input/emulated/VPADController.h" +#ifdef HAVE_SWITCH2KIT +#include "input/api/SDL/Switch2KitMapping.h" +#endif #include "input/api/Controller.h" #ifdef HAS_SDL #include "input/api/SDL/SDLController.h" @@ -238,9 +241,9 @@ void VPADController::update_touch(VPADStatus_t& status) void VPADController::update_motion(VPADStatus_t& status) { - if (has_motion()) + if (auto available = get_motion_data()) { - auto motionSample = get_motion_data(); + auto motionSample = *available; glm::vec3 acc; motionSample.getVPADAccelerometer(&acc[0]); @@ -516,6 +519,14 @@ bool VPADController::set_default_mapping(const std::shared_ptr& #ifdef HAS_SDL case InputAPI::SDLController: { const auto sdl_controller = std::static_pointer_cast(controller); +#ifdef HAVE_SWITCH2KIT + if (const auto model = sdl_controller->GetSwitch2Model(); + model && CemuSwitch2Kit::IsSupportedModel(*model)) + { + mapping = CemuSwitch2Kit::GamepadMapping(*model); + } + else +#endif if (sdl_controller->get_guid() == SDLController::kLeftJoyCon) { mapping = diff --git a/src/input/emulated/WPADController.cpp b/src/input/emulated/WPADController.cpp index 2eae0f8611..4f88921ef4 100644 --- a/src/input/emulated/WPADController.cpp +++ b/src/input/emulated/WPADController.cpp @@ -256,9 +256,9 @@ void WPADController::KPADRead(KPADStatus_t& status, const BtnRepeat& repeat) status.mpls.dir.Z.z = 1; } - if (has_motion()) + if (auto available = get_motion_data()) { - auto motion_sample = get_motion_data(); + auto motion_sample = *available; glm::vec3 acc; motion_sample.getAccelerometer(&acc[0]); @@ -337,9 +337,9 @@ void WPADController::KPADRead(KPADStatus_t& status, const BtnRepeat& repeat) status.ex_status.fs.stick.x = axis.x; status.ex_status.fs.stick.y = axis.y; - if(has_second_motion()) + if (auto available = get_second_motion_data()) { - auto motion_sample = get_second_motion_data(); + auto motion_sample = *available; glm::vec3 acc; motion_sample.getAccelerometer(&acc[0]); diff --git a/src/resource/MacOSXBundleInfo.plist.in b/src/resource/MacOSXBundleInfo.plist.in index ccd1c922d1..5426ebf3ff 100644 --- a/src/resource/MacOSXBundleInfo.plist.in +++ b/src/resource/MacOSXBundleInfo.plist.in @@ -65,5 +65,6 @@ Viewer + ${SWITCH2KIT_BLUETOOTH_USAGE} diff --git a/tests/switch2kit/PolicyTests.cpp b/tests/switch2kit/PolicyTests.cpp new file mode 100644 index 0000000000..3439efdb2a --- /dev/null +++ b/tests/switch2kit/PolicyTests.cpp @@ -0,0 +1,163 @@ +#include +#include +#include +#include +#include +#include +#include "ControllerEnums.h" +#include "input/api/SDL/Switch2KitMapping.h" +#include "input/api/SDL/Switch2KitIdentity.h" +#include "input/api/SDL/Switch2KitSession.h" +#include "wxgui/input/Switch2KitSetupTransaction.h" + +// Bluetooth is intentionally absent here. These execute the production policies +// with the actual Cemu/SDL/SDK enum values and controlled host/storage boundaries. +struct Host +{ + int result = 0, discoveries = 0, pumps = 0, stops = 0, shutdowns = 0; + int discover() { ++discoveries; return result; } + int pump() { ++pumps; return 0; } + int stop() { ++stops; return 0; } + void shutdown() { ++shutdowns; } +}; + +void SessionTests() +{ + Switch2KitSession session; + auto& host = session.GetHost(); + session.Pump(); + assert(!session.IsEnabled() && host.pumps == 0); + host.result = -1; + assert(session.Discover() == -1); + session.Pump(); + assert(!session.IsEnabled() && host.pumps == 0); + host.result = 0; + assert(session.Discover() == 0); + session.Pump(); + assert(session.IsEnabled() && host.pumps == 1); + // A failed additional scan must not tear down an already connected session. + host.result = -2; + assert(session.Discover() == -2 && session.IsEnabled()); + assert(session.Stop() == 0); + for (int i = 0; i < 1000; ++i) session.Pump(); + assert(!session.IsEnabled() && host.pumps == 1 && host.stops == 1); + assert(session.Discover() == -2 && !session.IsEnabled()); + host.result = 0; + assert(session.Discover() == 0); + session.Pump(); + assert(host.pumps == 2); + session.Shutdown(); + session.Pump(); + assert(!session.IsEnabled() && host.pumps == 2 && host.shutdowns == 1); + std::cout << "PASS startup consent, failed-start retry, disconnect fence and restart\n"; +} + +template +void MappingTests() +{ + using namespace CemuSwitch2Kit; + for (unsigned model : {S2K_PRO, S2K_GAMECUBE, S2K_JOYCON_LEFT, S2K_JOYCON_RIGHT}) + { + auto entries = GamepadMapping(model); + std::map mapping(entries.begin(), entries.end()); + assert(mapping.size() == entries.size()); + for (auto [button, input] : entries) + { + assert(button > Pad::kButtonId_None && button < Pad::kButtonId_Max); + assert(input < kButtonMAX); + } + if (model != S2K_JOYCON_RIGHT) + { + assert(mapping.at(Pad::kButtonId_StickL_Up) == kAxisYN); + assert(mapping.at(Pad::kButtonId_StickL_Down) == kAxisYP); + assert(mapping.at(Pad::kButtonId_StickL_Left) == kAxisXN); + assert(mapping.at(Pad::kButtonId_ZL) == kTriggerXP); + assert(mapping.at(Pad::kButtonId_Minus) == (model == S2K_GAMECUBE ? SDL_GAMEPAD_BUTTON_MISC2 : SDL_GAMEPAD_BUTTON_BACK)); + } + if (model != S2K_JOYCON_LEFT) + { + assert(mapping.at(Pad::kButtonId_A) == (model == S2K_GAMECUBE ? SDL_GAMEPAD_BUTTON_SOUTH : SDL_GAMEPAD_BUTTON_EAST)); + assert(mapping.at(Pad::kButtonId_B) == (model == S2K_GAMECUBE ? SDL_GAMEPAD_BUTTON_WEST : SDL_GAMEPAD_BUTTON_SOUTH)); + assert(mapping.at(Pad::kButtonId_X) == (model == S2K_GAMECUBE ? SDL_GAMEPAD_BUTTON_EAST : SDL_GAMEPAD_BUTTON_NORTH)); + assert(mapping.at(Pad::kButtonId_Y) == (model == S2K_GAMECUBE ? SDL_GAMEPAD_BUTTON_NORTH : SDL_GAMEPAD_BUTTON_WEST)); + assert(mapping.at(Pad::kButtonId_StickR_Up) == kRotationYN); + assert(mapping.at(Pad::kButtonId_StickR_Right) == kRotationXP); + assert(mapping.at(Pad::kButtonId_ZR) == kTriggerYP); + assert(mapping.at(Pad::kButtonId_R) == SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER); + } + if constexpr (requires { Pad::kButtonId_StickL; Pad::kButtonId_StickR; }) + { + assert(mapping.contains(Pad::kButtonId_StickL) == (model != S2K_GAMECUBE && model != S2K_JOYCON_RIGHT)); + assert(mapping.contains(Pad::kButtonId_StickR) == (model != S2K_GAMECUBE && model != S2K_JOYCON_LEFT)); + } + } + auto left = GamepadMapping(S2K_JOYCON_LEFT); + auto right = GamepadMapping(S2K_JOYCON_RIGHT); + std::map combined(left.begin(), left.end()); + combined.insert(right.begin(), right.end()); + assert(combined.size() == left.size() + right.size()); + auto pro = GamepadMapping(S2K_PRO); + assert((combined == std::map(pro.begin(), pro.end()))); + assert(!IsSupportedModel(0) && !IsSupportedModel(0x2009)); +} + +void TransactionTests() +{ + for (int failure = 0; failure != 6; ++failure) + { + std::string order; + int slot = 7; + bool backedUp = false; + bool result = false; + try + { + result = CemuSwitch2Kit::CommitSetup( + [&] { + order += 'B'; + assert(slot == 7); + if (failure == 1) return false; + if (failure == 2) throw std::runtime_error("backup"); + backedUp = true; + return true; + }, + [&] { + order += 'A'; + assert(backedUp); + slot = 8; + if (failure == 3) throw std::runtime_error("apply"); + }, + [&] { + order += 'S'; + assert(slot == 8); + if (failure == 4) return false; + if (failure == 5) throw std::runtime_error("save"); + return true; + }, + [&] { order += 'R'; slot = 7; }); + } + catch (const std::runtime_error&) { assert(failure == 2); } + if (failure == 0) + assert(result && slot == 8 && order == "BAS"); + else + { + assert(!result && slot == 7); + assert(order == (failure < 3 ? "B" : failure == 3 ? "BAR" : "BASR")); + } + } + std::cout << "PASS backup-before-replace, backup refusal, save failure and exception rollback\n"; +} + +int main() +{ + using CemuSwitch2Kit::ValidPhysicalKey; + assert(ValidPhysicalKey("s2k:0123456789abcdef0123456789abcdef")); + for (const auto* invalid : {"", "s2k:", "s2k:0123456789abcdef0123456789abcdeg", "s2k:0123456789abcdef0123456789abcdeF", "0_0123456789abcdef0123456789abcdef", "s2k:0123456789abcdef0123456789abcdef0"}) + assert(!ValidPhysicalKey(invalid)); + std::cout << "PASS persistent identity validation without ordinal fallback\n"; + SessionTests(); + MappingTests(); + MappingTests(); + MappingTests(); + std::cout << "PASS GameCube/Pro labels, trigger/stick axes and complementary Joy-Con mappings for all three pad types\n"; + TransactionTests(); +} diff --git a/tests/switch2kit/run.py b/tests/switch2kit/run.py new file mode 100644 index 0000000000..ccaaa0f132 --- /dev/null +++ b/tests/switch2kit/run.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run controller policy regressions. No Bluetooth, GUI or emulation is faked as hardware. + +The small test Pad types contain verbatim enum blocks extracted from the actual +Cemu headers, not duplicated numbers. Complete macOS builds compile the mappings +against the real classes and the production wxWidgets integration. +""" +import argparse +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] + +def run(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--sdk', type=Path, default=ROOT / 'dependencies/Switch2Kit') + parser.add_argument('--sdl', type=Path) + parser.add_argument('--sanitize', action='store_true') + args = parser.parse_args() + compiler = os.environ.get('CXX') or shutil.which('clang++') or shutil.which('g++') + if not compiler: + raise SystemExit('A C++20 compiler is required') + sdk = args.sdk.resolve() + if not (sdk / 'Sources/Switch2KitCABI/include/Switch2KitC.h').is_file(): + raise SystemExit('Initialize the Switch2Kit submodule first') + if args.sdl: + sdl = args.sdl.resolve() / 'include' + else: + sdl = ROOT / 'build-switch2kit/vcpkg_installed' + headers = list(sdl.glob('*/include/SDL3/SDL_gamepad.h')) + if len(headers) != 1: + raise SystemExit('Pass --sdl /path/to/SDL-source or build Cemu first') + sdl = headers[0].parents[1] + assert (sdl / 'SDL3/SDL_gamepad.h').is_file() + with tempfile.TemporaryDirectory(prefix='cemu-s2k-tests-') as directory: + work = Path(directory) + enums = [] + for name in ('VPADController', 'ProController', 'ClassicController'): + source = (ROOT / f'src/input/emulated/{name}.h').read_text() + match = re.search(r'enum ButtonId\s*\{.*?\};', source, re.S) + assert match, f'Missing production enum: {name}' + enums.append(f'struct {name} {{ {match[0]} }};') + (work / 'ControllerEnums.h').write_text('\n'.join(enums)) + binary = work / 'policies' + command = [compiler, '-std=c++20', '-Wall', '-Wextra', '-Werror', '-UNDEBUG', + '-I' + str(work), '-I' + str(ROOT / 'src'), '-I' + str(ROOT / 'src/gui'), + '-I' + str(sdl), '-I' + str(sdk / 'Sources/Switch2KitCABI/include'), + str(ROOT / 'tests/switch2kit/PolicyTests.cpp'), '-o', str(binary)] + if args.sanitize: + command += ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] + subprocess.run(command, check=True, timeout=120) + subprocess.run([str(binary)], check=True, timeout=30) + subprocess.run(['python3', str(ROOT / 'tests/switch2kit/test_wiring.py')], check=True) + +if __name__ == '__main__': + run() diff --git a/tests/switch2kit/test_wiring.py b/tests/switch2kit/test_wiring.py new file mode 100644 index 0000000000..ead4b8a76f --- /dev/null +++ b/tests/switch2kit/test_wiring.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Source-contract checks supplement, but never substitute for, native builds.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[2] +def text(path): return (ROOT / path).read_text() + +class Wiring(unittest.TestCase): + def test_opt_in_bundle_and_target(self): + cmake = text('CMakeLists.txt') + self.assertIn('option(ENABLE_SWITCH2KIT "Use in-process Switch2Kit controllers on macOS 15+" OFF)', cmake) + self.assertIn('NOT APPLE OR NOT ENABLE_SDL OR NOT MACOS_BUNDLE', cmake) + self.assertIn('CMAKE_OSX_DEPLOYMENT_TARGET VERSION_LESS 15.0', cmake) + self.assertIn('${CMAKE_CURRENT_SOURCE_DIR}/dependencies/Switch2Kit', cmake) + self.assertIn('SWITCH2KIT_BLUETOOTH_USAGE', text('src/resource/MacOSXBundleInfo.plist.in')) + def test_real_input_loop_and_consent(self): + provider = text('src/input/api/SDL/SDLControllerProvider.cpp') + self.assertIn('NativeSession().Discover()', provider) + self.assertIn('NativeSession().Pump()', provider) + self.assertLess(provider.index('NativeSession().Shutdown()'), provider.index('SDL_QuitSubSystem')) + app = text('src/gui/wxgui/CemuApp.cpp') + self.assertIn('SDLControllerProvider::PumpSDLEvents()', app) + self.assertNotIn('FindSwitch2Controllers', app) + def test_enumeration_does_not_cancel_rumble(self): + provider = text('src/input/api/SDL/SDLControllerProvider.cpp') + self.assertIn('SDL_GetGamepadProductForID', provider) + ui = text('src/gui/wxgui/input/InputSettings2.cpp').split('void InputSettings2::RefreshSwitch2Controllers()')[1] + self.assertNotIn('->connect()', ui) + self.assertNotIn('set_default_mapping', ui) + controller = text('src/input/api/SDL/SDLController.cpp') + self.assertIn('SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN', controller) + def test_backups_and_stale_dialog(self): + setup = text('src/gui/wxgui/input/Switch2KitSetup.cpp') + self.assertIn('ControllerConfigSnapshot(playerIndex) != snapshot', setup) + self.assertIn('wxNO_DEFAULT', setup) + self.assertIn('AssignedElsewhere', setup) + self.assertIn('CommitSetup(', setup) + self.assertIn('manager.save(playerIndex, name, false)', setup) + self.assertIn('manager.is_gameprofile_set(playerIndex)', setup) + manager = text('src/input/InputManager.cpp') + self.assertIn('FileStream::WriteFileAtomic', manager) + def test_saved_native_identity(self): + self.assertIn('starts_with("s2k:")', text('src/input/ControllerFactory.cpp')) + controller = text('src/input/api/SDL/SDLController.cpp') + self.assertIn('ValidPhysicalKey', controller) + self.assertIn('FindSwitch2Device(m_physical_key)', controller) + def test_motion_is_never_fabricated(self): + for path in ['src/input/emulated/VPADController.cpp', 'src/input/emulated/WPADController.cpp']: + self.assertIn('get_motion_data()', text(path)) + provider = text('src/input/api/SDL/SDLControllerProvider.cpp') + self.assertIn('availableSample()', provider) + self.assertIn('loadMotionProfile', provider) + def test_timer_ids_and_rumble_cancel(self): + ui = text('src/gui/wxgui/input/settings/DefaultControllerSettings.cpp') + self.assertIn('this, m_timer->GetId()', ui) + self.assertIn('native->TryRumble(m_settings.rumble)', ui) + self.assertIn('m_controller->stop_rumble()', ui) + self.assertIn('m_switch2DevicesChanged.exchange(false)', text('src/gui/wxgui/input/InputSettings2.cpp')) + +if __name__ == '__main__': unittest.main(verbosity=2) From 7537a82c3bd845c6564aa8dd26aa00eac69793fc Mon Sep 17 00:00:00 2001 From: Johnny D Date: Thu, 17 Sep 2026 17:40:19 -0400 Subject: [PATCH 2/7] CI: qualify native Switch2Kit Cemu applications on Apple silicon and Intel --- .github/workflows/native-switch2kit.yml | 138 ++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 .github/workflows/native-switch2kit.yml diff --git a/.github/workflows/native-switch2kit.yml b/.github/workflows/native-switch2kit.yml new file mode 100644 index 0000000000..484887f140 --- /dev/null +++ b/.github/workflows/native-switch2kit.yml @@ -0,0 +1,138 @@ +name: Native Switch2Kit +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: native-switch2kit-${{ github.ref }} + cancel-in-progress: true +jobs: + policies: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - run: git submodule update --init dependencies/Switch2Kit + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: libsdl-org/SDL + ref: f87239e71e42da91ca317a12eefb82cfbf3393eb + path: test-sdl + persist-credentials: false + - name: Execute mapping, identity, setup rollback and session regressions + run: python3 tests/switch2kit/run.py --sdl test-sdl --sanitize + macos: + if: github.repository == 'jmonster/Cemu' + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + arch: arm64 + - os: macos-15-intel + arch: x86_64 + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + env: + DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer + VCPKG_MAX_CONCURRENCY: 3 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + submodules: recursive + persist-credentials: false + - name: Install native tools + run: brew install cmake ninja nasm automake libtool molten-vk + - name: Build the complete application + run: | + set -o pipefail + test "$(uname -m)" = '${{ matrix.arch }}' + bash scripts/build-switch2kit.sh 2>&1 | tee native-build.log + - name: Check production policies on the native architecture + run: python3 tests/switch2kit/run.py --sanitize + - name: Inspect embedded SDK, architecture and application signature + run: | + codesign --verify --deep --strict bin/Cemu_release.app + python3 dependencies/Switch2Kit/Integrations/Emulators/verify-bundle.py \ + cemu . build-switch2kit --architecture '${{ matrix.arch }}' + - name: Launch, quit and relaunch the exact ZIP with build dependencies denied + run: | + xcrun swiftc -swift-version 6 -warnings-as-errors \ + dependencies/Switch2Kit/tests/emulator-launch/Observe.swift \ + -o "$RUNNER_TEMP/cemu-window-observer" + mkdir "$RUNNER_TEMP/cemu-app" + ditto -x -k build-switch2kit/integration-app.zip "$RUNNER_TEMP/cemu-app" + python3 dependencies/Switch2Kit/tests/emulator-launch/verify.py cemu \ + "$RUNNER_TEMP/cemu-app/Cemu_release.app" "$RUNNER_TEMP/cemu-window-observer" \ + "$PWD/launch-results" + - name: Check the disabled option retains the upstream deployment target + run: | + cmake -S . -B build-switch2kit -DENABLE_SWITCH2KIT=OFF \ + -DMACOS_BUNDLE=OFF -DCMAKE_OSX_DEPLOYMENT_TARGET=13.4 + python3 - <<'PY' + import json + from pathlib import Path + commands = json.loads(Path('build-switch2kit/compile_commands.json').read_text()) + assert not any('HAVE_SWITCH2KIT' in row['command'] for row in commands) + assert not any('Switch2KitSetup.cpp' in row['file'] for row in commands) + cache = Path('build-switch2kit/CMakeCache.txt').read_text() + assert 'CMAKE_OSX_DEPLOYMENT_TARGET:STRING=13.4' in cache + print('PASS disabled option: no native backend sources or defines; macOS 13.4 retained') + PY + - name: Qualified development application + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: Cemu-Switch2Kit-${{ matrix.arch }} + path: | + build-switch2kit/integration-app.zip + build-switch2kit/integration-inspection.json + launch-results/launch.json + if-no-files-found: error + retention-days: 14 + - name: Native diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: Cemu-Switch2Kit-${{ matrix.arch }}-diagnostics + path: | + native-build.log + build-switch2kit/integration-native-diagnostics.txt + build-switch2kit/CMakeCache.txt + build-switch2kit/CMakeFiles/CMakeConfigureLog.yaml + launch-results + sdk: + if: github.repository == 'jmonster/Cemu' + runs-on: macos-15 + timeout-minutes: 30 + env: + DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - run: git submodule update --init dependencies/Switch2Kit + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: libsdl-org/SDL + ref: f87239e71e42da91ca317a12eefb82cfbf3393eb + path: test-sdl + persist-credentials: false + - run: brew install cmake ninja boost + - name: Exercise the pinned SDK, motor packets, real SDL and Cemu motion consumer + run: | + set -o pipefail + swift test --package-path dependencies/Switch2Kit -Xswiftc -warnings-as-errors 2>&1 | tee sdk-tests.log + bash dependencies/Switch2Kit/tests/rumble/run.sh 2>&1 | tee motor-tests.log + bash dependencies/Switch2Kit/tests/session/run.sh 2>&1 | tee session-tests.log + S2K_SDL_SOURCE="$PWD/test-sdl" \ + bash dependencies/Switch2Kit/tests/sdl-inprocess/verify.sh 2>&1 | tee sdl-tests.log + S2K_SDL_SOURCE="$PWD/test-sdl" S2K_CEMU_SOURCE="$PWD" \ + bash dependencies/Switch2Kit/tests/emulator-host/verify.sh 2>&1 | tee host-tests.log + - name: Test diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: Cemu-Switch2Kit-sdk-diagnostics + path: '*-tests.log' From fbaa590a156e518c477ebb975a04563dfe9966a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:46:33 +0000 Subject: [PATCH 3/7] Restore empty controller slots exactly after failed setup and release discovery timers --- src/gui/wxgui/input/InputSettings2.cpp | 1 + src/gui/wxgui/input/Switch2KitSetup.cpp | 5 +--- .../wxgui/input/Switch2KitSetupTransaction.h | 13 +++++++++ tests/switch2kit/PolicyTests.cpp | 29 +++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/gui/wxgui/input/InputSettings2.cpp b/src/gui/wxgui/input/InputSettings2.cpp index abfaeaf7cb..d3c572a5c4 100644 --- a/src/gui/wxgui/input/InputSettings2.cpp +++ b/src/gui/wxgui/input/InputSettings2.cpp @@ -165,6 +165,7 @@ InputSettings2::~InputSettings2() m_timer->Stop(); #ifdef HAVE_SWITCH2KIT m_switch2Timer->Stop(); + delete m_switch2Timer; #endif InputManager::instance().save(); } diff --git a/src/gui/wxgui/input/Switch2KitSetup.cpp b/src/gui/wxgui/input/Switch2KitSetup.cpp index 7a52280716..b7a4eb37d4 100644 --- a/src/gui/wxgui/input/Switch2KitSetup.cpp +++ b/src/gui/wxgui/input/Switch2KitSetup.cpp @@ -134,10 +134,7 @@ bool ApplySwitch2KitSetup(wxWindow* parent, size_t playerIndex, const Controller [&] { return manager.save(playerIndex); }, [&] { native->set_settings(previousSettings); - if (before) - manager.set_controller(before); - else - manager.delete_controller(playerIndex); + CemuSwitch2Kit::RestoreSlot(manager, playerIndex, before); }); if (!committed) throw std::runtime_error("Could not save controller settings"); diff --git a/src/gui/wxgui/input/Switch2KitSetupTransaction.h b/src/gui/wxgui/input/Switch2KitSetupTransaction.h index d7f6bd5c92..c852cf0d46 100644 --- a/src/gui/wxgui/input/Switch2KitSetupTransaction.h +++ b/src/gui/wxgui/input/Switch2KitSetupTransaction.h @@ -1,7 +1,20 @@ #pragma once +#include + namespace CemuSwitch2Kit { +// InputManager normally inherits sources from the outgoing slot when the new +// slot is empty. Remove that outgoing slot first so rollback restores an empty +// prior configuration exactly, rather than inheriting the new controller. +template +void RestoreSlot(Manager& manager, std::size_t index, const Controller& previous) +{ + manager.delete_controller(index); + if (previous) + manager.set_controller(previous); +} + // Save the old profile before changing the active slot. A failed atomic save or // an exception after applying the new slot restores the in-memory configuration. // Callers validate identity/selection again after any modal confirmation. diff --git a/tests/switch2kit/PolicyTests.cpp b/tests/switch2kit/PolicyTests.cpp index 3439efdb2a..817a8b0aa5 100644 --- a/tests/switch2kit/PolicyTests.cpp +++ b/tests/switch2kit/PolicyTests.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -101,6 +102,33 @@ void MappingTests() assert(!IsSupportedModel(0) && !IsSupportedModel(0x2009)); } +void RestoreSlotTests() +{ + struct Pad { int source = 0; }; + struct Manager + { + std::shared_ptr slot; + void delete_controller(std::size_t) { slot.reset(); } + void set_controller(const std::shared_ptr& next) + { + // Match InputManager's source inheritance for an empty replacement. + if (slot && next->source == 0) next->source = slot->source; + slot = next; + } + }; + for (int source : {0, 7}) + { + Manager manager{std::make_shared(Pad{8})}; + auto before = std::make_shared(Pad{source}); + CemuSwitch2Kit::RestoreSlot(manager, 0, before); + assert(manager.slot == before && manager.slot->source == source); + } + Manager manager{std::make_shared(Pad{8})}; + CemuSwitch2Kit::RestoreSlot(manager, 0, std::shared_ptr{}); + assert(!manager.slot); + std::cout << "PASS exact rollback of populated, empty and disabled slots\n"; +} + void TransactionTests() { for (int failure = 0; failure != 6; ++failure) @@ -160,4 +188,5 @@ int main() MappingTests(); std::cout << "PASS GameCube/Pro labels, trigger/stick axes and complementary Joy-Con mappings for all three pad types\n"; TransactionTests(); + RestoreSlotTests(); } From f877c0c697e5b68b25454ceee32e903e7cd91916 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Thu, 17 Sep 2026 17:47:55 -0400 Subject: [PATCH 4/7] Test exact slot rollback and discovery timer ownership in production wiring --- tests/switch2kit/test_wiring.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/switch2kit/test_wiring.py b/tests/switch2kit/test_wiring.py index ead4b8a76f..504ad43644 100644 --- a/tests/switch2kit/test_wiring.py +++ b/tests/switch2kit/test_wiring.py @@ -36,6 +36,7 @@ def test_backups_and_stale_dialog(self): self.assertIn('wxNO_DEFAULT', setup) self.assertIn('AssignedElsewhere', setup) self.assertIn('CommitSetup(', setup) + self.assertIn('CemuSwitch2Kit::RestoreSlot(manager, playerIndex, before)', setup) self.assertIn('manager.save(playerIndex, name, false)', setup) self.assertIn('manager.is_gameprofile_set(playerIndex)', setup) manager = text('src/input/InputManager.cpp') @@ -56,6 +57,8 @@ def test_timer_ids_and_rumble_cancel(self): self.assertIn('this, m_timer->GetId()', ui) self.assertIn('native->TryRumble(m_settings.rumble)', ui) self.assertIn('m_controller->stop_rumble()', ui) - self.assertIn('m_switch2DevicesChanged.exchange(false)', text('src/gui/wxgui/input/InputSettings2.cpp')) + ui = text('src/gui/wxgui/input/InputSettings2.cpp') + self.assertIn('m_switch2DevicesChanged.exchange(false)', ui) + self.assertIn('delete m_switch2Timer;', ui) if __name__ == '__main__': unittest.main(verbosity=2) From 8359abeb0d1be8bc99196cda0a2219144ce3b164 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Thu, 17 Sep 2026 20:38:40 -0400 Subject: [PATCH 5/7] CI: compare disabled deployment target by value rather than cache type --- .github/workflows/native-switch2kit.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/native-switch2kit.yml b/.github/workflows/native-switch2kit.yml index 484887f140..c00447e411 100644 --- a/.github/workflows/native-switch2kit.yml +++ b/.github/workflows/native-switch2kit.yml @@ -78,7 +78,12 @@ jobs: assert not any('HAVE_SWITCH2KIT' in row['command'] for row in commands) assert not any('Switch2KitSetup.cpp' in row['file'] for row in commands) cache = Path('build-switch2kit/CMakeCache.txt').read_text() - assert 'CMAKE_OSX_DEPLOYMENT_TARGET:STRING=13.4' in cache + # Command-line cache entries may be STRING or UNINITIALIZED. Check + # the deployment value, not CMake's incidental cache type spelling. + values = {line.split(':', 1)[0]: line.split('=', 1)[1] + for line in cache.splitlines() + if not line.startswith(('#', '//')) and ':' in line and '=' in line} + assert values.get('CMAKE_OSX_DEPLOYMENT_TARGET') == '13.4', values.get('CMAKE_OSX_DEPLOYMENT_TARGET') print('PASS disabled option: no native backend sources or defines; macOS 13.4 retained') PY - name: Qualified development application From 2d845d64bb96ee0f0e06ed543d56e5415e61a939 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Thu, 17 Sep 2026 20:46:18 -0400 Subject: [PATCH 6/7] Keep rejected quick setup from interrupting an assigned controller Check duplicate assignments, game-profile ownership and unsupported emulated types before opening the candidate SDL handle. A rejected temporary handle must not stop another slot's rumble or change its sensor policy. Add an ordering regression alongside the existing mapping, rollback and lifecycle checks. --- src/gui/wxgui/input/Switch2KitSetup.cpp | 18 ++++++++++-------- tests/switch2kit/test_wiring.py | 6 ++++++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/gui/wxgui/input/Switch2KitSetup.cpp b/src/gui/wxgui/input/Switch2KitSetup.cpp index b7a4eb37d4..09c4b5ff53 100644 --- a/src/gui/wxgui/input/Switch2KitSetup.cpp +++ b/src/gui/wxgui/input/Switch2KitSetup.cpp @@ -34,14 +34,6 @@ bool ApplySwitch2KitSetup(wxWindow* parent, size_t playerIndex, const Controller const auto native = std::dynamic_pointer_cast(controller); if (playerIndex >= InputManager::kMaxController || !native || !native->IsSwitch2Controller()) return false; - if (!native->connect()) - { - wxMessageBox(_("Connect the controller with Find Switch 2 Controllers first."), _("Switch 2 Controllers"), wxOK | wxICON_INFORMATION, parent); - return false; - } - const auto model = native->GetSwitch2Model(); - if (!model || (*model != S2K_GAMECUBE && *model != S2K_PRO)) - return false; const auto before = manager.get_controller(playerIndex); if (manager.is_gameprofile_set(playerIndex)) { @@ -60,6 +52,16 @@ bool ApplySwitch2KitSetup(wxWindow* parent, size_t playerIndex, const Controller wxMessageBox(_("This controller is assigned to another slot. Remove it there first."), _("Switch 2 Controllers"), wxOK | wxICON_INFORMATION, parent); return false; } + // Do not open another handle for a controller that this action will reject. + // Closing that temporary handle can stop rumble on its already-assigned slot. + if (!native->connect()) + { + wxMessageBox(_("Connect the controller with Find Switch 2 Controllers first."), _("Switch 2 Controllers"), wxOK | wxICON_INFORMATION, parent); + return false; + } + const auto model = native->GetSwitch2Model(); + if (!model || (*model != S2K_GAMECUBE && *model != S2K_PRO)) + return false; const auto snapshot = manager.ControllerConfigSnapshot(playerIndex); if (before && !before->get_controllers().empty() && wxMessageBox( _("Replace this slot's controller assignments and button mappings? A backup profile will be saved first. Use Profile > Load to restore it."), diff --git a/tests/switch2kit/test_wiring.py b/tests/switch2kit/test_wiring.py index 504ad43644..3815968b30 100644 --- a/tests/switch2kit/test_wiring.py +++ b/tests/switch2kit/test_wiring.py @@ -41,6 +41,12 @@ def test_backups_and_stale_dialog(self): self.assertIn('manager.is_gameprofile_set(playerIndex)', setup) manager = text('src/input/InputManager.cpp') self.assertIn('FileStream::WriteFileAtomic', manager) + def test_rejected_setup_does_not_open_controller(self): + setup = text('src/gui/wxgui/input/Switch2KitSetup.cpp').split('bool ApplySwitch2KitSetup(')[1] + connect = setup.index('!native->connect()') + self.assertLess(setup.index('manager.is_gameprofile_set(playerIndex)'), connect) + self.assertLess(setup.index('type == EmulatedController::Wiimote'), connect) + self.assertLess(setup.index('AssignedElsewhere(playerIndex, native)'), connect) def test_saved_native_identity(self): self.assertIn('starts_with("s2k:")', text('src/input/ControllerFactory.cpp')) controller = text('src/input/api/SDL/SDLController.cpp') From 40e725e935a35feb3c12b2959c129cb1524a38a2 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Thu, 17 Sep 2026 20:59:11 -0400 Subject: [PATCH 7/7] Make controller-change notifications safe during settings-window teardown Retain a shared coalescing flag in the event subscription instead of binding the window pointer. Callbacks already in flight can finish after disconnect without touching destroyed widgets; only the main-thread timer refreshes the UI. Add executable notification lifetime and reopened-window isolation tests alongside the source wiring regression. --- src/gui/wxgui/input/InputSettings2.cpp | 14 ++++---- src/gui/wxgui/input/InputSettings2.h | 8 +++-- src/gui/wxgui/input/Switch2KitDeviceChanges.h | 19 +++++++++++ tests/switch2kit/PolicyTests.cpp | 32 +++++++++++++++++++ tests/switch2kit/run.py | 2 +- tests/switch2kit/test_wiring.py | 9 +++++- 6 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 src/gui/wxgui/input/Switch2KitDeviceChanges.h diff --git a/src/gui/wxgui/input/InputSettings2.cpp b/src/gui/wxgui/input/InputSettings2.cpp index d3c572a5c4..5acc2230cb 100644 --- a/src/gui/wxgui/input/InputSettings2.cpp +++ b/src/gui/wxgui/input/InputSettings2.cpp @@ -147,14 +147,20 @@ InputSettings2::InputSettings2(wxWindow* parent) m_switch2Timer = new wxTimer(this); Bind(wxEVT_TIMER, [this](wxTimerEvent&) { m_switch2Status->SetLabel(wxString::FromUTF8(SDLControllerProvider::Switch2ControllerStatus())); - if (m_switch2DevicesChanged.exchange(false)) + if (m_switch2DevicesChanged->Consume()) on_controller_changed(); }, m_switch2Timer->GetId()); m_switch2Timer->Start(500); RefreshSwitch2Controllers(); #endif +#ifdef HAVE_SWITCH2KIT + // Keep a callback already in flight away from the window's lifetime. + m_controller_changed = EventService::instance().connect( + &CemuSwitch2Kit::DeviceChanges::Notify, m_switch2DevicesChanged); +#else m_controller_changed = EventService::instance().connect(&InputSettings2::on_controller_changed, this); +#endif } InputSettings2::~InputSettings2() @@ -556,11 +562,7 @@ void InputSettings2::update_state() void InputSettings2::on_controller_changed() { #ifdef HAVE_SWITCH2KIT - if (!wxIsMainThread()) - { - m_switch2DevicesChanged = true; - return; - } + wxASSERT(wxIsMainThread()); RefreshSwitch2Controllers(); #endif for(auto i = 0 ; i < m_notebook->GetPageCount(); ++i) diff --git a/src/gui/wxgui/input/InputSettings2.h b/src/gui/wxgui/input/InputSettings2.h index 72d254faae..6911b1e4ca 100644 --- a/src/gui/wxgui/input/InputSettings2.h +++ b/src/gui/wxgui/input/InputSettings2.h @@ -3,7 +3,10 @@ #include #include #include -#include +#include +#ifdef HAVE_SWITCH2KIT +#include "wxgui/input/Switch2KitDeviceChanges.h" +#endif #include "input/api/InputAPI.h" @@ -27,7 +30,8 @@ class InputSettings2 : public wxDialog #ifdef HAVE_SWITCH2KIT wxTimer* m_switch2Timer; wxStaticText* m_switch2Status; - std::atomic m_switch2DevicesChanged{false}; + std::shared_ptr m_switch2DevicesChanged = + std::make_shared(); void RefreshSwitch2Controllers(); #endif diff --git a/src/gui/wxgui/input/Switch2KitDeviceChanges.h b/src/gui/wxgui/input/Switch2KitDeviceChanges.h new file mode 100644 index 0000000000..091a2635b4 --- /dev/null +++ b/src/gui/wxgui/input/Switch2KitDeviceChanges.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace CemuSwitch2Kit +{ +// The event subscription owns this flag, not the settings window. A callback +// already in flight may finish after disconnect() or window destruction. +// Only the window's main-thread timer consumes the flag and touches widgets. +class DeviceChanges +{ +public: + void Notify() { m_pending.store(true, std::memory_order_relaxed); } + bool Consume() { return m_pending.exchange(false, std::memory_order_relaxed); } + +private: + std::atomic m_pending{false}; +}; +} // namespace CemuSwitch2Kit diff --git a/tests/switch2kit/PolicyTests.cpp b/tests/switch2kit/PolicyTests.cpp index 817a8b0aa5..aa8811d2a8 100644 --- a/tests/switch2kit/PolicyTests.cpp +++ b/tests/switch2kit/PolicyTests.cpp @@ -5,11 +5,14 @@ #include #include #include +#include +#include #include "ControllerEnums.h" #include "input/api/SDL/Switch2KitMapping.h" #include "input/api/SDL/Switch2KitIdentity.h" #include "input/api/SDL/Switch2KitSession.h" #include "wxgui/input/Switch2KitSetupTransaction.h" +#include "wxgui/input/Switch2KitDeviceChanges.h" // Bluetooth is intentionally absent here. These execute the production policies // with the actual Cemu/SDL/SDK enum values and controlled host/storage boundaries. @@ -175,6 +178,34 @@ void TransactionTests() std::cout << "PASS backup-before-replace, backup refusal, save failure and exception rollback\n"; } +void DeviceChangeTests() +{ + using CemuSwitch2Kit::DeviceChanges; + auto windowState = std::make_shared(); + std::weak_ptr retired = windowState; + // Model the shared ownership retained by EventService's bound slot. No GUI + // object is captured, and the actual production flag implements coalescing. + std::function callback = [state = windowState] { state->Notify(); }; + assert(!windowState->Consume()); + callback(); + callback(); + assert(windowState->Consume() && !windowState->Consume()); + std::thread producer([callback] { for (int i = 0; i < 10000; ++i) callback(); }); + producer.join(); + assert(windowState->Consume() && !windowState->Consume()); + // A retained in-flight callback can finish after the old window releases + // its state without accessing that window or notifying a new instance. + windowState.reset(); + assert(!retired.expired()); + auto reopened = std::make_shared(); + std::thread finishing([callback] { callback(); }); + finishing.join(); + assert(!reopened->Consume()); + callback = {}; + assert(retired.expired()); + std::cout << "PASS coalesced notifications, in-flight window teardown and reopened-window isolation\n"; +} + int main() { using CemuSwitch2Kit::ValidPhysicalKey; @@ -183,6 +214,7 @@ int main() assert(!ValidPhysicalKey(invalid)); std::cout << "PASS persistent identity validation without ordinal fallback\n"; SessionTests(); + DeviceChangeTests(); MappingTests(); MappingTests(); MappingTests(); diff --git a/tests/switch2kit/run.py b/tests/switch2kit/run.py index ccaaa0f132..565d019d1c 100644 --- a/tests/switch2kit/run.py +++ b/tests/switch2kit/run.py @@ -46,7 +46,7 @@ def run(): enums.append(f'struct {name} {{ {match[0]} }};') (work / 'ControllerEnums.h').write_text('\n'.join(enums)) binary = work / 'policies' - command = [compiler, '-std=c++20', '-Wall', '-Wextra', '-Werror', '-UNDEBUG', + command = [compiler, '-std=c++20', '-pthread', '-Wall', '-Wextra', '-Werror', '-UNDEBUG', '-I' + str(work), '-I' + str(ROOT / 'src'), '-I' + str(ROOT / 'src/gui'), '-I' + str(sdl), '-I' + str(sdk / 'Sources/Switch2KitCABI/include'), str(ROOT / 'tests/switch2kit/PolicyTests.cpp'), '-o', str(binary)] diff --git a/tests/switch2kit/test_wiring.py b/tests/switch2kit/test_wiring.py index 3815968b30..d4e01633f8 100644 --- a/tests/switch2kit/test_wiring.py +++ b/tests/switch2kit/test_wiring.py @@ -58,13 +58,20 @@ def test_motion_is_never_fabricated(self): provider = text('src/input/api/SDL/SDLControllerProvider.cpp') self.assertIn('availableSample()', provider) self.assertIn('loadMotionProfile', provider) + def test_notification_callback_does_not_own_window(self): + ui = text('src/gui/wxgui/input/InputSettings2.cpp') + self.assertIn('&CemuSwitch2Kit::DeviceChanges::Notify, m_switch2DevicesChanged', ui) + self.assertIn('std::shared_ptr', text('src/gui/wxgui/input/InputSettings2.h')) + changed = ui.split('void InputSettings2::on_controller_changed()')[1].split('\n}')[0] + self.assertNotIn('m_switch2DevicesChanged = true', changed) + self.assertIn('wxASSERT(wxIsMainThread())', changed) def test_timer_ids_and_rumble_cancel(self): ui = text('src/gui/wxgui/input/settings/DefaultControllerSettings.cpp') self.assertIn('this, m_timer->GetId()', ui) self.assertIn('native->TryRumble(m_settings.rumble)', ui) self.assertIn('m_controller->stop_rumble()', ui) ui = text('src/gui/wxgui/input/InputSettings2.cpp') - self.assertIn('m_switch2DevicesChanged.exchange(false)', ui) + self.assertIn('m_switch2DevicesChanged->Consume()', ui) self.assertIn('delete m_switch2Timer;', ui) if __name__ == '__main__': unittest.main(verbosity=2)