mirror of
https://git.743378673.xyz/MeloNX/MeloNX.git
synced 2025-06-27 19:06:23 +02:00
motion implementation
This commit is contained in:
parent
bff023563b
commit
29997c46e4
11 changed files with 604 additions and 15 deletions
|
@ -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 */; };
|
||||
|
@ -198,6 +199,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CA8F9C322D3F5AB200D7E586 /* GameController.framework in Frameworks */,
|
||||
4549A31C2DD8795900EC8D88 /* CocoaAsyncSocket in Frameworks */,
|
||||
4EA5AE822D16807500AD0B9F /* SwiftSVG in Frameworks */,
|
||||
4E8A80772D5FDD2D0041B48F /* GameController.framework in Frameworks */,
|
||||
);
|
||||
|
@ -295,6 +297,7 @@
|
|||
name = MeloNX;
|
||||
packageProductDependencies = (
|
||||
4EA5AE812D16807500AD0B9F /* SwiftSVG */,
|
||||
4549A31B2DD8795900EC8D88 /* CocoaAsyncSocket */,
|
||||
);
|
||||
productName = MeloNX;
|
||||
productReference = 4E80A98D2CD6F54500029585 /* MeloNX.app */;
|
||||
|
@ -386,6 +389,7 @@
|
|||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
4EA5AE802D16807500AD0B9F /* XCRemoteSwiftPackageReference "SwiftSVG" */,
|
||||
4549A31A2DD8795900EC8D88 /* XCRemoteSwiftPackageReference "CocoaAsyncSocket" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 56;
|
||||
productRefGroup = 4E80A98E2CD6F54500029585 /* Products */;
|
||||
|
@ -730,6 +734,11 @@
|
|||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
);
|
||||
GCC_OPTIMIZATION_LEVEL = z;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
@ -916,6 +925,18 @@
|
|||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
);
|
||||
MARKETING_VERSION = "$(VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX;
|
||||
|
@ -1026,6 +1047,12 @@
|
|||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
);
|
||||
GCC_OPTIMIZATION_LEVEL = z;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
@ -1212,6 +1239,18 @@
|
|||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
"$(PROJECT_DIR)/MeloNX/Dependencies/Dynamic\\ Libraries",
|
||||
);
|
||||
MARKETING_VERSION = "$(VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stossy11.MeloNX;
|
||||
|
@ -1413,6 +1452,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";
|
||||
|
@ -1424,6 +1471,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" */;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// BaseController.swift
|
||||
// MeloNX
|
||||
//
|
||||
// Created by MediaMoots on 5/17/2025.
|
||||
//
|
||||
|
||||
//──────────────────────────────────────────────────────────────────────── MARK:- Base Controller Protocol
|
||||
|
||||
/// One motion source == one DSU *slot* (0-7).
|
||||
protocol BaseController: AnyObject {
|
||||
func tryRegisterMotion(slot: UInt8)
|
||||
func tryGetMotionProvider() -> DSUMotionProvider?
|
||||
}
|
|
@ -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<Float>
|
||||
let g: SIMD3<Float>
|
||||
|
||||
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<Float>
|
||||
let a = SIMD3<Float>(
|
||||
Float(m.acceleration.x),
|
||||
Float(m.acceleration.z),
|
||||
-Float(m.acceleration.y)
|
||||
)
|
||||
|
||||
// Extract, transform, and convert rotation rate to SIMD3<Float> (in radians/s)
|
||||
let g = SIMD3<Float>(
|
||||
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>) -> Self { min(max(self, r.lowerBound), r.upperBound) }
|
||||
}
|
|
@ -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<Float> // G's
|
||||
var gyroDeg: SIMD3<Float> // °/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<Self>.size)
|
||||
}
|
||||
}
|
||||
private extension Float {
|
||||
var leData: Data {
|
||||
var v = self
|
||||
return Data(bytes: &v, count: MemoryLayout<Self>.size)
|
||||
}
|
||||
}
|
||||
private extension Data {
|
||||
func readUInt16LE(at offset: Int) -> UInt16 {
|
||||
self[offset..<offset+2].withUnsafeBytes { $0.load(as: UInt16.self) }.littleEndian
|
||||
}
|
||||
func readUInt32LE(at offset: Int) -> UInt32 {
|
||||
self[offset..<offset+4].withUnsafeBytes { $0.load(as: UInt32.self) }.littleEndian
|
||||
}
|
||||
}
|
|
@ -8,10 +8,11 @@
|
|||
import CoreHaptics
|
||||
import GameController
|
||||
|
||||
class NativeController: Hashable {
|
||||
class NativeController: Hashable, BaseController {
|
||||
private var instanceID: SDL_JoystickID = -1
|
||||
private var controller: OpaquePointer?
|
||||
private var nativeController: GCController
|
||||
private var controllerMotionProvider: ControllerMotionProvider?
|
||||
private let controllerHaptics: CHHapticEngine?
|
||||
|
||||
public var controllername: String { "GC - \(nativeController.vendorName ?? "Unknown")" }
|
||||
|
@ -26,6 +27,20 @@ class NativeController: Hashable {
|
|||
deinit {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
internal func tryRegisterMotion(slot: UInt8) {
|
||||
// Setup Motion
|
||||
let dsuServer = DSUServer.shared
|
||||
|
||||
controllerMotionProvider = ControllerMotionProvider(controller: nativeController, slot: slot)
|
||||
if let provider = controllerMotionProvider {
|
||||
dsuServer.register(provider)
|
||||
}
|
||||
}
|
||||
|
||||
internal func tryGetMotionProvider() -> DSUMotionProvider? {
|
||||
return controllerMotionProvider
|
||||
}
|
||||
|
||||
private func setupHandheldController() {
|
||||
if SDL_WasInit(Uint32(SDL_INIT_GAMECONTROLLER)) == 0 {
|
||||
|
|
|
@ -9,9 +9,10 @@ import Foundation
|
|||
import CoreHaptics
|
||||
import UIKit
|
||||
|
||||
class VirtualController {
|
||||
class VirtualController : BaseController {
|
||||
private var instanceID: SDL_JoystickID = -1
|
||||
private var controller: OpaquePointer?
|
||||
private var deviceMotionProvider: DeviceMotionProvider?
|
||||
|
||||
public let controllername = "MeloNX Touch Controller"
|
||||
|
||||
|
@ -19,6 +20,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))
|
||||
|
|
|
@ -166,6 +166,7 @@ class Ryujinx : ObservableObject {
|
|||
public struct Configuration : Codable, Equatable {
|
||||
var gamepath: String
|
||||
var inputids: [String]
|
||||
var inputDSUServers: [String]
|
||||
var resscale: Float
|
||||
var debuglogs: Bool
|
||||
var tracelogs: Bool
|
||||
|
@ -193,6 +194,7 @@ class Ryujinx : ObservableObject {
|
|||
|
||||
init(gamepath: String,
|
||||
inputids: [String] = [],
|
||||
inputDSUServers: [String] = [],
|
||||
debuglogs: Bool = false,
|
||||
tracelogs: Bool = false,
|
||||
listinputids: Bool = false,
|
||||
|
@ -219,6 +221,7 @@ class Ryujinx : ObservableObject {
|
|||
) {
|
||||
self.gamepath = gamepath
|
||||
self.inputids = inputids
|
||||
self.inputDSUServers = inputDSUServers
|
||||
self.debuglogs = debuglogs
|
||||
self.tracelogs = tracelogs
|
||||
self.listinputids = listinputids
|
||||
|
@ -554,6 +557,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
|
||||
|
|
|
@ -332,6 +332,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 }
|
||||
|
||||
|
@ -340,10 +359,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 {
|
||||
|
|
|
@ -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; }
|
||||
|
||||
|
|
|
@ -893,7 +893,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)
|
||||
{
|
||||
|
@ -1073,6 +1073,28 @@ namespace Ryujinx.Headless.SDL2
|
|||
EnableRumble = false,
|
||||
},
|
||||
};
|
||||
|
||||
// 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
|
||||
|
@ -1212,9 +1234,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)
|
||||
{
|
||||
|
@ -1222,15 +1244,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)
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue