From 731bd5f97c806e15277c9cd120d55515cef57940 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Fri, 18 Sep 2026 13:00:41 -0400 Subject: [PATCH 1/4] Expose automatic discovery and policy-only start through SDLHost Add two forwarding methods for emulator-owned consent and lifecycle control. Keep the default policy, finite discover behavior and SDL/host lock order unchanged. --- Integrations/Emulators/SDLHost.hpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Integrations/Emulators/SDLHost.hpp b/Integrations/Emulators/SDLHost.hpp index bcda4e9..375ad1e 100644 --- a/Integrations/Emulators/SDLHost.hpp +++ b/Integrations/Emulators/SDLHost.hpp @@ -9,7 +9,7 @@ namespace Switch2Kit { /** Application-owned session for an SDL emulator. There is no process singleton. - * initialize/discover must first run on the macOS main thread. pump belongs to + * initialize/start/discover must first run on the macOS main thread. pump belongs to * the emulator's input loop. Other calls are serialized; stop before SDL quits. * The library remains loaded until process exit. This owner never starts a worker. */ @@ -28,6 +28,21 @@ class SDLHost final { context_ = s2k_create(nullptr, &error_); return error_; } + /** Select continuous or on-demand discovery without starting support. + * Hosts own consent and persistence. Disabling preserves ready controllers; + * a stopped host remains stopped. BUSY is returned while stop is finishing. */ + S2KResult setAutomaticDiscovery(bool enabled) { + if (const auto result = initialize(); result != S2K_OK) return result; + Guard lock(mutex_); + return error_ = s2k_set_automatic_discovery(context_, enabled ? 1u : 0u); + } + /** Start with the selected policy, without opening a finite discovery window. + * Call once after saved consent, or as an explicit user action, not per pump. */ + S2KResult start() { + if (const auto result = initialize(); result != S2K_OK) return result; + Guard lock(mutex_); + return error_ = s2k_start(context_); + } /** Explicit settings action: start support and open one bounded scan window. */ S2KResult discover(double seconds = 60.0) { if (const auto result = initialize(); result != S2K_OK) return result; From 722cb9b11549796fd33d5cace1b710702d19cd46 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Fri, 18 Sep 2026 14:16:46 -0400 Subject: [PATCH 2/4] test: qualify SDLHost automatic discovery and repair repository validation Exercise the real SDL host and C context with controlled radio fixture counters, queued input, opt-out, busy stop/restart and physical identity regressions. Keep the README below the existing layout bound by linking its complete library example, and exclude non-Swift fixture tools from the SwiftPM test target. --- Package.swift | 2 +- README.md | 58 ++++------------------- tests/emulator-host/main.cpp | 79 +++++++++++++++++++++++++++++++ tests/sdl-inprocess/Fixture.swift | 36 +++++++++++++- 4 files changed, 123 insertions(+), 52 deletions(-) diff --git a/Package.swift b/Package.swift index 7e54b96..27c109f 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ var targets: [Target] = [ // Compile the actual native SDL fixture during swift test, not just its separate CMake build. .testTarget(name: "Switch2KitSDLFixtureTests", dependencies: ["Switch2Kit", "Switch2KitC", "Switch2KitCABI"], path: "tests/sdl-inprocess", - exclude: ["CMakeLists.txt", "Clock.cpp", "Clock.hpp", "main.cpp", "motion.cpp", "verify.sh"], + exclude: ["CMakeLists.txt", "Clock.cpp", "Clock.hpp", "main.cpp", "motion.cpp", "verify.sh", "run.sh", "version_test.py"], sources: ["Fixture.swift", "FixtureTests.swift"], swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "Switch2Kit", dependencies: radioDependencies, path: "Sources/Switch2Kit", swiftSettings: [.swiftLanguageMode(.v6)]), diff --git a/README.md b/README.md index a2d206f..961e240 100644 --- a/README.md +++ b/README.md @@ -47,65 +47,27 @@ Dolphin and Cemu have their own upstream platform support; that does not mean th ## Developer integration -The sections below cover adding Switch2Kit to an app, building the optional dashboard, and working on the library. To use an existing controller-enabled emulator, start with [Start playing](#start-playing). +Requires Swift 6.2+. macOS hosts require macOS 15+ and Xcode 26+; Linux hosts use [BlueZ and the native Swift toolchain](docs/switch2kit/linux.md). To use an existing controller-enabled emulator, start with [Start playing](#start-playing). ### C/C++ integration -Use the [C ABI and CMake integration](docs/switch2kit/cpp.md) to embed the same controller engine in a native host. The host does not need to be written in Swift and does not require the dashboard. - -The maintained [Dolphin](https://github.com/jmonster/dolphin) and [Cemu](https://github.com/jmonster/Cemu) forks demonstrate complete app integrations. The separate [emulator integration guide](Integrations/Emulators/README.md) documents pinned source patches, builds, controller bindings, and motion-profile configuration for the SDK's reference integrations. Those patches and the maintained forks can differ in UI, supported platforms, and features; use each fork's README for its end-user setup. +Use the [C ABI and CMake integration](docs/switch2kit/cpp.md) to embed the same controller engine in a native host without the dashboard. The [emulator integration guide](Integrations/Emulators/README.md) documents pinned source patches, builds, bindings, and motion profiles. Those reference patches can differ from the maintained forks in UI, platforms, and features; use each fork's README for end-user setup. The SDL3 integrations run in the emulator's existing input backend. The emulator owns discovery, Bluetooth permissions, and controller lifecycle; no network bridge, second SDL instance, or system virtual controller is required. Disabled builds retain the emulator's upstream platforms and deployment targets. ### Library -Requires Swift 6.2+. macOS hosts require macOS 15+ and Xcode 26+; Linux hosts use [BlueZ and the native Swift toolchain](docs/switch2kit/linux.md). - -```swift -.package(url: "https://github.com/jmonster/Switch2Kit.git", branch: "main") -``` - -Add `.product(name: "Switch2Kit", package: "Switch2Kit")` to your target dependencies. For a local checkout, use `.package(path: "/path/to/Switch2Kit")`. - -```swift -import Switch2Kit - -@MainActor -final class ControllerInput { - let manager = Switch2ControllerManager() - private var observation: Switch2ControllerObservation? - - func start() throws { - observation = try manager.observe(on: .main) { event in - if case .input(let controller) = event { - print(controller.state.buttons) - } - } - manager.start() - try manager.discover(for: 60) - } - - func stop() async { - await manager.stop() - observation?.cancel() - observation = nil - } -} -``` +Add `.package(url: "https://github.com/jmonster/Switch2Kit.git", branch: "main")` and `.product(name: "Switch2Kit", package: "Switch2Kit")` to your SwiftPM host. For a local checkout, use `.package(path: "/path/to/Switch2Kit")`. The [library guide](docs/switch2kit/README.md#minimal-discovery-and-input) contains the complete retained-observation, discovery, input, and shutdown example. On macOS, the host provides `NSBluetoothAlwaysUsageDescription` and, when sandboxed, `com.apple.security.device.bluetooth`. In-process input needs neither Accessibility permission nor CoreHID. Hold the controller's Sync button while discovery is active. -Raw motion telemetry is available through the library. Calibrated SDL motion requires an explicitly selected [physical motion profile](docs/switch2kit/motion-profiles.md); no measured built-in profiles are supplied. NSO GameCube's soft/strong firmware feedback clips are separate from its on/off game rumble. See [controller and feature coverage](docs/switch2kit/coverage.md) for transport, output, and physical-qualification boundaries. +Raw motion telemetry is available through the library. Calibrated SDL motion requires an explicitly selected [physical motion profile](docs/switch2kit/motion-profiles.md); no measured built-in profiles are supplied. NSO GameCube's soft/strong firmware feedback clips are separate from its on/off game rumble. -[Library guide](docs/switch2kit/README.md) · [API](docs/switch2kit/api.md) · [SwiftUI](docs/switch2kit/swiftui.md) · [AppKit](docs/switch2kit/appkit.md) · [Bluetooth lifecycle](docs/switch2kit/bluetooth-lifecycle.md) · [Application actions](docs/switch2kit/actions.md) +[API](docs/switch2kit/api.md) · [SwiftUI](docs/switch2kit/swiftui.md) · [AppKit](docs/switch2kit/appkit.md) · [Bluetooth lifecycle](docs/switch2kit/bluetooth-lifecycle.md) · [Application actions](docs/switch2kit/actions.md) ## Dashboard -The optional macOS dashboard displays live input and manages multiple controllers, Joy-Con pairs, player indicators, rumble, and mappings. It is not needed by the Dolphin and Cemu forks above. - -### Build - -With the macOS/Xcode requirements above installed, clone this repository and run: +The optional macOS dashboard displays live input and manages multiple controllers, Joy-Con pairs, player indicators, rumble, and mappings. It is not needed by the Dolphin and Cemu forks above. With the macOS/Xcode requirements installed: ```sh git clone https://github.com/jmonster/Switch2Kit.git @@ -126,11 +88,9 @@ Package and regression checks (automated tests do not establish physical radio o swift build swift test bash tests/run.sh -# Linux radio integration tests: -bash tests/linux-bluez/run.sh -# macOS application and independent Swift consumer checks: -bash scripts/build-switch2kit-demo.sh -bash scripts/verify-switch2kit-consumer.sh +bash tests/linux-bluez/run.sh # Linux radio integration +bash scripts/build-switch2kit-demo.sh # macOS application +bash scripts/verify-switch2kit-consumer.sh # Independent Swift consumer ``` The [standalone demo](Examples/README.md) shows live controller input and local semantic navigation. Swift consumers use SwiftPM source integration; the independent consumer check needs no prebuilt framework. The [distribution note](docs/switch2kit/xcframework.md) covers migration from the standalone Swift XCFramework. The optional C/C++ integration retains its native library build and validation. diff --git a/tests/emulator-host/main.cpp b/tests/emulator-host/main.cpp index 1dfa9d6..d4a1bf3 100644 --- a/tests/emulator-host/main.cpp +++ b/tests/emulator-host/main.cpp @@ -6,11 +6,90 @@ extern "C" { S2KContext* test_input_create(); void test_input_report(S2KContext*, int32_t, uint32_t, const S2KState*); void test_input_retire(S2KContext*, int32_t); +uint32_t test_input_automatic_discovery(S2KContext*); +uint32_t test_input_discovery_configuration_count(S2KContext*); +uint32_t test_input_start_count(S2KContext*); +uint32_t test_input_discovery_count(S2KContext*); +uint32_t test_input_finish_stop(S2KContext*); +} +// Execute the production SDLHost, C context and SDL3 adapter. Only the radio +// source is controlled; this is not physical Bluetooth qualification. +static void automaticDiscoveryTests() { + auto* fixture = test_input_create(); assert(fixture); + Switch2Kit::SDLHost host(fixture); + assert(host.initialize() == S2K_OK && !host.snapshot().running); + assert(test_input_automatic_discovery(fixture) == 0); + assert(test_input_start_count(fixture) == 0); + assert(host.setAutomaticDiscovery(true) == S2K_OK); + assert(host.setAutomaticDiscovery(true) == S2K_OK); + assert(test_input_automatic_discovery(fixture) == 1); + assert(test_input_discovery_configuration_count(fixture) == 1); + assert(!host.snapshot().running && test_input_start_count(fixture) == 0); + assert(host.start() == S2K_OK && host.start() == S2K_OK); + assert(host.snapshot().running && test_input_start_count(fixture) == 1); + assert(test_input_discovery_count(fixture) == 0); + + S2KState state{}; state.sequence = 1; + test_input_report(fixture, 0, S2K_PRO, &state); + test_input_report(fixture, 1, S2K_PRO, &state); + assert(host.pump() == S2K_OK); + const std::string first = "s2k:00000000000000000000000000000001"; + const std::string second = "s2k:00000000000000000000000000000002"; + const auto original = host.instance(first), other = host.instance(second); + assert(original && other && original != other); + auto* pad = SDL_OpenGamepad(original); assert(pad); + state.sequence++; state.buttons = S2K_BUTTON_A; + test_input_report(fixture, 0, S2K_PRO, &state); + assert(host.pump() == S2K_OK && SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_EAST)); + state.sequence++; state.buttons = 0; + test_input_report(fixture, 0, S2K_PRO, &state); + // A policy change must neither consume the queued release nor detach input. + assert(host.setAutomaticDiscovery(false) == S2K_OK); + assert(test_input_automatic_discovery(fixture) == 0); + assert(SDL_GamepadConnected(pad) && SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_EAST)); + assert(host.instance(first) == original && host.instance(second) == other); + assert(host.pump() == S2K_OK && !SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_EAST)); + assert(host.setAutomaticDiscovery(true) == S2K_OK); + for (int i = 0; i < 1000; ++i) { + assert(host.pump() == S2K_OK && host.snapshot().count == 2); + } + assert(test_input_start_count(fixture) == 1 && test_input_discovery_count(fixture) == 0); + assert(test_input_discovery_configuration_count(fixture) == 3); + + assert(host.stop() == S2K_OK && !host.snapshot().running); + assert(!SDL_GamepadConnected(pad)); + SDL_CloseGamepad(pad); + assert(host.instance(first) == 0 && host.instance(second) == 0); + assert(host.start() == S2K_BUSY && host.setAutomaticDiscovery(false) == S2K_BUSY); + assert(test_input_automatic_discovery(fixture) == 1); + assert(test_input_finish_stop(fixture) == 1 && test_input_finish_stop(fixture) == 0); + assert(host.setAutomaticDiscovery(true) == S2K_OK); + for (int i = 0; i < 1000; ++i) { + assert(host.pump() == S2K_OK && !host.snapshot().running); + } + assert(test_input_start_count(fixture) == 1 && test_input_discovery_count(fixture) == 0); + assert(host.start() == S2K_OK && test_input_start_count(fixture) == 2); + assert(test_input_automatic_discovery(fixture) == 1); + // A reverse-order new generation still resolves each original physical key. + state = {}; state.sequence = 1; + test_input_report(fixture, 1, S2K_PRO, &state); + test_input_report(fixture, 0, S2K_PRO, &state); + assert(host.pump() == S2K_OK); + assert(host.instance(first) && host.instance(first) != original); + assert(host.instance(second) && host.instance(second) != other); + assert(host.identity(host.instance(first)) == first && host.identity(host.instance(second)) == second); + assert(host.setAutomaticDiscovery(false) == S2K_OK && host.discover() == S2K_OK); + assert(test_input_start_count(fixture) == 2 && test_input_discovery_count(fixture) == 1); + assert(host.stop() == S2K_OK && test_input_finish_stop(fixture) == 1); + host.shutdown(); host.shutdown(); + assert(host.pump() == S2K_OK); + std::puts("PASS real SDLHost automatic policy, queued input, live opt-out, stop/busy/restart and physical identity"); } int main() { SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "0"); SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); assert(SDL_Init(SDL_INIT_GAMEPAD)); + automaticDiscoveryTests(); auto* fixture = test_input_create(); Switch2Kit::SDLHost host(fixture); assert(host.initialize() == S2K_OK && host.snapshot().running == 0); diff --git a/tests/sdl-inprocess/Fixture.swift b/tests/sdl-inprocess/Fixture.swift index 8dab338..7742390 100644 --- a/tests/sdl-inprocess/Fixture.swift +++ b/tests/sdl-inprocess/Fixture.swift @@ -14,6 +14,8 @@ final class SDLTestSource: ControllerSource { var running = false var automaticDiscovery = false var discoveryConfigurationCount: UInt32 = 0 + var startCount: UInt32 = 0 + var discoveryCount: UInt32 = 0 var stopCompletion: (@Sendable () -> Void)? } let state = Mutex(State()) @@ -21,7 +23,7 @@ final class SDLTestSource: ControllerSource { .init(isRunning: value.running, bluetooth: .poweredOn, discovery: .paused, controllers: value.controllers.sorted { $0.key < $1.key }.map(\.value)) } - func start() { state.withLock { $0.running = true; hub.publish(snapshot($0), event: .status(snapshot($0))) } } + func start() { state.withLock { $0.startCount += 1; $0.running = true; hub.publish(snapshot($0), event: .status(snapshot($0))) } } func stop(completion: @escaping @Sendable () -> Void) { state.withLock { value in value.running = false; value.stopCompletion = completion @@ -30,7 +32,7 @@ final class SDLTestSource: ControllerSource { hub.publish(snapshot(value), event: .status(snapshot(value))) } } - func discover(seconds: Double) {} + func discover(seconds: Double) { state.withLock { $0.discoveryCount += 1 } } func setAutomaticDiscovery(_ enabled: Bool) { // Record policy intent without simulating Bluetooth or changing ready sessions. state.withLock { @@ -98,3 +100,33 @@ public func fixtureRumble(_ handle: OpaquePointer, _ index: Int32, _ strong: Uns strong.pointee = value?.strong ?? 0; weak.pointee = value?.weak ?? 0 return value?.count ?? 0 } + +// Native consumer inspection only. These exports are not SDK distribution APIs. +@_cdecl("test_input_automatic_discovery") +public func fixtureAutomaticDiscovery(_ handle: OpaquePointer) -> UInt32 { + source(handle).state.withLock { $0.automaticDiscovery ? 1 : 0 } +} +@_cdecl("test_input_discovery_configuration_count") +public func fixtureDiscoveryConfigurationCount(_ handle: OpaquePointer) -> UInt32 { + source(handle).state.withLock { $0.discoveryConfigurationCount } +} +@_cdecl("test_input_start_count") +public func fixtureStartCount(_ handle: OpaquePointer) -> UInt32 { + source(handle).state.withLock { $0.startCount } +} +@_cdecl("test_input_discovery_count") +public func fixtureDiscoveryCount(_ handle: OpaquePointer) -> UInt32 { + source(handle).state.withLock { $0.discoveryCount } +} +@_cdecl("test_input_finish_stop") +public func fixtureFinishStop(_ handle: OpaquePointer) -> UInt32 { + let completion = source(handle).state.withLock { value in + let completion = value.stopCompletion + value.stopCompletion = nil + return completion + } + // Deliver after releasing the source lock, as the real asynchronous source does. + guard let completion else { return 0 } + completion() + return 1 +} From 51e36a8223f2c4254b8d9d9f43dc10c8cdb3ff33 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Fri, 18 Sep 2026 14:21:13 -0400 Subject: [PATCH 3/4] ci: execute the real SDLHost lifecycle regressions on Linux --- .github/workflows/sdlhost-autoconnect.yml | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/sdlhost-autoconnect.yml diff --git a/.github/workflows/sdlhost-autoconnect.yml b/.github/workflows/sdlhost-autoconnect.yml new file mode 100644 index 0000000..d357386 --- /dev/null +++ b/.github/workflows/sdlhost-autoconnect.yml @@ -0,0 +1,59 @@ +name: SDLHost automatic-discovery regressions +on: + pull_request: + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: sdlhost-autoconnect-${{ github.ref }} + cancel-in-progress: true +jobs: + native-host: + runs-on: ubuntu-24.04 + container: swift:6.2.1-noble + timeout-minutes: 25 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: libsdl-org/SDL + ref: fa2c02bb6e21974a89ea9824bc53c9932abe5f9c + path: build/SDL + persist-credentials: false + - name: Preserve the exact SDL input for reproduction + run: | + git config --global --add safe.directory "$PWD/build/SDL" + test "$(git -C build/SDL rev-parse HEAD)" = fa2c02bb6e21974a89ea9824bc53c9932abe5f9c + git -C build/SDL archive HEAD -o "$RUNNER_TEMP/sdl-source.zip" + - name: Retain pinned SDL source + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: sdlhost-pinned-source + path: ${{ runner.temp }}/sdl-source.zip + retention-days: 3 + - name: Install native build tools + run: | + apt-get update + apt-get install -y --no-install-recommends cmake ninja-build make g++ python3 libsystemd0 + - name: Execute real host, C facade and SDL consumers + env: + S2K_SDL_SOURCE: ${{ github.workspace }}/build/SDL + S2K_EXPECT_SDL_VERSION: 3004016 + run: | + set -o pipefail + bash tests/emulator-host/verify.sh 2>&1 | tee native-host.log + - name: Native host diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: sdlhost-autoconnect-diagnostics + path: native-host.log + if-no-files-found: warn + retention-days: 7 From 1e6eac15fd4d4c93f15244eb66d79021c92d29fc Mon Sep 17 00:00:00 2001 From: Johnny D Date: Sat, 19 Sep 2026 17:45:42 -0400 Subject: [PATCH 4/4] ci: validate SDLHost against merged desktop controller support The synchronized branch at 6575ac7 incorporates main d73cbec but has no workflow runs associated with its head. Advance the existing PR branch with an identical source tree to request the complete pull-request validation matrix; no implementation, workflow, test or safety check is changed. Independently verified tree 50e0685fc5503ebcf1bbdcb5dac1f08007d94f5a against the published merge. Linux/Swift 6.2.1: all 101 package tests with warnings as errors, all six real SDLHost/SDL consumers using SDL 3.4.16, four repository integrity tests, five distribution-notice tests and fail-closed signing/application identity checks passed. Keep native Windows/macOS and complete final-head CI as merge gates. No physical-controller or gameplay qualification is claimed.