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
57 changes: 57 additions & 0 deletions .github/workflows/sdl-regressions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: SDL input regressions
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
sdl:
runs-on: macos-26
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
repository: libsdl-org/SDL
ref: 147a8ee32dbf9ac02f3794964490687b6bbda1bc
path: .ci-sdl
persist-credentials: false
- name: Ensure the real wired driver is compiled
run: brew list libusb >/dev/null 2>&1 || brew install libusb
- name: Build baseline and reproduce missing events and wrong USB selection
shell: bash
run: |
set -euo pipefail
bash -n sdl/build-sdl.sh sdl/make-gopher64-both.sh
git -C .ci-sdl apply "$GITHUB_WORKSPACE/sdl/sdl3-3.4.14-s2udp.patch"
bash tests/sdl-usb/check-sdl.sh .ci-sdl before
cmake -S .ci-sdl -B "$RUNNER_TEMP/sdl-build" -DSDL_SHARED=ON -DSDL_STATIC=OFF -DSDL_TESTS=OFF -DSDL_HIDAPI_LIBUSB=ON > "$RUNNER_TEMP/sdl-baseline.log" 2>&1
cmake --build "$RUNNER_TEMP/sdl-build" --parallel 3 >> "$RUNNER_TEMP/sdl-baseline.log" 2>&1 || { tail -80 "$RUNNER_TEMP/sdl-baseline.log"; exit 1; }
grep '^#define HAVE_LIBUSB 1' "$RUNNER_TEMP/sdl-build"/include-config-*/build_config/SDL_build_config.h
cc -I .ci-sdl/include tests/sdl-edges/check.c -L "$RUNNER_TEMP/sdl-build" -Wl,-rpath,"$RUNNER_TEMP/sdl-build" -lSDL3 -o "$RUNNER_TEMP/check-edges"
set +e
"$RUNNER_TEMP/check-edges"
result=$?
set -e
test "$result" -eq 42
- name: Build corrected library and verify the same production functions
shell: bash
run: |
set -euo pipefail
bash sdl/build-sdl.sh .ci-sdl > "$RUNNER_TEMP/sdl-corrected.log" 2>&1 || { tail -80 "$RUNNER_TEMP/sdl-corrected.log"; exit 1; }
git -C .ci-sdl apply "$GITHUB_WORKSPACE/sdl/s2usb-device-identity.patch"
bash tests/sdl-usb/check-sdl.sh .ci-sdl after
SDL3_DYNAMIC_API="$GITHUB_WORKSPACE/build/sdl/libSDL3.0.dylib" "$RUNNER_TEMP/check-edges"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
if: always()
with:
name: sdl-build-diagnostics
path: ${{ runner.temp }}/sdl-*.log
retention-days: 7
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: corrected-sdl-arm64
path: build/sdl/libSDL3.0.dylib
retention-days: 7
31 changes: 31 additions & 0 deletions sdl/INPUT-DELIVERY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Input delivery correction

The tracked upstream libSDL3.0.dylib predates this fix. Editing a source patch
cannot change that binary. Build the corrected library on macOS:

```sh
git clone https://github.com/libsdl-org/SDL /tmp/SDL
bash sdl/build-sdl.sh /tmp/SDL
bash sdl/make-gopher64-both.sh
```

The build script exports exact commit 147a8ee32dbf9ac02f3794964490687b6bbda1bc,
applies the original patch and then s2udp-input-edges.patch, and writes
build/sdl/libSDL3.0.dylib without modifying the checkout or tracked binary.
The Gopher64 wrapper defaults to that rebuilt library and refuses to proceed
when it is missing. SDL3_LIBRARY may explicitly select another compatible
library. The corrected-sdl-arm64 CI artifact is a development build, not a
notarized application. Existing installed Gopher64 copies are not updated
until the wrapper is rerun.

Every received state now reaches an open SDL joystick before the next state
is read. The test sends complete button taps and analog-trigger excursions
between updates through actual localhost UDP sockets and SDL event APIs.
The CI negative control must fail with exit 42 before the correction; other
errors are not accepted as a reproduced defect. The same executable must
then pass via SDL3_DYNAMIC_API using the documented script's rebuilt library.

Nintendo BLE commands, bonding, keep-alives and report decoding are untouched.
This does not recover datagrams lost before receipt, nor guarantee that a
state-polling game observes arbitrarily short transitions. Physical gameplay
and controller latency still need hardware acceptance.
29 changes: 29 additions & 0 deletions sdl/USB-IDENTITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Wired USB command ownership

The upstream SDL addition selected the first openable device with the same
VID/PID. With identical controllers this can initialize or send feedback to
the wrong controller while HID input comes from another one.

The added s2usb-device-identity.patch resolves the exact HID DevSrvsID path
through IOKit to its USB device ancestor. It reads locationID and USB Address
(or USBDeviceAddress), matches both libusb bus and device address plus VID/PID,
and opens only a unique match. Registry entries and properties are released.
Unknown paths, missing/invalid properties and ambiguous matches do not guess.

**Compatibility tradeoff:** this acquisition path also performs wired
initialization. If identity cannot be established on a particular Mac/USB
backend, that wired SDL device may fail to initialize instead of falling back
to a potentially different controller. BLE/UDP output is unaffected. Capture
the actual HID/IOKit/libusb identities before qualifying supported hardware;
no physical-controller or driver-restoration test has been performed here.

CI compiles the real wired driver with libusb and Apple IOKit headers. A small
harness extracts the actual acquisition/identity function bodies and supplies
fake platform boundaries: the original selects the wrong identical device
(exit 42), the correction passes reverse-order, missing-peer, duplicate-address,
failed-claim, alternate-property, malformed-path and cleanup tests. The existing
real SDL/UDP edge test also runs against the combined rebuilt library.

Build with sdl/build-sdl.sh as documented in INPUT-DELIVERY.md. The tracked
upstream dylib is not updated; the builder applies all three patches and
produces build/sdl/libSDL3.0.dylib. No controller command bytes are changed.
18 changes: 18 additions & 0 deletions sdl/build-sdl.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/bash
# Build reviewed SDL sources; never overwrite the tracked upstream dylib.
# Usage: bash sdl/build-sdl.sh /path/to/SDL-git-checkout
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
source_dir=${1:?Provide a git checkout of libsdl-org/SDL containing release-3.4.14}
revision=147a8ee32dbf9ac02f3794964490687b6bbda1bc
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
git -C "$source_dir" archive "$revision" | tar -x -C "$work"
git -C "$work" apply "$root/sdl/sdl3-3.4.14-s2udp.patch"
git -C "$work" apply "$root/sdl/s2udp-input-edges.patch"
git -C "$work" apply "$root/sdl/s2usb-device-identity.patch"
cmake -S "$work" -B "$work/build" -DSDL_SHARED=ON -DSDL_STATIC=OFF -DSDL_TESTS=OFF -DSDL_HIDAPI_LIBUSB=ON
cmake --build "$work/build" --parallel 3
mkdir -p "$root/build/sdl"
cp "$work/build/libSDL3.0.dylib" "$root/build/sdl/libSDL3.0.dylib"
shasum -a 256 "$root/build/sdl/libSDL3.0.dylib"
7 changes: 6 additions & 1 deletion sdl/make-gopher64-both.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
set -euo pipefail

SRC="/Applications/Gopher64.app"
DYLIB="$(cd "$(dirname "$0")" && pwd)/libSDL3.0.dylib"
DYLIB="${SDL3_LIBRARY:-$(cd "$(dirname "$0")/.." && pwd)/build/sdl/libSDL3.0.dylib}"
if [ ! -f "$DYLIB" ]; then
echo "Build the corrected SDL first: bash sdl/build-sdl.sh /path/to/SDL" >&2
echo "Or set SDL3_LIBRARY to an explicitly selected compatible dylib." >&2
exit 1
fi
WORK="$(mktemp -d)/Gopher64-Both.app"
DEST="$HOME/Applications/Gopher64-Both.app"
# Portable: @executable_path resolves relative to the bundle no matter where
Expand Down
119 changes: 119 additions & 0 deletions sdl/s2udp-input-edges.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
--- a/src/joystick/darwin/SDL_s2udpjoystick.c
+++ b/src/joystick/darwin/SDL_s2udpjoystick.c
@@ -83,6 +83,7 @@
int sock; // -1 when unavailable
bool present;
SDL_JoystickID instance_id;
+ SDL_Joystick *joystick; // currently opened instance, never a replacement
Uint32 buttons;
float lx, ly, rx, ry;
Uint8 lt, rt;
@@ -124,8 +125,10 @@
}
}

-/* Drain one slot's queued datagrams, keep the newest state, and run its
- * presence state machine (hotplug on both edges). */
+static void S2UDP_EmitState(SDL_Joystick *joystick, S2UDP_Slot *slot);
+
+/* Every received state reaches an open joystick before the next is read.
+ * Keeping only the last packet would erase a complete press/release. */
static void S2UDP_PumpSlot(S2UDP_Slot *slot)
{
Uint8 buf[128];
@@ -148,6 +151,9 @@
slot->lt = buf[28];
slot->rt = buf[29];
slot->last_state_ms = now;
+ if (slot->present && slot->joystick) {
+ S2UDP_EmitState(slot->joystick, slot);
+ }
} else if (n > 4 && n < 4 + (ssize_t)sizeof(slot->name) &&
SDL_memcmp(buf, "S2N1", 4) == 0) {
// Bridge-provided display name (may be a user rename). A live
@@ -160,6 +166,8 @@
if (slot->present) {
slot->present = false;
slot->last_state_ms = 0; // re-add on next state packet
+ if (slot->joystick) slot->joystick->hwdata = NULL;
+ slot->joystick = NULL;
SDL_PrivateJoystickRemoved(slot->instance_id);
}
}
@@ -177,6 +185,8 @@
SDL_PrivateJoystickAdded(slot->instance_id);
} else if (slot->present && now - slot->last_state_ms >= PRESENCE_TIMEOUT_MS) {
slot->present = false;
+ if (slot->joystick) slot->joystick->hwdata = NULL;
+ slot->joystick = NULL;
SDL_PrivateJoystickRemoved(slot->instance_id);
}
}
@@ -352,13 +362,14 @@
joystick->naxes = 6;
joystick->nhats = 0;
joystick->hwdata = (struct joystick_hwdata *)slot;
+ slot->joystick = joystick;
return true;
}

static bool S2UDP_JoystickRumble(SDL_Joystick *joystick, Uint16 low_frequency_rumble, Uint16 high_frequency_rumble)
{
S2UDP_Slot *slot = (S2UDP_Slot *)joystick->hwdata;
- if (!slot) {
+ if (!slot || slot->joystick != joystick || !slot->present) {
return SDL_Unsupported();
}
slot->rumble_strong = low_frequency_rumble;
@@ -400,7 +411,7 @@
return (Sint16)(((int)value * 65535 / 255) - 32768);
}

-static void S2UDP_JoystickUpdate(SDL_Joystick *joystick)
+static void S2UDP_EmitState(SDL_Joystick *joystick, S2UDP_Slot *slot)
{
static const struct { Uint8 index; Uint32 mask; } button_map[] = {
{ 0, S2_BTN_B }, { 1, S2_BTN_A }, { 2, S2_BTN_Y }, { 3, S2_BTN_X },
@@ -410,14 +421,8 @@
{ 11, S2_BTN_UP }, { 12, S2_BTN_DOWN }, { 13, S2_BTN_LEFT }, { 14, S2_BTN_RIGHT },
{ 15, S2_BTN_CAPTURE }, { 16, S2_BTN_GR }, { 17, S2_BTN_GL }, { 18, S2_BTN_C },
};
- S2UDP_Slot *slot = (S2UDP_Slot *)joystick->hwdata;
Uint64 timestamp = SDL_GetTicksNS();
int i;
-
- S2UDP_PumpAll();
- if (!slot || !slot->present) {
- return;
- }

for (i = 0; i < (int)SDL_arraysize(button_map); ++i) {
SDL_SendJoystickButton(timestamp, joystick, button_map[i].index,
@@ -430,6 +435,18 @@
SDL_SendJoystickAxis(timestamp, joystick, 4, S2UDP_AxisFromTrigger(slot->lt));
SDL_SendJoystickAxis(timestamp, joystick, 5, S2UDP_AxisFromTrigger(slot->rt));

+}
+
+static void S2UDP_JoystickUpdate(SDL_Joystick *joystick)
+{
+ S2UDP_Slot *slot = (S2UDP_Slot *)joystick->hwdata;
+ S2UDP_PumpAll();
+ if (!slot || !slot->present || slot->joystick != joystick) {
+ return;
+ }
+ // Also initialize a newly opened joystick when there is no new datagram.
+ S2UDP_EmitState(joystick, slot);
+
// Sustain active rumble: the bridge expires intents after 500 ms so a
// crashed consumer can never leave the motor running.
if ((slot->rumble_strong || slot->rumble_weak) &&
@@ -440,6 +457,8 @@

static void S2UDP_JoystickClose(SDL_Joystick *joystick)
{
+ S2UDP_Slot *slot = (S2UDP_Slot *)joystick->hwdata;
+ if (slot && slot->joystick == joystick) slot->joystick = NULL;
joystick->hwdata = NULL;
}

86 changes: 86 additions & 0 deletions sdl/s2usb-device-identity.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
--- a/src/joystick/hidapi/SDL_hidapi_switch2.c
+++ b/src/joystick/hidapi/SDL_hidapi_switch2.c
@@ -417,10 +417,56 @@
+#ifdef SDL_PLATFORM_MACOS
+#include <CoreFoundation/CFNumber.h>
+#include <IOKit/IOKitLib.h>
+
+/* Match the opened HID service's USB ancestor, never just its model. */
+static bool S2USB_GetIdentity(const char *path, Uint8 *bus, Uint8 *address)
+{
+ unsigned long long identifier = 0;
+ char trailing;
+ if (!path || SDL_sscanf(path, "DevSrvsID:%llu%c", &identifier, &trailing) != 1) {
+ return false;
+ }
+ io_registry_entry_t entry = IOServiceGetMatchingService(kIOMainPortDefault,
+ IORegistryEntryIDMatching(identifier));
+ while (entry && !IOObjectConformsTo(entry, "IOUSBHostDevice") &&
+ !IOObjectConformsTo(entry, "IOUSBDevice")) {
+ io_registry_entry_t parent = IO_OBJECT_NULL;
+ kern_return_t result = IORegistryEntryGetParentEntry(entry, kIOServicePlane, &parent);
+ IOObjectRelease(entry);
+ if (result != KERN_SUCCESS) return false;
+ entry = parent;
+ }
+ if (!entry) return false;
+ CFTypeRef location = IORegistryEntryCreateCFProperty(entry, CFSTR("locationID"), kCFAllocatorDefault, 0);
+ CFTypeRef device_address = IORegistryEntryCreateCFProperty(entry, CFSTR("USB Address"), kCFAllocatorDefault, 0);
+ if (!device_address) {
+ device_address = IORegistryEntryCreateCFProperty(entry, CFSTR("USBDeviceAddress"), kCFAllocatorDefault, 0);
+ }
+ Sint64 loc = -1, addr = -1;
+ bool valid = location && device_address &&
+ CFGetTypeID(location) == CFNumberGetTypeID() &&
+ CFGetTypeID(device_address) == CFNumberGetTypeID() &&
+ CFNumberGetValue((CFNumberRef)location, kCFNumberSInt64Type, &loc) &&
+ CFNumberGetValue((CFNumberRef)device_address, kCFNumberSInt64Type, &addr) &&
+ loc >= 0 && loc <= 0xffffffffLL && addr > 0 && addr <= 127;
+ if (location) CFRelease(location);
+ if (device_address) CFRelease(device_address);
+ IOObjectRelease(entry);
+ if (!valid) return false;
+ *bus = (Uint8)((Uint32)loc >> 24);
+ *address = (Uint8)addr;
+ return true;
+}
+#endif
+
static bool AcquireVendorInterface(SDL_DriverSwitch2_Context *ctx)
{
#ifdef SDL_PLATFORM_MACOS
if (ctx->device_handle) {
return true;
}
- if (!ctx->libusb) {
+ Uint8 bus = 0, address = 0;
+ if (!ctx->libusb || !S2USB_GetIdentity(ctx->device->path, &bus, &address)) {
return false;
}
if (ctx->libusb->init(&ctx->usb_context) != 0) {
@@ -430,16 +476,20 @@
{
libusb_device **usb_list = NULL;
ssize_t usb_count = ctx->libusb->get_device_list(ctx->usb_context, &usb_list);
+ libusb_device *matched = NULL;
for (ssize_t di = 0; di < usb_count; di++) {
struct libusb_device_descriptor desc;
if (ctx->libusb->get_device_descriptor(usb_list[di], &desc) == 0 &&
desc.idVendor == ctx->device->vendor_id &&
- desc.idProduct == ctx->device->product_id) {
- if (ctx->libusb->open(usb_list[di], &ctx->device_handle) == 0) {
- ctx->own_device_handle = true;
- break;
- }
+ desc.idProduct == ctx->device->product_id &&
+ ctx->libusb->get_bus_number(usb_list[di]) == bus &&
+ ctx->libusb->get_device_address(usb_list[di]) == address) {
+ if (matched) { matched = NULL; break; } // ambiguous: no command
+ matched = usb_list[di];
}
+ }
+ if (matched && ctx->libusb->open(matched, &ctx->device_handle) == 0) {
+ ctx->own_device_handle = true;
}
if (usb_list) {
ctx->libusb->free_device_list(usb_list, 1);
Loading
Loading