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
59 changes: 59 additions & 0 deletions .github/workflows/sdlhost-autoconnect.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 18 additions & 2 deletions Integrations/Emulators/SDLHost.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@

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
* the emulator's input loop. Other calls are serialized; stop before SDL quits.
* On macOS, the first call allocating a context (initialize, policy selection,
* start or discover) must run on the 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.
*/
class SDLHost final {
Expand All @@ -28,6 +29,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;
Expand Down
130 changes: 130 additions & 0 deletions tests/emulator-host/main.cpp
Original file line number Diff line number Diff line change
@@ -1,16 +1,146 @@
#include <SDLHost.hpp>
#include <cassert>
#include <atomic>
#include <thread>
#include <cstdio>
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 onDemandPolicyTests() {
auto* fixture = test_input_create(); assert(fixture);
Switch2Kit::SDLHost host(fixture);
// No saved consent: selecting the default policy must not touch the radio.
assert(host.setAutomaticDiscovery(false) == S2K_OK);
assert(test_input_discovery_configuration_count(fixture) == 0);
assert(test_input_start_count(fixture) == 0 && !host.snapshot().running);
assert(host.start() == S2K_OK && host.start() == S2K_OK);
for (int i = 0; i < 100; ++i) assert(host.pump() == S2K_OK);
assert(host.snapshot().running && test_input_start_count(fixture) == 1);
assert(test_input_automatic_discovery(fixture) == 0);
assert(test_input_discovery_count(fixture) == 0);
assert(host.stop() == S2K_OK && host.snapshot().stopping);
assert(host.discover() == S2K_BUSY);
assert(test_input_start_count(fixture) == 1 && test_input_discovery_count(fixture) == 0);
assert(test_input_finish_stop(fixture) == 1);
// Changing either policy while stopped is configuration, not a restart.
assert(host.setAutomaticDiscovery(true) == S2K_OK);
assert(host.setAutomaticDiscovery(false) == S2K_OK);
assert(!host.snapshot().running && !host.snapshot().stopping);
assert(test_input_start_count(fixture) == 1);
assert(host.start() == S2K_OK && test_input_start_count(fixture) == 2);
assert(test_input_automatic_discovery(fixture) == 0);
assert(test_input_discovery_count(fixture) == 0);
assert(host.stop() == S2K_OK && test_input_finish_stop(fixture) == 1);
host.shutdown();
std::puts("PASS policy-only start defaults to on-demand, busy discovery and stopped opt-out");
}
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);

// Settings can change policy while the input loop and enumeration are live.
// Exercise the production SDL-before-host lock order without timing sleeps.
std::atomic<bool> inputReady{false}, policyDone{false};
std::thread settings([&] {
while (!inputReady.load()) std::this_thread::yield();
for (int i = 0; i < 500; ++i) {
assert(host.setAutomaticDiscovery(false) == S2K_OK);
assert(host.setAutomaticDiscovery(true) == S2K_OK);
assert(host.instance(first) == original && host.instance(second) == other);
}
policyDone.store(true);
});
inputReady.store(true);
do {
assert(host.pump() == S2K_OK && host.snapshot().count == 2);
assert(SDL_GamepadConnected(pad));
} while (!policyDone.load());
settings.join();
assert(test_input_discovery_configuration_count(fixture) == 1003);
assert(test_input_start_count(fixture) == 1 && test_input_discovery_count(fixture) == 0);

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));
onDemandPolicyTests();
automaticDiscoveryTests();
auto* fixture = test_input_create();
Switch2Kit::SDLHost host(fixture);
assert(host.initialize() == S2K_OK && host.snapshot().running == 0);
Expand Down
36 changes: 34 additions & 2 deletions tests/sdl-inprocess/Fixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ 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())
func snapshot(_ value: State) -> Switch2ManagerSnapshot {
.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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}