From 52fb3c029bcf5bf9cfc4a6941f9e342ce57fc739 Mon Sep 17 00:00:00 2001 From: Luke Hager Date: Sun, 23 Aug 2026 11:03:48 +0300 Subject: [PATCH 1/5] Add network-gamepad output sink (RetroArch) RetroArch on macOS has no SDL input driver, so the SDL bridge can't reach it. Add an optional ControllerOutputSink that publishes controller state over localhost UDP in the libretro remote-gamepad format, which RetroArch's built-in Network Gamepad consumes: one port per player (base port + slot, default 55400-55403), with a Configuration toggle and base-port field. Off by default. Naming is generic (sink, settings keys, UI) with RetroArch as a hint on the toggle and in the README directions. The receiver drains one datagram per player per frame, so the sink sends diffs only, paced at 60/s, button edges before analog, and re-asserts held state every 2 s so a receiver launched mid-game converges. Co-Authored-By: Claude Fable 5 --- README.md | 27 ++- .../FinallyTheControllerWorks/FTCWApp.swift | 1 + .../Output/NetworkGamepadSink.swift | 228 ++++++++++++++++++ .../UI/AppNotifications.swift | 11 + .../UI/DashboardView.swift | 19 ++ 5 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift diff --git a/README.md b/README.md index d085e1d..0b35703 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,27 @@ Gopher64 is SDL-based, so it works through the bridge today: The same recipe works for any SDL3-based emulator or game — see [`sdl/README.md`](sdl/README.md) for the general one-line launch method. +## Using it with RetroArch + +RetroArch on macOS doesn't use SDL for input, so the bridge above can't +reach it. Instead the app can feed RetroArch's built-in **Network +Gamepad** directly — no extra library, nothing to patch: + +1. In RetroArch: **Settings → Network → Network Gamepad** → on. Leave + the base port at 55400, and turn on **Network Gamepad User 1** (and + 2–4 for more players). Restart RetroArch. +2. In the menu-bar app's dashboard, open **Configuration** and turn on + **Network gamepad output (RetroArch)**. +3. Load a game. Your controller drives the RetroPad for player 1 + directly (no "bind all" step needed) — sticks, D-pad, A/B/X/Y, + L/R/ZL/ZR, stick clicks, +/−. + +Caveats: RetroArch's network protocol is one-way (no rumble), and Home, +Capture, C, GL and GR have no RetroPad equivalent (use the app's +button remapper for those). RetroArch listens on all interfaces with no +authentication, so only enable its network gamepad on a network you +trust. + ## Features **Working now, in the beta UI** @@ -78,6 +99,7 @@ The same recipe works for any SDL3-based emulator or game — see - Button remapping per controller - Joy-Con 2 **mouse mode** (the optical sensor, used flat on the desk) - UDP/SDL bridge for games and emulators, with game rumble passthrough +- RetroArch network-gamepad output (no SDL needed; off by default) - Signed auto-updates, first-run tour, settings import/export, live log with BLE gap diagnostics, launch-at-login @@ -117,12 +139,13 @@ Controller ──BLE──> BridgeEngine ──> ControllerSession (per slot) ▼ ControllerOutputSink protocol ├── VirtualHIDSink (CoreHID; entitlement-gated) - └── UDPHub (SDL-compat, ports 24800-24803) + ├── UDPHub (SDL-compat, ports 24800-24803) + └── NetworkGamepadSink (network gamepad / RetroArch, 55400-55403) ``` - `Protocol/Switch2Protocol.swift` — the wire protocol, transport-free. - `Bluetooth/` — CoreBluetooth engine + per-controller session state machine. -- `Output/` — the two sinks. +- `Output/` — the sinks. - `UI/` — SwiftUI dashboard (status cards + live log) and menu bar. ## Support diff --git a/Sources/FinallyTheControllerWorks/FTCWApp.swift b/Sources/FinallyTheControllerWorks/FTCWApp.swift index 65c7d53..5371178 100644 --- a/Sources/FinallyTheControllerWorks/FTCWApp.swift +++ b/Sources/FinallyTheControllerWorks/FTCWApp.swift @@ -83,6 +83,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { func applicationDidFinishLaunching(_ notification: Notification) { bridgeLog(.info, "app", "Finally the Controller Works — starting bridge") engine.addSink(UDPHub()) + engine.addSink(NetworkGamepadSink()) engine.addSink(VirtualHIDSink()) notifications.attach(to: engine) // Daily auto-update check (only if a feed URL is configured); results diff --git a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift new file mode 100644 index 0000000..96ca585 --- /dev/null +++ b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift @@ -0,0 +1,228 @@ +// NetworkGamepadSink.swift +// Optional sink: publishes controller state as "network gamepad" datagrams +// over localhost UDP, one port per player (base port + slot, default +// 55400-55403), for programs that accept a remote gamepad over the network +// instead of reading input devices — RetroArch's Network Gamepad (Remote +// RetroPad) is the one this was built for, and it has no SDL input path on +// macOS, so the SDL bridge can't reach it. Off by default; toggled in +// Configuration. +// +// Wire format is the libretro remote protocol (RetroArch +// input/input_driver.h `struct remote_message`, 20 bytes, native +// little-endian): +// i32 port | i32 device | i32 index | i32 id | u16 state | 2 pad +// device 1 (JOYPAD): id = RetroPad button, state 0/1 +// device 5 (ANALOG): index 0 = left stick, 1 = right; id 0 = X, 1 = Y; +// state = Int16, +Y down +// The receiver reads ONE datagram per player per frame and drops the rest, +// so this sink sends diffs only, paced at 60/s per player, button edges +// before analog drift, and re-asserts held state every 2 s (a receiver +// launched mid-hold converges; zeros match its initial state). The +// protocol is one-way: no rumble. + +import Foundation +import Darwin + +final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { + + private static let sendInterval: TimeInterval = 1.0 / 60.0 + private static let refreshInterval: TimeInterval = 2.0 + /// Quantize axes so centre jitter doesn't eat the per-frame send budget. + private static let analogQuantum: Int32 = 512 + + private static let retroDeviceJoypad: Int32 = 1 + private static let retroDeviceAnalog: Int32 = 5 + private static let retroPadL2 = 12 + private static let retroPadR2 = 13 + + /// Switch 2 button → RetroPad id (libretro.h RETRO_DEVICE_ID_JOYPAD_*). + /// Home/Capture/C/GL/GR have no RetroPad slot; the remapper covers those. + private static let buttonMap: [(Switch2.Buttons, Int)] = [ + (.b, 0), (.y, 1), (.minus, 2), (.plus, 3), + (.dpadUp, 4), (.dpadDown, 5), (.dpadLeft, 6), (.dpadRight, 7), + (.a, 8), (.x, 9), (.l, 10), (.r, 11), (.zl, 12), (.zr, 13), + (.lStick, 14), (.rStick, 15), + ] + + var onRumble: ((Int, Double, Double) -> Void)? + + private final class Player { + var wantButtons: UInt16 = 0 + var sentButtons: UInt16 = 0 + var wantAxes = [Int16](repeating: 0, count: 4) // LX LY RX RY + var sentAxes = [Int16](repeating: 0, count: 4) + var nextSendAt: TimeInterval = 0 + var lastRefreshAt: TimeInterval = 0 + var announced = false + + var hasPending: Bool { + wantButtons != sentButtons || wantAxes != sentAxes + } + var isHeld: Bool { + wantButtons != 0 || wantAxes.contains { $0 != 0 } + } + } + + private let queue = DispatchQueue(label: "com.petersharma.ftcw.netpad") + private let players = (0..= 0 { close(fd) } + } + + private func openSocket() { + fd = socket(AF_INET, SOCK_DGRAM, 0) + guard fd >= 0 else { + bridgeLog(.error, "netpad", "socket() failed: \(String(cString: strerror(errno)))") + return + } + _ = fcntl(fd, F_SETFL, O_NONBLOCK) + } + + // MARK: ControllerOutputSink (called on the Bluetooth queue) + + func controllerConnected(slot: Int, model: Switch2.Model) {} + func controllerName(slot: Int, name: String) {} + + func controllerDisconnected(slot: Int) { + queue.async { [weak self] in + guard let self, let p = self.players[safe: slot] else { return } + p.wantButtons = 0 + p.wantAxes = [0, 0, 0, 0] + self.ensurePumping() + } + } + + func controllerState(slot: Int, state: ControllerState) { + guard AppConfig.networkGamepadEnabled else { return } + queue.async { [weak self] in + guard let self, let p = self.players[safe: slot] else { return } + var buttons: UInt16 = 0 + for (button, id) in Self.buttonMap where state.buttons.contains(button) { + buttons |= 1 << id + } + // GameCube-style analog triggers report in lt/rt without ZL/ZR. + if state.leftTrigger >= 128 { buttons |= 1 << Self.retroPadL2 } + if state.rightTrigger >= 128 { buttons |= 1 << Self.retroPadR2 } + p.wantButtons = buttons + p.wantAxes = [ + Self.axis(state.leftStick.x), + Self.axis(-state.leftStick.y), // +Y up here, +Y down in RetroPad + Self.axis(state.rightStick.x), + Self.axis(-state.rightStick.y), + ] + self.ensurePumping() + } + } + + private static func axis(_ value: Double) -> Int16 { + let raw = Int32(max(-1, min(1, value)) * 32767) + return Int16(raw / analogQuantum * analogQuantum) + } + + // MARK: Pump (queue-confined) + + /// The timer only runs while something is pending or held, so an idle + /// or disabled sink costs nothing. + private func ensurePumping() { + guard timer == nil, fd >= 0 else { return } + let t = DispatchSource.makeTimerSource(queue: queue) + t.schedule(deadline: .now(), repeating: Self.sendInterval, leeway: .milliseconds(2)) + t.setEventHandler { [weak self] in self?.pump() } + t.resume() + timer = t + } + + private func pump() { + let enabled = AppConfig.networkGamepadEnabled + let basePort = AppConfig.networkGamepadBasePort + let now = CFAbsoluteTimeGetCurrent() + var busy = false + + for (slot, p) in players.enumerated() { + if !enabled { + // Toggled off mid-hold: release everything, then go quiet. + p.wantButtons = 0 + p.wantAxes = [0, 0, 0, 0] + p.announced = false + } else if now - p.lastRefreshAt >= Self.refreshInterval { + p.lastRefreshAt = now + p.sentButtons &= p.wantButtons + for i in 0..<4 where p.wantAxes[i] != 0 { p.sentAxes[i] = 0 } + } + + busy = busy || p.hasPending || p.isHeld + guard now >= p.nextSendAt, p.hasPending else { continue } + + let port = UInt16(clamping: basePort + slot) + let diff = p.wantButtons ^ p.sentButtons + if diff != 0 { + let id = diff.trailingZeroBitCount + let pressed = (p.wantButtons >> id) & 1 + send(Self.message(slot: slot, device: Self.retroDeviceJoypad, + index: 0, id: Int32(id), state: pressed), port: port) + p.sentButtons ^= 1 << id + } else { + let axis = (0..<4).max { a, b in + abs(Int(p.wantAxes[a]) - Int(p.sentAxes[a])) + < abs(Int(p.wantAxes[b]) - Int(p.sentAxes[b])) + }! + send(Self.message(slot: slot, device: Self.retroDeviceAnalog, + index: Int32(axis / 2), id: Int32(axis % 2), + state: UInt16(bitPattern: p.wantAxes[axis])), port: port) + p.sentAxes[axis] = p.wantAxes[axis] + } + p.nextSendAt = now + Self.sendInterval + if !p.announced { + p.announced = true + bridgeLog(.info, "netpad", + "player \(slot + 1) → udp://127.0.0.1:\(port) (network gamepad)") + } + } + + if !busy { + timer?.cancel() + timer = nil + } + } + + private static func message(slot: Int, device: Int32, index: Int32, + id: Int32, state: UInt16) -> [UInt8] { + var d = [UInt8]() + d.reserveCapacity(20) + for v in [Int32(slot), device, index, id] { + withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } + } + withUnsafeBytes(of: state.littleEndian) { d.append(contentsOf: $0) } + d.append(contentsOf: [0, 0]) + return d + } + + private func send(_ bytes: [UInt8], port: UInt16) { + var dest = sockaddr_in() + dest.sin_family = sa_family_t(AF_INET) + dest.sin_port = port.bigEndian + dest.sin_addr.s_addr = UInt32(0x7F000001).bigEndian // 127.0.0.1 + _ = bytes.withUnsafeBytes { buf in + withUnsafePointer(to: &dest) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { destPtr in + sendto(fd, buf.baseAddress, buf.count, 0, + destPtr, socklen_t(MemoryLayout.size)) + } + } + } + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift b/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift index 4af3030..1357731 100644 --- a/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift +++ b/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift @@ -14,6 +14,8 @@ enum AppConfig { static let notifyConnectionsKey = "notifyConnections" // Bool, default true static let lowBatteryThresholdKey = "lowBatteryThreshold" // Double 0-1, default 0.15 static let idleSleepMinutesKey = "idleSleepMinutes" // Double, 0 = never, default 15 + static let networkGamepadEnabledKey = "networkGamepadEnabled" // Bool, default false + static let networkGamepadBasePortKey = "networkGamepadBasePort" // Int, default 55400 static var notifyEnabled: Bool { UserDefaults.standard.object(forKey: notifyEnabledKey) as? Bool ?? true @@ -24,6 +26,15 @@ enum AppConfig { static var lowBatteryThreshold: Double { UserDefaults.standard.object(forKey: lowBatteryThresholdKey) as? Double ?? 0.15 } + static var networkGamepadEnabled: Bool { + UserDefaults.standard.object(forKey: networkGamepadEnabledKey) as? Bool ?? false + } + /// Network gamepad base UDP port; player N uses base + N - 1 + /// (RetroArch's `network_remote_base_port`). + static var networkGamepadBasePort: Int { + let port = UserDefaults.standard.object(forKey: networkGamepadBasePortKey) as? Int ?? 55400 + return min(max(port, 1024), 65535 - BridgeEngine.maxPlayers) + } static var idleSleepMinutes: Double { UserDefaults.standard.object(forKey: idleSleepMinutesKey) as? Double ?? 15 } diff --git a/Sources/FinallyTheControllerWorks/UI/DashboardView.swift b/Sources/FinallyTheControllerWorks/UI/DashboardView.swift index 03cf18e..f2a73cb 100644 --- a/Sources/FinallyTheControllerWorks/UI/DashboardView.swift +++ b/Sources/FinallyTheControllerWorks/UI/DashboardView.swift @@ -738,6 +738,8 @@ struct ConfigurationSection: View { @AppStorage(AppConfig.notifyConnectionsKey) private var notifyConnections = true @AppStorage(AppConfig.lowBatteryThresholdKey) private var lowBattery = 0.15 @AppStorage(AppConfig.idleSleepMinutesKey) private var idleMinutes = 15.0 + @AppStorage(AppConfig.networkGamepadEnabledKey) private var networkGamepadEnabled = false + @AppStorage(AppConfig.networkGamepadBasePortKey) private var networkGamepadBasePort = 55400 var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -766,6 +768,23 @@ struct ConfigurationSection: View { .font(.caption) .foregroundStyle(.secondary) Divider() + Toggle("Network gamepad output (RetroArch)", isOn: $networkGamepadEnabled) + HStack(spacing: 12) { + Text("Base port") + TextField("55400", value: $networkGamepadBasePort, format: .number.grouping(.never)) + .textFieldStyle(.roundedBorder) + .frame(width: 72) + .disabled(!networkGamepadEnabled) + Text("players 1–4 on \(String(networkGamepadBasePort))–\(String(networkGamepadBasePort + 3))") + .foregroundStyle(.secondary) + } + .padding(.leading, 18) + Text("Sends each player's state over local UDP to programs that accept a " + + "network gamepad — no SDL library needed; no rumble. " + + "RetroArch: Settings → Network → Network Gamepad, same base port.") + .font(.caption) + .foregroundStyle(.secondary) + Divider() HStack(spacing: 10) { Text("Settings backup") Button("Export…") { exportSettings() } From 40e0476c81a3c5142c567dc497aecd66f9f5494e Mon Sep 17 00:00:00 2001 From: Luke Hager Date: Sun, 23 Aug 2026 15:55:54 +0300 Subject: [PATCH 2/5] Network gamepad: don't mark a message sent until sendto accepts it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only diffs are ever sent, so a datagram dropped by a transient sendto failure (ENOBUFS etc.) was lost for good — a lost button release left the direction held until the button was pressed again. Commit sent-state only on success and retry on the next tick, logging failures at most once a second. --- .../Output/NetworkGamepadSink.swift | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift index 96ca585..441489f 100644 --- a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift +++ b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift @@ -67,6 +67,7 @@ final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { private let players = (0..= p.nextSendAt, p.hasPending else { continue } + // Only record a message as delivered once sendto accepts it; a + // transient failure (ENOBUFS etc.) would otherwise drop a button + // release for good, since only diffs are ever sent. let port = UInt16(clamping: basePort + slot) let diff = p.wantButtons ^ p.sentButtons if diff != 0 { let id = diff.trailingZeroBitCount let pressed = (p.wantButtons >> id) & 1 - send(Self.message(slot: slot, device: Self.retroDeviceJoypad, - index: 0, id: Int32(id), state: pressed), port: port) + guard send(Self.message(slot: slot, device: Self.retroDeviceJoypad, + index: 0, id: Int32(id), state: pressed), + port: port, now: now) else { continue } p.sentButtons ^= 1 << id } else { let axis = (0..<4).max { a, b in abs(Int(p.wantAxes[a]) - Int(p.sentAxes[a])) < abs(Int(p.wantAxes[b]) - Int(p.sentAxes[b])) }! - send(Self.message(slot: slot, device: Self.retroDeviceAnalog, - index: Int32(axis / 2), id: Int32(axis % 2), - state: UInt16(bitPattern: p.wantAxes[axis])), port: port) + guard send(Self.message(slot: slot, device: Self.retroDeviceAnalog, + index: Int32(axis / 2), id: Int32(axis % 2), + state: UInt16(bitPattern: p.wantAxes[axis])), + port: port, now: now) else { continue } p.sentAxes[axis] = p.wantAxes[axis] } p.nextSendAt = now + Self.sendInterval @@ -205,12 +211,14 @@ final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { return d } - private func send(_ bytes: [UInt8], port: UInt16) { + /// True once the kernel accepted the datagram. Failures are logged at + /// most once a second; the caller retries on the next tick. + private func send(_ bytes: [UInt8], port: UInt16, now: TimeInterval) -> Bool { var dest = sockaddr_in() dest.sin_family = sa_family_t(AF_INET) dest.sin_port = port.bigEndian dest.sin_addr.s_addr = UInt32(0x7F000001).bigEndian // 127.0.0.1 - _ = bytes.withUnsafeBytes { buf in + let sent = bytes.withUnsafeBytes { buf in withUnsafePointer(to: &dest) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { destPtr in sendto(fd, buf.baseAddress, buf.count, 0, @@ -218,6 +226,13 @@ final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { } } } + if sent == bytes.count { return true } + if now - lastSendErrorAt >= 1.0 { + lastSendErrorAt = now + bridgeLog(.warning, "netpad", + "sendto 127.0.0.1:\(port) failed, retrying: \(String(cString: strerror(errno)))") + } + return false } } From 323afe500ab5c675aa8756a7d037946180c6e715 Mon Sep 17 00:00:00 2001 From: Luke Hager Date: Sun, 23 Aug 2026 16:47:26 +0300 Subject: [PATCH 3/5] README: note RetroArch's upstream SDL3 driver as a future path --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 0b35703..5675a1f 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,13 @@ button remapper for those). RetroArch listens on all interfaces with no authentication, so only enable its network gamepad on a network you trust. +Looking ahead: RetroArch gained an SDL3 joypad driver upstream in +mid-2026, but the official macOS builds aren't compiled with it yet. If +that changes, the SDL bridge above will work with RetroArch too — with +rumble — by launching it with the patched `libSDL3` like any other SDL3 +app, and the network gamepad becomes the fallback rather than the only +route. + ## Features **Working now, in the beta UI** From dccc0e3fc155806bbfc5cc938d070cca143fe9d3 Mon Sep 17 00:00:00 2001 From: Luke Hager Date: Sun, 23 Aug 2026 17:14:28 +0300 Subject: [PATCH 4/5] Network gamepad: don't let the 2 s refresh swallow pending releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh cleared sent-bits with sent &= want, which also cleared the bit of a button released since the previous tick — so its release was never sent and the direction stayed held in the receiver until the button was pressed again. Clear only the bits of currently held buttons (sent &= ~want) so pending releases still go out. --- .../Output/NetworkGamepadSink.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift index 441489f..509c22e 100644 --- a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift +++ b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift @@ -154,8 +154,11 @@ final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { p.wantAxes = [0, 0, 0, 0] p.announced = false } else if now - p.lastRefreshAt >= Self.refreshInterval { + // Force a resend of HELD buttons/axes only. A button released + // since the last tick still has its sent-bit set; clearing + // that too would swallow the pending release. p.lastRefreshAt = now - p.sentButtons &= p.wantButtons + p.sentButtons &= ~p.wantButtons for i in 0..<4 where p.wantAxes[i] != 0 { p.sentAxes[i] = 0 } } From 2c7a396336f5a657a16a772b8c056d96ec6ff7f1 Mon Sep 17 00:00:00 2001 From: Luke Hager Date: Sun, 23 Aug 2026 17:21:22 +0300 Subject: [PATCH 5/5] Network gamepad: track re-asserts separately from sent state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The periodic refresh forced a resend of held buttons by clearing their sent-bits — but sent-bits are also the only record of what the receiver currently holds. A button released between the refresh and the resend then looked already-released (want 0, sent 0), so no release was sent and the direction stayed held. Keep sent* as the true receiver model and track pending re-asserts in separate refresh* masks, sent after edges and analog changes. --- .../Output/NetworkGamepadSink.swift | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift index 509c22e..193a8ca 100644 --- a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift +++ b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift @@ -53,10 +53,21 @@ final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { var sentAxes = [Int16](repeating: 0, count: 4) var nextSendAt: TimeInterval = 0 var lastRefreshAt: TimeInterval = 0 + /// Held state still to re-assert after the last refresh. Kept apart + /// from sent*, which must stay the true model of what the receiver + /// holds — clearing sent-bits to force a resend is how a release got + /// skipped and a direction stayed stuck. + var refreshButtons: UInt16 = 0 + var refreshAxes = [Bool](repeating: false, count: 4) var announced = false + var reassertButtons: UInt16 { refreshButtons & wantButtons } + var reassertAxis: Int? { + (0..<4).first { refreshAxes[$0] && wantAxes[$0] != 0 } + } var hasPending: Bool { wantButtons != sentButtons || wantAxes != sentAxes + || reassertButtons != 0 || reassertAxis != nil } var isHeld: Bool { wantButtons != 0 || wantAxes.contains { $0 != 0 } @@ -154,22 +165,24 @@ final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { p.wantAxes = [0, 0, 0, 0] p.announced = false } else if now - p.lastRefreshAt >= Self.refreshInterval { - // Force a resend of HELD buttons/axes only. A button released - // since the last tick still has its sent-bit set; clearing - // that too would swallow the pending release. p.lastRefreshAt = now - p.sentButtons &= ~p.wantButtons - for i in 0..<4 where p.wantAxes[i] != 0 { p.sentAxes[i] = 0 } + p.refreshButtons = p.wantButtons + p.refreshAxes = p.wantAxes.map { $0 != 0 } } busy = busy || p.hasPending || p.isHeld guard now >= p.nextSendAt, p.hasPending else { continue } - // Only record a message as delivered once sendto accepts it; a + // Priority: button edges, analog changes, then re-asserts. Only + // record a message as delivered once sendto accepts it; a // transient failure (ENOBUFS etc.) would otherwise drop a button // release for good, since only diffs are ever sent. let port = UInt16(clamping: basePort + slot) let diff = p.wantButtons ^ p.sentButtons + let axis = (0..<4).max { a, b in + abs(Int(p.wantAxes[a]) - Int(p.sentAxes[a])) + < abs(Int(p.wantAxes[b]) - Int(p.sentAxes[b])) + }! if diff != 0 { let id = diff.trailingZeroBitCount let pressed = (p.wantButtons >> id) & 1 @@ -177,16 +190,28 @@ final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { index: 0, id: Int32(id), state: pressed), port: port, now: now) else { continue } p.sentButtons ^= 1 << id - } else { - let axis = (0..<4).max { a, b in - abs(Int(p.wantAxes[a]) - Int(p.sentAxes[a])) - < abs(Int(p.wantAxes[b]) - Int(p.sentAxes[b])) - }! + p.refreshButtons &= ~(UInt16(1) << id) // an edge supersedes it + } else if p.wantAxes[axis] != p.sentAxes[axis] { guard send(Self.message(slot: slot, device: Self.retroDeviceAnalog, index: Int32(axis / 2), id: Int32(axis % 2), state: UInt16(bitPattern: p.wantAxes[axis])), port: port, now: now) else { continue } p.sentAxes[axis] = p.wantAxes[axis] + p.refreshAxes[axis] = false + } else if p.reassertButtons != 0 { + let id = p.reassertButtons.trailingZeroBitCount + guard send(Self.message(slot: slot, device: Self.retroDeviceJoypad, + index: 0, id: Int32(id), state: 1), + port: port, now: now) else { continue } + p.refreshButtons &= ~(UInt16(1) << id) + } else if let held = p.reassertAxis { + guard send(Self.message(slot: slot, device: Self.retroDeviceAnalog, + index: Int32(held / 2), id: Int32(held % 2), + state: UInt16(bitPattern: p.wantAxes[held])), + port: port, now: now) else { continue } + p.refreshAxes[held] = false + } else { + continue } p.nextSendAt = now + Self.sendInterval if !p.announced {