diff --git a/src/MeloNX/MeloNX.xcodeproj/project.pbxproj b/src/MeloNX/MeloNX.xcodeproj/project.pbxproj index 92cc5f999..58de94918 100644 --- a/src/MeloNX/MeloNX.xcodeproj/project.pbxproj +++ b/src/MeloNX/MeloNX.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 4549A31C2DD8795900EC8D88 /* CocoaAsyncSocket in Frameworks */ = {isa = PBXBuildFile; productRef = 4549A31B2DD8795900EC8D88 /* CocoaAsyncSocket */; }; 4E12B23C2D797CFA00FB2271 /* MeloNX.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 4E12B23B2D797CFA00FB2271 /* MeloNX.xcconfig */; }; 4E8A80772D5FDD2D0041B48F /* GameController.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E80AA622CD7122800029585 /* GameController.framework */; }; 4EA5AE822D16807500AD0B9F /* SwiftSVG in Frameworks */ = {isa = PBXBuildFile; productRef = 4EA5AE812D16807500AD0B9F /* SwiftSVG */; }; @@ -203,6 +204,7 @@ buildActionMask = 2147483647; files = ( CA8F9C322D3F5AB200D7E586 /* GameController.framework in Frameworks */, + 4549A31C2DD8795900EC8D88 /* CocoaAsyncSocket in Frameworks */, 4EA5AE822D16807500AD0B9F /* SwiftSVG in Frameworks */, 4E8A80772D5FDD2D0041B48F /* GameController.framework in Frameworks */, ); @@ -300,6 +302,7 @@ name = MeloNX; packageProductDependencies = ( 4EA5AE812D16807500AD0B9F /* SwiftSVG */, + 4549A31B2DD8795900EC8D88 /* CocoaAsyncSocket */, ); productName = MeloNX; productReference = 4E80A98D2CD6F54500029585 /* MeloNX.app */; @@ -391,6 +394,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 4EA5AE802D16807500AD0B9F /* XCRemoteSwiftPackageReference "SwiftSVG" */, + 4549A31A2DD8795900EC8D88 /* XCRemoteSwiftPackageReference "CocoaAsyncSocket" */, ); preferredProjectObjectVersion = 56; productRefGroup = 4E80A98E2CD6F54500029585 /* Products */; @@ -1569,6 +1573,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 4549A31A2DD8795900EC8D88 /* XCRemoteSwiftPackageReference "CocoaAsyncSocket" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/robbiehanson/CocoaAsyncSocket"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 7.6.5; + }; + }; 4EA5AE802D16807500AD0B9F /* XCRemoteSwiftPackageReference "SwiftSVG" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mchoe/SwiftSVG"; @@ -1580,6 +1592,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 4549A31B2DD8795900EC8D88 /* CocoaAsyncSocket */ = { + isa = XCSwiftPackageProductDependency; + package = 4549A31A2DD8795900EC8D88 /* XCRemoteSwiftPackageReference "CocoaAsyncSocket" */; + productName = CocoaAsyncSocket; + }; 4EA5AE812D16807500AD0B9F /* SwiftSVG */ = { isa = XCSwiftPackageProductDependency; package = 4EA5AE802D16807500AD0B9F /* XCRemoteSwiftPackageReference "SwiftSVG" */; diff --git a/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5f080c7a7..cb3a468df 100644 --- a/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "fedf09a893a63378a2e53f631cd833ae83a0c9ee7338eb8d153b04fd34aaf805", + "originHash" : "b4a593815773c4e9eedb98cabe88f41620776314bffb6c39d5a41cb743e4d390", "pins" : [ + { + "identity" : "cocoaasyncsocket", + "kind" : "remoteSourceControl", + "location" : "https://github.com/robbiehanson/CocoaAsyncSocket", + "state" : { + "revision" : "dbdc00669c1ced63b27c3c5f052ee4d28f10150c", + "version" : "7.6.5" + } + }, { "identity" : "swiftsvg", "kind" : "remoteSourceControl", diff --git a/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/BaseController.swift b/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/BaseController.swift new file mode 100644 index 000000000..34bbb79a5 --- /dev/null +++ b/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/BaseController.swift @@ -0,0 +1,14 @@ +// +// BaseController.swift +// MeloNX +// +// Created by MediaMoots on 5/17/2025. +// + +//──────────────────────────────────────────────────────────────────────── MARK:- Base Controller Protocol + +/// Base Controller with motion related functions +protocol BaseController: AnyObject { + func tryRegisterMotion(slot: UInt8) + func tryGetMotionProvider() -> DSUMotionProvider? +} diff --git a/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/Motion/DSUMotionProviders.swift b/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/Motion/DSUMotionProviders.swift new file mode 100644 index 000000000..601115809 --- /dev/null +++ b/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/Motion/DSUMotionProviders.swift @@ -0,0 +1,178 @@ +// +// DSUMotionProviders.swift +// +// Multi-source Cemuhook-compatible DSU server. +// Created by MediaMoots on 5/17/2025. +// +// + +import CoreMotion +import GameController // GCController + +//──────────────────────────────────────────────────────────────────────── MARK:- Providers + +/// iPhone / iPad IMU +final class DeviceMotionProvider: DSUMotionProvider { + + // ───── DSUMotionProvider conformance + let slot: UInt8 + let mac: [UInt8] = [0xAB,0x12,0xCD,0x34,0xEF,0x56] + let connectionType: UInt8 = 2 + let batteryLevel: UInt8 = 5 + let motionRate: Double = 60.0 // 60 Hz + + // ───── Internals + private let mm = CMMotionManager() + + // Thread Safety + private let dataLock = NSLock() + private var _latest: CMDeviceMotion? + private var latest: CMDeviceMotion? { + get { dataLock.lock(); defer { dataLock.unlock() }; return _latest } + set { dataLock.lock(); _latest = newValue; dataLock.unlock() } + } + + private var orientation: UIDeviceOrientation = + UIDevice.current.orientation == .unknown ? .landscapeLeft : UIDevice.current.orientation + + init(slot: UInt8) { + precondition(slot < 8, "DSU only supports slots 0…7") + self.slot = slot + + // ── start Core Motion + mm.deviceMotionUpdateInterval = 1.0 / motionRate + mm.startDeviceMotionUpdates(to: .main) { [weak self] m, _ in + guard let self = self, let m = m else { return } + self.latest = m + if let sample = self.nextSample() { + DSUServer.shared.pushSample(sample, from: self) + } + } + + // ── track orientation changes (ignore flat) + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + NotificationCenter.default.addObserver( + self, + selector: #selector(orientationDidChange), + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + } + + @objc private func orientationDidChange() { + let o = UIDevice.current.orientation + if o.isFlat { return } // ignore face-up / face-down + orientation = o + } + + func nextSample() -> DSUMotionSample? { + guard let m = latest else { return nil } + + // Raw values + let gx = Float(m.rotationRate.x) + let gy = Float(m.rotationRate.y) + let gz = Float(m.rotationRate.z) + let ax = Float(m.gravity.x + m.userAcceleration.x) + let ay = Float(m.gravity.y + m.userAcceleration.y) + let az = Float(m.gravity.z + m.userAcceleration.z) + + // Rotate axes to match Cemuhook's "landscape-left as neutral" convention + let a: SIMD3 + let g: SIMD3 + + switch orientation { + case .portrait: + a = SIMD3( ax, az, -ay) + g = SIMD3( gx, -gz, gy) + case .landscapeRight: + a = SIMD3( ay, az, ax) + g = SIMD3( gy, -gz, -gx) + case .portraitUpsideDown: + a = SIMD3( -ax, az, ay) + g = SIMD3( -gx, -gz, -gy) + case .landscapeLeft, .unknown, .faceUp, .faceDown: + a = SIMD3( -ay, az, -ax) + g = SIMD3( -gy, -gz, gx) + @unknown default: + return nil + } + + // Convert gyro rad/s → °/s here so the server doesn't have to. + let gDeg = g * (180 / .pi) + + return DSUMotionSample(timestampUS: currentUS(), + accel: a, + gyroDeg: gDeg) + } +} + +// Any Switch Pro / DualSense controller that exposes `GCMotion` +final class ControllerMotionProvider: DSUMotionProvider { + + // DSUMotionProvider + let slot: UInt8 + let mac: [UInt8] = [0xAB,0x12,0xCD,0x34,0xEF,0x56] + let connectionType: UInt8 = 2 + var batteryLevel: UInt8 { + UInt8((pad.battery?.batteryLevel ?? 0.3) * 5).clamped(to: 0...5) + } + + private let pad: GCController + + // Thread Safety + private let dataLock = NSLock() + private var _latest: GCMotion? + private var latest: GCMotion? { + get { dataLock.lock(); defer { dataLock.unlock() }; return _latest } + set { dataLock.lock(); _latest = newValue; dataLock.unlock() } + } + + init(controller: GCController, slot: UInt8) { + self.pad = controller + self.slot = slot + pad.motion?.sensorsActive = true + pad.motion?.valueChangedHandler = { [weak self] motion in + guard let self = self else { return } + self.latest = motion + if let sample = self.nextSample() { + DSUServer.shared.pushSample(sample, from: self) + } + } + } + + func nextSample() -> DSUMotionSample? { + guard let m = latest else { return nil } + + // Extract and convert acceleration to SIMD3 + let a = SIMD3( + Float(m.acceleration.x), + Float(m.acceleration.z), + -Float(m.acceleration.y) + ) + + // Extract, transform, and convert rotation rate to SIMD3 (in radians/s) + let g = SIMD3( + Float(m.rotationRate.x), + -Float(m.rotationRate.z), + Float(m.rotationRate.y) + ) + + // Convert gyro rotation rate from rad/s to degrees/s + let gDeg = g * (180 / .pi) + + return DSUMotionSample( + timestampUS: currentUS(), + accel: a, + gyroDeg: gDeg + ) + } +} + +//──────────────────────────────────────────────────────────────────────── MARK:- Helper funcs / ext + +private func uint64US(_ time: TimeInterval) -> UInt64 { UInt64(time * 1_000_000) } +private func currentUS() -> UInt64 { uint64US(CACurrentMediaTime()) } + +private extension Comparable { + func clamped(to r: ClosedRange) -> Self { min(max(self, r.lowerBound), r.upperBound) } +} diff --git a/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/Motion/DSUServer.swift b/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/Motion/DSUServer.swift new file mode 100644 index 000000000..9947fae5e --- /dev/null +++ b/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/Motion/DSUServer.swift @@ -0,0 +1,217 @@ +// +// DSUServer.swift +// +// Multi-source Cemuhook-compatible DSU server. +// Created by MediaMoots on 5/17/2025. +// +// + +import Foundation +import CocoaAsyncSocket // GCDAsyncUdpSocket +import zlib // CRC-32 + +//──────────────────────────────────────────────────────────────────────── MARK:- DSU Motion protocol + +/// One motion source == one DSU *slot* (0-7). +protocol DSUMotionProvider: AnyObject { + var slot: UInt8 { get } // unique, 0-7 + var mac: [UInt8] { get } // 6-byte ID + var connectionType: UInt8 { get } // 0 = USB, 2 = BT + var batteryLevel: UInt8 { get } // 0-5 (Cemuhook) + + func nextSample() -> DSUMotionSample? +} + +/// Raw motion payload returned by providers. +struct DSUMotionSample { + var timestampUS: UInt64 // µs + var accel: SIMD3 // G's + var gyroDeg: SIMD3 // °/s +} + +//──────────────────────────────────────────────────────────────────────── MARK:- Server constants + +private enum C { + static let port: UInt16 = 26_760 + static let protocolVersion: UInt16 = 1_001 + static let headerMagic = "DSUS" +} + +//──────────────────────────────────────────────────────────────────────── MARK:- Server core + +final class DSUServer: NSObject { + + // Singleton for convenience + static let shared = DSUServer() + private override init() { + serverID = UInt32.random(in: .min ... .max) + super.init() + configureSocket() + } + + // MARK: Public API ───────────────────────────────────────────── + func register(_ provider: DSUMotionProvider) { providers[provider.slot] = provider } + func unregister(slot: UInt8) { providers.removeValue(forKey: slot) } + + /// 🔸 providers push fresh samples here. + func pushSample(_ sample: DSUMotionSample, from provider: DSUMotionProvider) { + guard let addr = lastClientAddress else { return } // no subscriber → drop + sendPadData(sample: sample, from: provider, to: addr) + } + + // MARK: Private + private let serverID: UInt32 + private var socket: GCDAsyncUdpSocket? + private var lastClientAddress: Data? + + private var providers = [UInt8 : DSUMotionProvider]() // slot→provider + private var packetNumber = [UInt8 : UInt32]() // per-slot counter + + // ───────── UDP setup + private func configureSocket() { + socket = GCDAsyncUdpSocket(delegate: self, delegateQueue: .main) + do { + try socket?.bind(toPort: C.port) + try socket?.beginReceiving() + //print("🟢 DSU server listening on UDP \(C.port)") + } catch { + //print("❌ DSU socket error:", error) + } + } +} + +//──────────────────────────────────────────────────────────────────────── MARK:- UDP delegate + +extension DSUServer: GCDAsyncUdpSocketDelegate { + + func udpSocket(_ sock: GCDAsyncUdpSocket, + didReceive data: Data, + fromAddress addr: Data, + withFilterContext ctx: Any?) { + + lastClientAddress = addr + + // Light validation + guard data.count >= 20, + String(decoding: data[0..<4], as: UTF8.self) == C.headerMagic, + data.readUInt16LE(at: 4) == C.protocolVersion + else { return } + + let type = data.readUInt32LE(at: 16) + switch type { + case 0x100001: sendPortInfo(to: addr) // client asks for port list + case 0x100002: break // subscription acknowledged + default: break + } + } + + func udpSocketDidClose(_ sock: GCDAsyncUdpSocket, withError err: Error?) { + //print("UDP closed:", err?.localizedDescription ?? "nil") + lastClientAddress = nil + } +} + +//──────────────────────────────────────────────────────────────────────── MARK:- Packet helpers + +private extension DSUServer { + + // ── Header (16 bytes) + func appendHeader(into d: inout Data, payloadSize: UInt16) { + d.append(C.headerMagic.data(using: .utf8)!) // "DSUS" + d.append(C.protocolVersion.leData) // Protocol Version + d.append(payloadSize.leData) // Payload Size + d.append(Data(repeating: 0, count: 4)) // CRC-stub + d.append(serverID.leData) // Server ID + } + func patchCRC32(of packet: inout Data) { + let crc = packet.withUnsafeBytes { ptr in + crc32(0, ptr.baseAddress, uInt(packet.count)) + }.littleEndian + let crcLE = UInt32(crc).littleEndian + let crcData = withUnsafeBytes(of: crcLE) { Data($0) } + packet.replaceSubrange(8..<12, with: crcData) + } + + // ── 0x100001 DSUSPortInfo + func sendPortInfo(to addr: Data) { + for p in providers.values { + var pkt = Data() + appendHeader(into: &pkt, payloadSize: 12) + pkt.append(UInt32(0x100001).leData) + + pkt.append(p.slot) + pkt.append(UInt8(2)) // connected + pkt.append(UInt8(2)) // full gyro + pkt.append(p.connectionType) + pkt.append(p.mac, count: 6) + pkt.append(p.batteryLevel) + pkt.append(UInt8(0)) // padding + + patchCRC32(of: &pkt) + socket?.send(pkt, toAddress: addr, withTimeout: -1, tag: 0) + } + } + + // ── 0x100002 DSUSPadDataRsp + func sendPadData(sample s: DSUMotionSample, + from p: DSUMotionProvider, + to addr: Data) { + + var pkt = Data() + appendHeader(into: &pkt, payloadSize: 84) + pkt.append(UInt32(0x100002).leData) + + pkt.append(p.slot) + pkt.append(UInt8(2)) // connected + pkt.append(UInt8(2)) // full gyro + pkt.append(p.connectionType) + pkt.append(p.mac, count: 6) + pkt.append(p.batteryLevel) + pkt.append(UInt8(1)) // is connected + + let num = packetNumber[p.slot, default: 0] + pkt.append(num.leData) + packetNumber[p.slot] = num &+ 1 + + pkt.append(UInt16(0).leData) // buttons + pkt.append(contentsOf: [0,0]) // HOME / Touch + pkt.append(contentsOf: [128,128,128,128]) // sticks + pkt.append(Data(repeating: 0, count: 12)) // d-pad / face / trig + pkt.append(Data(repeating: 0, count: 12)) // touch 1 & 2 + pkt.append(s.timestampUS.leData) + + pkt.append(s.accel.x.leData) + pkt.append(s.accel.y.leData) + pkt.append(s.accel.z.leData) + + pkt.append(s.gyroDeg.x.leData) + pkt.append(s.gyroDeg.y.leData) + pkt.append(s.gyroDeg.z.leData) + + patchCRC32(of: &pkt) + socket?.send(pkt, toAddress: addr, withTimeout: -1, tag: 0) + } +} + +//──────────────────────────────────────────────────────────────────────── MARK:- Helper funcs / ext + +private extension FixedWidthInteger { + var leData: Data { + var v = self.littleEndian + return Data(bytes: &v, count: MemoryLayout.size) + } +} +private extension Float { + var leData: Data { + var v = self + return Data(bytes: &v, count: MemoryLayout.size) + } +} +private extension Data { + func readUInt16LE(at offset: Int) -> UInt16 { + self[offset.. UInt32 { + self[offset.. DSUMotionProvider? { + return controllerMotionProvider + } private func setupHandheldController() { if SDL_WasInit(Uint32(SDL_INIT_GAMECONTROLLER)) == 0 { diff --git a/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/VirtualController.swift b/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/VirtualController.swift index 41d5d1c42..923dfc838 100644 --- a/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/VirtualController.swift +++ b/src/MeloNX/MeloNX/App/Core/Ryujinx/Controller/VirtualController.swift @@ -9,11 +9,12 @@ import Foundation import CoreHaptics import UIKit -class VirtualController { +class VirtualController : BaseController { private var instanceID: SDL_JoystickID = -1 private var controller: OpaquePointer? private let hapticEngine: CHHapticEngine? private let rumbleController: RumbleController? + private var deviceMotionProvider: DeviceMotionProvider? public let controllername = "MeloNX Touch Controller" @@ -37,6 +38,20 @@ class VirtualController { setupVirtualController() } + internal func tryRegisterMotion(slot: UInt8) { + // Setup Motion + let dsuServer = DSUServer.shared + + deviceMotionProvider = DeviceMotionProvider(slot: slot) + if let provider = deviceMotionProvider { + dsuServer.register(provider) + } + } + + internal func tryGetMotionProvider() -> DSUMotionProvider? { + return deviceMotionProvider + } + private func setupVirtualController() { if SDL_WasInit(Uint32(SDL_INIT_GAMECONTROLLER)) == 0 { SDL_InitSubSystem(Uint32(SDL_INIT_GAMECONTROLLER)) diff --git a/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift b/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift index 0064bfb63..e345145d3 100644 --- a/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift +++ b/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift @@ -189,6 +189,7 @@ class Ryujinx : ObservableObject { public class Arguments : Observable, Codable, Equatable { var gamepath: String var inputids: [String] + var inputDSUServers: [String] var resscale: Float = 1.0 var debuglogs: Bool = false var tracelogs: Bool = false @@ -217,6 +218,7 @@ class Ryujinx : ObservableObject { init(gamepath: String = "", inputids: [String] = [], + inputDSUServers: [String] = [], debuglogs: Bool = false, tracelogs: Bool = false, listinputids: Bool = false, @@ -244,6 +246,7 @@ class Ryujinx : ObservableObject { ) { self.gamepath = gamepath self.inputids = inputids + self.inputDSUServers = inputDSUServers self.debuglogs = debuglogs self.tracelogs = tracelogs self.listinputids = listinputids @@ -642,6 +645,17 @@ class Ryujinx : ObservableObject { } } + // Append the input dsu servers (limit to 8 (used to be 4) just in case) + if !config.inputDSUServers.isEmpty { + config.inputDSUServers.prefix(8).enumerated().forEach { index, inputDSUServer in + if config.handHeldController { + args.append(contentsOf: ["\(index == 0 ? "--input-dsu-server-handheld" : "--input-dsu-server-\(index + 1)")", inputDSUServer]) + } else { + args.append(contentsOf: ["--input-dsu-server-\(index + 1)", inputDSUServer]) + } + } + } + args.append(contentsOf: config.additionalArgs) return args diff --git a/src/MeloNX/MeloNX/App/Views/Main/UI/ContentView.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/ContentView.swift index 07346860b..272ff3d51 100644 --- a/src/MeloNX/MeloNX/App/Views/Main/UI/ContentView.swift +++ b/src/MeloNX/MeloNX/App/Views/Main/UI/ContentView.swift @@ -338,6 +338,25 @@ struct ContentView: View { } } + private func registerMotionForMatchingControllers() { + // Loop through currentControllers with index + for (index, controller) in currentControllers.enumerated() { + let slot = UInt8(index) + + // Check native controllers + for (_, nativeController) in nativeControllers where nativeController.controllername == String("GC - \(controller.name)") && nativeController.tryGetMotionProvider() == nil { + nativeController.tryRegisterMotion(slot: slot) + continue + } + + // Check virtual controller if active + if Ryujinx.shared.virtualController.controllername == controller.name && Ryujinx.shared.virtualController.tryGetMotionProvider() == nil { + Ryujinx.shared.virtualController.tryRegisterMotion(slot: slot) + continue + } + } + } + private func start(displayid: UInt32) { guard let game else { return } @@ -346,10 +365,17 @@ struct ContentView: View { configureEnvironmentVariables() + registerMotionForMatchingControllers() + if config.inputids.isEmpty { config.inputids.append("0") } + // Local DSU loopback to ryujinx per input id + for _ in config.inputids { + config.inputDSUServers.append("127.0.0.1:26760") + } + do { try ryujinx.start(with: config) } catch { diff --git a/src/Ryujinx.Headless.SDL2/Options.cs b/src/Ryujinx.Headless.SDL2/Options.cs index d45aa0215..e615cc8ce 100644 --- a/src/Ryujinx.Headless.SDL2/Options.cs +++ b/src/Ryujinx.Headless.SDL2/Options.cs @@ -99,6 +99,33 @@ namespace Ryujinx.Headless.SDL2 [Option("input-id-handheld", Required = false, HelpText = "Set the input id in use for the Handheld Player.")] public string InputIdHandheld { get; set; } + [Option("input-dsu-server-1", Required = false, HelpText = "Set the input DSU server:port in use for Player 1.")] + public string InputDSUServer1 { get; set; } + + [Option("input-dsu-server-2", Required = false, HelpText = "Set the input DSU server:port in use for Player 2.")] + public string InputDSUServer2 { get; set; } + + [Option("input-dsu-server-3", Required = false, HelpText = "Set the input DSU server:port in use for Player 3.")] + public string InputDSUServer3 { get; set; } + + [Option("input-dsu-server-4", Required = false, HelpText = "Set the input DSU server:port in use for Player 4.")] + public string InputDSUServer4 { get; set; } + + [Option("input-dsu-server-5", Required = false, HelpText = "Set the input DSU server:port in use for Player 5.")] + public string InputDSUServer5 { get; set; } + + [Option("input-dsu-server-6", Required = false, HelpText = "Set the input DSU server:port in use for Player 6.")] + public string InputDSUServer6 { get; set; } + + [Option("input-dsu-server-7", Required = false, HelpText = "Set the input DSU server:port in use for Player 7.")] + public string InputDSUServer7 { get; set; } + + [Option("input-dsu-server-8", Required = false, HelpText = "Set the input DSU server:port in use for Player 8.")] + public string InputDSUServer8 { get; set; } + + [Option("input-dsu-server-handheld", Required = false, HelpText = "Set the input DSU server:port in use for the Handheld Player.")] + public string InputDSUServerHandheld { get; set; } + [Option("enable-keyboard", Required = false, Default = false, HelpText = "Enable or disable keyboard support (Independent from controllers binding).")] public bool EnableKeyboard { get; set; } diff --git a/src/Ryujinx.Headless.SDL2/Program.cs b/src/Ryujinx.Headless.SDL2/Program.cs index 314965067..b1e01caa0 100644 --- a/src/Ryujinx.Headless.SDL2/Program.cs +++ b/src/Ryujinx.Headless.SDL2/Program.cs @@ -894,7 +894,7 @@ namespace Ryujinx.Headless.SDL2 return gameInfo; } - private static InputConfig HandlePlayerConfiguration(string inputProfileName, string inputId, PlayerIndex index, Options option) + private static InputConfig HandlePlayerConfiguration(string inputProfileName, string inputId, string inputDSUServer, PlayerIndex index, Options option) { if (inputId == null) { @@ -1074,6 +1074,28 @@ namespace Ryujinx.Headless.SDL2 EnableRumble = true, }, }; + + // Setup DSU Motion + if (config is StandardControllerInputConfig standardConfig && !string.IsNullOrWhiteSpace(inputDSUServer)) + { + var serverString = inputDSUServer.Trim(); + + var parts = serverString.Split(new[] { ':' }, 2); + if (parts.Length == 2 && int.TryParse(parts[1], out var port)) + { + var slot = index == PlayerIndex.Handheld ? 0 : (int)index; + standardConfig.Motion = new CemuHookMotionConfigController + { + MotionBackend = MotionInputBackendType.CemuHook, + EnableMotion = true, + Sensitivity = 100, + GyroDeadzone = 1, + Slot = slot, + DsuServerHost = parts[0], + DsuServerPort = port, + }; + } + } } } else @@ -1218,9 +1240,9 @@ namespace Ryujinx.Headless.SDL2 _enableKeyboard = option.EnableKeyboard; _enableMouse = option.EnableMouse; - static void LoadPlayerConfiguration(string inputProfileName, string inputId, PlayerIndex index, Options option) + static void LoadPlayerConfiguration(string inputProfileName, string inputId, string inputDSUServer, PlayerIndex index, Options option) { - InputConfig inputConfig = HandlePlayerConfiguration(inputProfileName, inputId, index, option); + InputConfig inputConfig = HandlePlayerConfiguration(inputProfileName, inputId, inputDSUServer, index, option); if (inputConfig != null) { @@ -1228,15 +1250,15 @@ namespace Ryujinx.Headless.SDL2 } } - LoadPlayerConfiguration(option.InputProfile1Name, option.InputId1, PlayerIndex.Player1, option); - LoadPlayerConfiguration(option.InputProfile2Name, option.InputId2, PlayerIndex.Player2, option); - LoadPlayerConfiguration(option.InputProfile3Name, option.InputId3, PlayerIndex.Player3, option); - LoadPlayerConfiguration(option.InputProfile4Name, option.InputId4, PlayerIndex.Player4, option); - LoadPlayerConfiguration(option.InputProfile5Name, option.InputId5, PlayerIndex.Player5, option); - LoadPlayerConfiguration(option.InputProfile6Name, option.InputId6, PlayerIndex.Player6, option); - LoadPlayerConfiguration(option.InputProfile7Name, option.InputId7, PlayerIndex.Player7, option); - LoadPlayerConfiguration(option.InputProfile8Name, option.InputId8, PlayerIndex.Player8, option); - LoadPlayerConfiguration(option.InputProfileHandheldName, option.InputIdHandheld, PlayerIndex.Handheld, option); + LoadPlayerConfiguration(option.InputProfile1Name, option.InputId1, option.InputDSUServer1, PlayerIndex.Player1, option); + LoadPlayerConfiguration(option.InputProfile2Name, option.InputId2, option.InputDSUServer2, PlayerIndex.Player2, option); + LoadPlayerConfiguration(option.InputProfile3Name, option.InputId3, option.InputDSUServer3, PlayerIndex.Player3, option); + LoadPlayerConfiguration(option.InputProfile4Name, option.InputId4, option.InputDSUServer4, PlayerIndex.Player4, option); + LoadPlayerConfiguration(option.InputProfile5Name, option.InputId5, option.InputDSUServer5, PlayerIndex.Player5, option); + LoadPlayerConfiguration(option.InputProfile6Name, option.InputId6, option.InputDSUServer6, PlayerIndex.Player6, option); + LoadPlayerConfiguration(option.InputProfile7Name, option.InputId7, option.InputDSUServer7, PlayerIndex.Player7, option); + LoadPlayerConfiguration(option.InputProfile8Name, option.InputId8, option.InputDSUServer8, PlayerIndex.Player8, option); + LoadPlayerConfiguration(option.InputProfileHandheldName, option.InputIdHandheld, option.InputDSUServerHandheld, PlayerIndex.Handheld, option); if (_inputConfiguration.Count == 0) {