Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/native-switch2kit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jobs:
Source/Core/DolphinQt/Config/Mapping/MappingWindow.cpp \
Source/Core/DolphinQt/Config/Mapping/Switch2KitMapping.cpp
- run: python3 Tools/test_switch2kit.py
- run: python3 Tools/test_switch2kit_autoconnect.py
- run: python3 Tools/test_switch2kit_mapping.py --sanitize
- name: Execute host lifecycle and identity regressions with sanitizers
run: python3 Tools/test_switch2kit_host.py --sanitize
Expand Down Expand Up @@ -76,8 +77,9 @@ jobs:
run: brew install cmake ninja nasm automake libtool qt@6
- name: Check source pin and integration
run: |
test "$(git -C Externals/Switch2Kit rev-parse HEAD)" = 8088ce3ef6845fe90d8ff7a579e6498668bd65de
test "$(git -C Externals/Switch2Kit rev-parse HEAD)" = a9d43b1f63d94f8844755a510bf6ecc876bc8c51
python3 Tools/test_switch2kit.py
python3 Tools/test_switch2kit_autoconnect.py
python3 Tools/test_switch2kit_host.py --sanitize
python3 Tools/test_switch2kit_mapping.py --sanitize
- name: Build the complete application
Expand Down
53 changes: 48 additions & 5 deletions Docs/Switch2Kit.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,42 @@ clicks. The Pro preset matches printed Nintendo A/B/X/Y labels; + is Start, R is
GameCube Z, and ZL/ZR provide on/off GameCube L/R. Pro triggers cannot produce
GameCube-style variable squeeze.

Open the game normally. Port bindings use Dolphin's existing settings. Use Find
again after restarting Dolphin; merely launching the app does not scan or request
Bluetooth permission. This is not wired GameCube USB-adapter mode.
Open the game normally. Port bindings use Dolphin's existing settings.
This is not wired GameCube USB-adapter mode.

### Automatic connection and recovery

Enable **Automatically connect Switch 2 controllers** in Controller Settings.
This starts listening now and saves your choice for future Dolphin launches. After
initial pairing, turn the controller on again after a long pause: Dolphin can
rediscover it without reopening settings or pressing Find. Discovery runs on the
SDK's Bluetooth queue, independently of emulation pause and the settings window.
There is no repeating Find timer or 60-second limit in automatic mode.

The option is off by default. With it off, Find still searches for 60 seconds,
and merely launching Dolphin does not start Bluetooth or request permission.
With it on, Dolphin starts once on the main run loop after SDL initialization.
The status distinguishes continuous listening from a finite manual search.

**Disconnect Switch 2 Controllers** stops input and discovery for the current
session, even with this option checked. Polling, returning to the app, resuming a
game or reopening settings cannot undo that explicit stop. Use Find or re-enable
the option to resume; the saved option still applies on the next app launch.
Unchecking the option stops automatic discovery without disconnecting ready
controllers. A connection handshake already admitted may finish.

Automatic discovery connects available **supported** controllers, not arbitrary
Bluetooth devices and not only a saved allowlist. Close competing controller apps.
It does not overwrite custom mappings or assign a new controller to a game port.
The transport retains its capacity limits, serialized handshakes, duplicate-filtered
scans and bounded retry behavior; continuous listening still uses Bluetooth radio
resources. It cannot wake a powered-off controller or bypass initial pairing or
Bluetooth permission.

The choice is stored as `[Settings] AutoConnect` in `Switch2Kit.ini`, alongside
but separate from physical identities. Failed reads/saves do not overwrite existing
settings or change the runtime choice. A saved choice whose start failed remains
visible; use Find to retry after resolving the reported problem.

The controller's physical identity is assigned a persistent Dolphin device number
in `Switch2Kit.ini` in Dolphin's user configuration directory. Reconnecting two
Expand All @@ -49,7 +82,7 @@ selected device. No controller identifiers are written to diagnostic messages.
The backend is OFF by default. Disabled builds do not require Swift and retain
Dolphin's existing macOS deployment target and other platforms. Enabled builds
require macOS 15+, Xcode 26+ with Swift 6.2+, and Dolphin's normal build dependencies.
The submodule pins Switch2Kit to `8088ce3ef6845fe90d8ff7a579e6498668bd65de`.
The submodule pins Switch2Kit to `a9d43b1f63d94f8844755a510bf6ecc876bc8c51`.

```sh
git submodule update --init --recursive
Expand Down Expand Up @@ -90,6 +123,10 @@ main run loop. The linked Swift library remains resident for process lifetime.
`python3 Tools/test_switch2kit_mapping.py --sanitize` executes the production mapping
helper against test-only UI/configuration boundaries, including cancellation and
backup failures. These boundaries do not replace the code in application builds.
`python3 Tools/test_switch2kit_host.py --sanitize` exercises the production host
wrapper against test-only SDK/SDL/file boundaries, including automatic policy,
saved consent, failed configuration, stable IDs, start-once and explicit stop.
`python3 Tools/test_switch2kit_autoconnect.py` checks guarded UI/startup wiring.
The native workflow compiles the complete application and inspects the embedded
library and Bluetooth description. These checks do not establish physical
controller behavior, clean-Mac startup, latency, or gameplay acceptance.
Expand All @@ -99,7 +136,13 @@ and physical rumble still need acceptance on a real NSO GameCube controller:
pair/permission refusal and retry; every button and axis; light L/R travel without
a click and independent full clicks; disconnect while holding input; reconnect;
two identical controllers; app restart; normal quit; motor start/stop; and an actual game session.
Retain the distinction between build success and these hardware results.
Automatic recovery additionally needs a real Mac/controller test: play, pause,
leave the controller off longer than 60 seconds, resume, and power it on without
opening settings. Repeat several cycles, test Bluetooth off/on and two controllers
returning in reverse order, then verify Disconnect stays stopped and disabling
auto-connect preserves a live controller. These hardware checks have not been
performed for this change. Retain the distinction between build success and these
hardware results.

This contribution was prepared with AI assistance. The build glue, host wrapper,
UI integration, profile, documentation and checks require human review. The
Expand Down
2 changes: 1 addition & 1 deletion Externals/Switch2Kit
Submodule Switch2Kit updated 54 files
+192 −0 .github/workflows/linux-bluez.yml
+12 −5 Integrations/CMake/CMakeLists.txt
+14 −0 Integrations/CMake/Linux.cmake
+6 −4 Integrations/Emulators/README.md
+1 −1 Integrations/Emulators/SDLHost.hpp
+44 −9 Integrations/Emulators/cemu.patch
+23 −10 Integrations/Emulators/dolphin.patch
+6 −6 Integrations/Emulators/revisions.json
+14 −1 Package.swift
+29 −8 README.md
+14 −2 Sources/Switch2Kit/Bluetooth/ControllerSession.swift
+13 −4 Sources/Switch2Kit/Bluetooth/ControllerTransport.swift
+381 −0 Sources/Switch2Kit/Platform/BlueZ/Central.swift
+283 −0 Sources/Switch2Kit/Platform/BlueZ/DBus.swift
+54 −0 Sources/Switch2Kit/Platform/BlueZ/Identity.swift
+143 −0 Sources/Switch2Kit/Platform/BlueZ/RadioTypes.swift
+14 −5 Sources/Switch2Kit/Public/Switch2ControllerManager.swift
+19 −1 Sources/Switch2KitC/Context.swift
+9 −2 Sources/Switch2KitC/Exports.swift
+4 −1 Sources/Switch2KitC/ManagerSource.swift
+18 −4 Sources/Switch2KitCABI/include/Switch2KitC.h
+113 −0 Sources/Switch2KitDBus/DBus.c
+42 −0 Sources/Switch2KitDBus/include/Switch2KitDBus.h
+4 −0 Sources/Switch2KitDBus/include/module.modulemap
+83 −0 Tests/Switch2KitCTests/AutomaticDiscoveryTests.swift
+7 −0 Tests/Switch2KitCTests/TestSource.swift
+28 −0 Tests/Switch2KitTests/AutomaticDiscoveryPolicyTests.swift
+30 −0 Tests/Switch2KitTests/BlueZIdentityTests.swift
+44 −0 docs/switch2kit/automatic-discovery.md
+3 −3 docs/switch2kit/cpp.md
+47 −0 docs/switch2kit/linux.md
+6 −2 docs/switch2kit/sdl3.md
+25 −7 scripts/build-switch2kit-emulator.sh
+1 −1 tests/c-consumer/main.cpp
+8 −0 tests/fixture-identity/test_identity.py
+104 −0 tests/linux-bluez/consumer.cpp
+275 −0 tests/linux-bluez/fake_bluez.py
+72 −0 tests/linux-bluez/run.py
+14 −0 tests/linux-bluez/run.sh
+137 −0 tests/linux-bluez/test_build.py
+23 −0 tests/linux-runtime/CMakeLists.txt
+51 −0 tests/linux-runtime/inspect.py
+21 −0 tests/linux-runtime/main.cpp
+75 −0 tests/linux-runtime/test_build_helper.py
+115 −0 tests/linux-runtime/verify.py
+3 −3 tests/motion-profiles/run.sh
+10 −1 tests/sdl-inprocess/Fixture.swift
+90 −0 tests/sdl-inprocess/FixtureTests.swift
+21 −2 tests/support/compile-fixture.py
+1 −1 tests/support/kit-sources.sh
+1 −1 tests/support/prepare-sources.py
+4 −0 tools/motion-calibration/Capture.swift
+1 −1 tools/motion-calibration/README.md
+1 −1 tools/motion-calibration/build.sh
38 changes: 34 additions & 4 deletions Source/Core/DolphinQt/Config/ControllersPane.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
#include <QVBoxLayout>

#ifdef HAVE_SWITCH2KIT
#include <QCheckBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QSignalBlocker>
#include <QTimer>

#include "InputCommon/ControllerInterface/SDL/Switch2Kit.h"
Expand Down Expand Up @@ -42,6 +44,13 @@ void ControllersPane::CreateMainLayout()
actions->addWidget(find);
actions->addWidget(stop);
layout->addLayout(actions);
auto* const auto_connect = new QCheckBox(tr("Automatically connect Switch 2 controllers"), this);
auto_connect->setToolTip(
tr("Listen for available supported controllers while Dolphin is open, including after a "
"controller powers off. This uses Bluetooth and starts automatically on future launches. "
"Disconnect stops it until you use Find or restart Dolphin. Mappings are not changed."));
auto_connect->setChecked(ciface::SDL::GetSwitch2KitStatus().auto_connect);
layout->addWidget(auto_connect);
auto* const status = new QLabel(this);
status->setWordWrap(true);
layout->addWidget(status);
Expand All @@ -62,27 +71,48 @@ void ControllersPane::CreateMainLayout()
.arg(result));
});
connect(stop, &QPushButton::clicked, this, [] { ciface::SDL::StopSwitch2Controllers(); });
const auto update_status = [status, find, stop] {
connect(auto_connect, &QCheckBox::toggled, this, [this, auto_connect](bool enabled) {
const int result = ciface::SDL::SetSwitch2KitAutoConnect(enabled);
const QSignalBlocker blocker(auto_connect);
auto_connect->setChecked(ciface::SDL::GetSwitch2KitStatus().auto_connect);
if (result != 0)
QMessageBox::warning(this, tr("Switch 2 Controllers"),
tr("The automatic connection setting could not be saved or applied "
"(error %1). Check configuration access and Bluetooth permission. "
"The checkbox shows the saved choice; use Find to retry connection.")
.arg(result));
});
const auto update_status = [status, find, stop, auto_connect] {
const auto state = ciface::SDL::GetSwitch2KitStatus();
find->setEnabled(state.available && !state.stopping);
stop->setEnabled(state.running && !state.stopping);
auto_connect->setEnabled(state.available && !state.stopping);
const QSignalBlocker blocker(auto_connect);
auto_connect->setChecked(state.auto_connect);
if (!state.available)
status->setText(tr("SDL controller input is unavailable."));
else if (state.stopping)
status->setText(tr("Disconnecting controllers..."));
else if (!state.running)
status->setText(tr("Switch 2 controller support is stopped."));
else if (state.bluetooth == ciface::SDL::Switch2KitBluetooth::Unauthorized)
status->setText(tr("Allow Dolphin in System Settings > Privacy & Security > Bluetooth."));
else if (state.bluetooth == ciface::SDL::Switch2KitBluetooth::Off)
status->setText(tr("Turn on Bluetooth to connect controllers."));
else if (state.bluetooth == ciface::SDL::Switch2KitBluetooth::Unsupported)
status->setText(tr("Bluetooth is not supported on this Mac."));
else if (state.error != 0)
status->setText(tr("Controller input error %1. Try discovery again.").arg(state.error));
status->setText(tr("Controller input error %1. Use Find to retry.").arg(state.error));
else if (!state.running)
status->setText(tr("Switch 2 controller support is stopped. Use Find to resume."));
else if (state.scanning && state.auto_connect)
status->setText(tr("Listening for Switch 2 controllers. Turn it on to reconnect; "
"hold Sync for initial pairing. Connected: %1.")
.arg(state.controllers));
else if (state.scanning)
status->setText(
tr("Searching for 60 seconds: hold Sync. Connected: %1.").arg(state.controllers));
else if (state.auto_connect)
status->setText(tr("Automatic connection is enabled. Connected Switch 2 controllers: %1.")
.arg(state.controllers));
else
status->setText(tr("Connected Switch 2 controllers: %1. Use Find to add another.")
.arg(state.controllers));
Expand Down
11 changes: 11 additions & 0 deletions Source/Core/DolphinQt/Main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
#include <QPushButton>
#include <QWidget>

#ifdef HAVE_SWITCH2KIT
#include <QTimer>
#include "InputCommon/ControllerInterface/SDL/Switch2Kit.h"
#endif

#include "Common/Config/Config.h"
#include "Common/MsgHandler.h"
#include "Common/ScopeGuard.h"
Expand Down Expand Up @@ -278,6 +283,12 @@ int main(int argc, char* argv[])
MainWindow win{Core::System::GetInstance(), std::move(boot),
static_cast<const char*>(options.get("movie"))};

#ifdef HAVE_SWITCH2KIT
// MainWindow has initialized SDL; create Bluetooth support on the main run loop.
// The saved opt-in is consumed once and explicit Disconnect cancels this attempt.
QTimer::singleShot(0, &win, [] { ciface::SDL::StartSwitch2KitAutoConnect(); });
#endif

#if defined(USE_ANALYTICS) && USE_ANALYTICS
if (!Config::Get(Config::MAIN_ANALYTICS_PERMISSION_ASKED))
{
Expand Down
116 changes: 100 additions & 16 deletions Source/Core/InputCommon/ControllerInterface/SDL/Switch2Kit.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ std::unique_ptr<Switch2Kit::SDL3Adapter> s_adapter;
S2KContext* s_context = nullptr;
bool s_available = false;
bool s_started = false;
bool s_auto_connect = false;
bool s_auto_start_pending = false;
S2KResult s_action_error = S2K_OK;
S2KResult s_error = S2K_OK;

// SDL callbacks run with this lock held. Always take it before our own mutex.
Expand All @@ -46,33 +49,107 @@ std::string PhysicalKey(const S2KID& id)
}
return key;
}
// Both helpers run outside the SDL/adapter lock and share the identity-file lock.
bool LoadAutoConnect()
{
const std::lock_guard settings_lock(s_settings_mutex);
const auto path = File::GetUserPath(D_CONFIG_IDX) + "Switch2Kit.ini";
Common::IniFile ini;
if (!ini.Load(path))
return false;
bool enabled = false;
ini.GetOrCreateSection("Settings")->Get("AutoConnect", &enabled, false);
return enabled;
}

bool SaveAutoConnect(bool enabled)
{
const std::lock_guard settings_lock(s_settings_mutex);
const auto path = File::GetUserPath(D_CONFIG_IDX) + "Switch2Kit.ini";
Common::IniFile ini;
if (File::Exists(path) && !ini.Load(path))
return false;
ini.GetOrCreateSection("Settings")->Set("AutoConnect", enabled);
return ini.Save(path);
}

// Caller holds s_mutex and is on the main thread. Never called from input polling.
int StartSwitch2KitLocked()
{
if (!s_available)
return s_action_error = S2K_NOT_READY;
// Creation/permission presentation belongs to the main run loop, even when
// startup was explicitly authorized by a preference from a previous launch.
if (!s_context)
s_context = s2k_create(nullptr, &s_action_error);
if (!s_context)
return s_action_error;
s_action_error = s2k_set_automatic_discovery(s_context, s_auto_connect ? 1 : 0);
if (s_action_error != S2K_OK)
return s_action_error;
s_action_error = s2k_start(s_context);
if (s_action_error == S2K_OK)
{
s_started = true;
if (!s_auto_connect)
s_action_error = s2k_discover(s_context, 60.0);
}
return s_action_error;
}
} // namespace

void InitializeSwitch2Kit()
{
const bool auto_connect = LoadAutoConnect();
const std::lock_guard lock(s_mutex);
s_available = true;
s_auto_connect = auto_connect;
s_auto_start_pending = auto_connect;
s_action_error = S2K_OK;
s_error = S2K_OK;
}

int FindSwitch2Controllers()
{
const std::lock_guard lock(s_mutex);
if (!s_available)
return S2K_NOT_READY;
// Creation is deliberately deferred to the main-thread GUI action. Creating
// the SDL backend on a worker thread must never create a Bluetooth manager.
if (!s_context)
s_context = s2k_create(nullptr, &s_error);
if (!s_context)
return s_error;
s_error = s2k_start(s_context);
if (s_error == S2K_OK)
s_auto_start_pending = false;
return StartSwitch2KitLocked();
}

int StartSwitch2KitAutoConnect()
{
const std::lock_guard lock(s_mutex);
if (!s_auto_start_pending)
return S2K_OK;
s_auto_start_pending = false;
if (!s_auto_connect || s_started)
return S2K_OK;
// One startup attempt only. A failure remains visible and Find can retry it.
return StartSwitch2KitLocked();
}

int SetSwitch2KitAutoConnect(bool enabled)
{
{
s_started = true;
s_error = s2k_discover(s_context, 60.0);
const std::lock_guard lock(s_mutex);
if (!s_available)
return S2K_NOT_READY;
}
return s_error;
// Preserve every existing section and fail without changing the preference
// or radio policy if configuration cannot be read/saved.
if (!SaveAutoConnect(enabled))
{
const std::lock_guard lock(s_mutex);
return s_action_error = S2K_INTERNAL_ERROR;
}
const std::lock_guard lock(s_mutex);
s_auto_connect = enabled;
s_auto_start_pending = false;
if (enabled)
return StartSwitch2KitLocked();
// Disabling discovery retains ready controllers and their current mappings.
s_action_error = s_context && s_started ? s2k_set_automatic_discovery(s_context, 0) : S2K_OK;
return s_action_error;
}

void UpdateSwitch2Kit()
Expand All @@ -93,11 +170,14 @@ void StopSwitch2Controllers()
const JoystickLock joystick_lock;
const std::lock_guard lock(s_mutex);
// Keep subsequent input polls from recreating the adapter while stopped or
// while asynchronous Bluetooth teardown is still completing. Only Find starts it.
// while asynchronous Bluetooth teardown is still completing. Explicit Find or
// enabling Auto-connect can restart it; a deferred startup callback cannot.
s_auto_start_pending = false;
s_started = false;
s_adapter.reset();
s_error = S2K_OK;
if (s_context)
s_error = s2k_stop(s_context);
s_action_error = s2k_stop(s_context);
// Stop is asynchronous. Never block the main run loop waiting for teardown.
}

Expand All @@ -107,6 +187,9 @@ void ShutdownSwitch2Kit()
const std::lock_guard lock(s_mutex);
s_available = false;
s_started = false;
s_auto_start_pending = false;
s_auto_connect = false;
s_action_error = S2K_OK;
// Destroy SDL devices before their borrowed C context and before SDL_Quit.
s_adapter.reset();
if (s_context)
Expand All @@ -123,7 +206,8 @@ Switch2KitStatus GetSwitch2KitStatus()
const std::lock_guard lock(s_mutex);
Switch2KitStatus status;
status.available = s_available;
status.error = s_error;
status.auto_connect = s_auto_connect;
status.error = s_action_error != S2K_OK ? s_action_error : s_error;
if (!s_context)
return status;
S2KSnapshot snapshot{};
Expand Down
7 changes: 6 additions & 1 deletion Source/Core/InputCommon/ControllerInterface/SDL/Switch2Kit.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct Switch2KitStatus
bool running = false;
bool scanning = false;
bool stopping = false;
bool auto_connect = false;
std::uint32_t controllers = 0;
Switch2KitBluetooth bluetooth = Switch2KitBluetooth::Unknown;
int error = 0;
Expand All @@ -35,8 +36,12 @@ void InitializeSwitch2Kit();
void ShutdownSwitch2Kit();
void UpdateSwitch2Kit();

// Find must be called on the macOS main thread with its run loop running.
// These three actions must run on the macOS main thread with its run loop running.
int FindSwitch2Controllers();
// Consume the saved opt-in once after backend initialization; never a polling task.
int StartSwitch2KitAutoConnect();
// Save first, then apply; disabling retains ready connections. Returns an SDK error.
int SetSwitch2KitAutoConnect(bool enabled);
void StopSwitch2Controllers();
Switch2KitStatus GetSwitch2KitStatus();
std::optional<int> GetSwitch2KitPreferredId(std::uint32_t instance);
Expand Down
Loading
Loading