Skip to content
Open
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
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,34 @@ 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.

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**
Expand All @@ -78,6 +106,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

Expand Down Expand Up @@ -117,12 +146,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
Expand Down
1 change: 1 addition & 0 deletions Sources/FinallyTheControllerWorks/FTCWApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
271 changes: 271 additions & 0 deletions Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
// 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
/// 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 }
}
}

private let queue = DispatchQueue(label: "com.petersharma.ftcw.netpad")
private let players = (0..<BridgeEngine.maxPlayers).map { _ in Player() }
private var fd: Int32 = -1
private var timer: DispatchSourceTimer?
private var lastSendErrorAt: TimeInterval = 0

init() {
queue.async { [weak self] in self?.openSocket() }
}

deinit {
timer?.cancel()
if fd >= 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.refreshButtons = p.wantButtons
p.refreshAxes = p.wantAxes.map { $0 != 0 }
}

busy = busy || p.hasPending || p.isHeld
guard now >= p.nextSendAt, p.hasPending else { continue }

// 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
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
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 {
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
}

/// 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
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,
destPtr, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
}
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
}
}

private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
11 changes: 11 additions & 0 deletions Sources/FinallyTheControllerWorks/UI/AppNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
19 changes: 19 additions & 0 deletions Sources/FinallyTheControllerWorks/UI/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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() }
Expand Down