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