mirror of
https://git.743378673.xyz/MeloNX/MeloNX.git
synced 2025-06-27 19:06:23 +02:00
Merge branch 'XC-ios-ht' into rumbleDev
This commit is contained in:
commit
54ef5018e0
11 changed files with 569 additions and 15 deletions
|
@ -24,6 +24,7 @@
|
||||||
/* End PBXAggregateTarget section */
|
/* End PBXAggregateTarget section */
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
4549A31C2DD8795900EC8D88 /* CocoaAsyncSocket in Frameworks */ = {isa = PBXBuildFile; productRef = 4549A31B2DD8795900EC8D88 /* CocoaAsyncSocket */; };
|
||||||
4E12B23C2D797CFA00FB2271 /* MeloNX.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 4E12B23B2D797CFA00FB2271 /* MeloNX.xcconfig */; };
|
4E12B23C2D797CFA00FB2271 /* MeloNX.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 4E12B23B2D797CFA00FB2271 /* MeloNX.xcconfig */; };
|
||||||
4E8A80772D5FDD2D0041B48F /* GameController.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E80AA622CD7122800029585 /* GameController.framework */; };
|
4E8A80772D5FDD2D0041B48F /* GameController.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E80AA622CD7122800029585 /* GameController.framework */; };
|
||||||
4EA5AE822D16807500AD0B9F /* SwiftSVG in Frameworks */ = {isa = PBXBuildFile; productRef = 4EA5AE812D16807500AD0B9F /* SwiftSVG */; };
|
4EA5AE822D16807500AD0B9F /* SwiftSVG in Frameworks */ = {isa = PBXBuildFile; productRef = 4EA5AE812D16807500AD0B9F /* SwiftSVG */; };
|
||||||
|
@ -203,6 +204,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
CA8F9C322D3F5AB200D7E586 /* GameController.framework in Frameworks */,
|
CA8F9C322D3F5AB200D7E586 /* GameController.framework in Frameworks */,
|
||||||
|
4549A31C2DD8795900EC8D88 /* CocoaAsyncSocket in Frameworks */,
|
||||||
4EA5AE822D16807500AD0B9F /* SwiftSVG in Frameworks */,
|
4EA5AE822D16807500AD0B9F /* SwiftSVG in Frameworks */,
|
||||||
4E8A80772D5FDD2D0041B48F /* GameController.framework in Frameworks */,
|
4E8A80772D5FDD2D0041B48F /* GameController.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
|
@ -300,6 +302,7 @@
|
||||||
name = MeloNX;
|
name = MeloNX;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
4EA5AE812D16807500AD0B9F /* SwiftSVG */,
|
4EA5AE812D16807500AD0B9F /* SwiftSVG */,
|
||||||
|
4549A31B2DD8795900EC8D88 /* CocoaAsyncSocket */,
|
||||||
);
|
);
|
||||||
productName = MeloNX;
|
productName = MeloNX;
|
||||||
productReference = 4E80A98D2CD6F54500029585 /* MeloNX.app */;
|
productReference = 4E80A98D2CD6F54500029585 /* MeloNX.app */;
|
||||||
|
@ -391,6 +394,7 @@
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
4EA5AE802D16807500AD0B9F /* XCRemoteSwiftPackageReference "SwiftSVG" */,
|
4EA5AE802D16807500AD0B9F /* XCRemoteSwiftPackageReference "SwiftSVG" */,
|
||||||
|
4549A31A2DD8795900EC8D88 /* XCRemoteSwiftPackageReference "CocoaAsyncSocket" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 56;
|
preferredProjectObjectVersion = 56;
|
||||||
productRefGroup = 4E80A98E2CD6F54500029585 /* Products */;
|
productRefGroup = 4E80A98E2CD6F54500029585 /* Products */;
|
||||||
|
@ -1569,6 +1573,14 @@
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference 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" */ = {
|
4EA5AE802D16807500AD0B9F /* XCRemoteSwiftPackageReference "SwiftSVG" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/mchoe/SwiftSVG";
|
repositoryURL = "https://github.com/mchoe/SwiftSVG";
|
||||||
|
@ -1580,6 +1592,11 @@
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
4549A31B2DD8795900EC8D88 /* CocoaAsyncSocket */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 4549A31A2DD8795900EC8D88 /* XCRemoteSwiftPackageReference "CocoaAsyncSocket" */;
|
||||||
|
productName = CocoaAsyncSocket;
|
||||||
|
};
|
||||||
4EA5AE812D16807500AD0B9F /* SwiftSVG */ = {
|
4EA5AE812D16807500AD0B9F /* SwiftSVG */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 4EA5AE802D16807500AD0B9F /* XCRemoteSwiftPackageReference "SwiftSVG" */;
|
package = 4EA5AE802D16807500AD0B9F /* XCRemoteSwiftPackageReference "SwiftSVG" */;
|
||||||
|
|
|
@ -1,6 +1,15 @@
|
||||||
{
|
{
|
||||||
"originHash" : "fedf09a893a63378a2e53f631cd833ae83a0c9ee7338eb8d153b04fd34aaf805",
|
"originHash" : "b4a593815773c4e9eedb98cabe88f41620776314bffb6c39d5a41cb743e4d390",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "cocoaasyncsocket",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/robbiehanson/CocoaAsyncSocket",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "dbdc00669c1ced63b27c3c5f052ee4d28f10150c",
|
||||||
|
"version" : "7.6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swiftsvg",
|
"identity" : "swiftsvg",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|
|
@ -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?
|
||||||
|
}
|
|
@ -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 CoreHaptics
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
class NativeController: Hashable {
|
class NativeController: Hashable, BaseController {
|
||||||
private var instanceID: SDL_JoystickID = -1
|
private var instanceID: SDL_JoystickID = -1
|
||||||
private var controller: OpaquePointer?
|
private var controller: OpaquePointer?
|
||||||
private var nativeController: GCController
|
private var nativeController: GCController
|
||||||
|
private var controllerMotionProvider: ControllerMotionProvider?
|
||||||
private let controllerHaptics: CHHapticEngine?
|
private let controllerHaptics: CHHapticEngine?
|
||||||
private let rumbleController: RumbleController?
|
private let rumbleController: RumbleController?
|
||||||
|
|
||||||
|
@ -42,6 +43,20 @@ class NativeController: Hashable {
|
||||||
deinit {
|
deinit {
|
||||||
cleanup()
|
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() {
|
private func setupHandheldController() {
|
||||||
if SDL_WasInit(Uint32(SDL_INIT_GAMECONTROLLER)) == 0 {
|
if SDL_WasInit(Uint32(SDL_INIT_GAMECONTROLLER)) == 0 {
|
||||||
|
|
|
@ -9,11 +9,12 @@ import Foundation
|
||||||
import CoreHaptics
|
import CoreHaptics
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class VirtualController {
|
class VirtualController : BaseController {
|
||||||
private var instanceID: SDL_JoystickID = -1
|
private var instanceID: SDL_JoystickID = -1
|
||||||
private var controller: OpaquePointer?
|
private var controller: OpaquePointer?
|
||||||
private let hapticEngine: CHHapticEngine?
|
private let hapticEngine: CHHapticEngine?
|
||||||
private let rumbleController: RumbleController?
|
private let rumbleController: RumbleController?
|
||||||
|
private var deviceMotionProvider: DeviceMotionProvider?
|
||||||
|
|
||||||
public let controllername = "MeloNX Touch Controller"
|
public let controllername = "MeloNX Touch Controller"
|
||||||
|
|
||||||
|
@ -37,6 +38,20 @@ class VirtualController {
|
||||||
setupVirtualController()
|
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() {
|
private func setupVirtualController() {
|
||||||
if SDL_WasInit(Uint32(SDL_INIT_GAMECONTROLLER)) == 0 {
|
if SDL_WasInit(Uint32(SDL_INIT_GAMECONTROLLER)) == 0 {
|
||||||
SDL_InitSubSystem(Uint32(SDL_INIT_GAMECONTROLLER))
|
SDL_InitSubSystem(Uint32(SDL_INIT_GAMECONTROLLER))
|
||||||
|
|
|
@ -189,6 +189,7 @@ class Ryujinx : ObservableObject {
|
||||||
public class Arguments : Observable, Codable, Equatable {
|
public class Arguments : Observable, Codable, Equatable {
|
||||||
var gamepath: String
|
var gamepath: String
|
||||||
var inputids: [String]
|
var inputids: [String]
|
||||||
|
var inputDSUServers: [String]
|
||||||
var resscale: Float = 1.0
|
var resscale: Float = 1.0
|
||||||
var debuglogs: Bool = false
|
var debuglogs: Bool = false
|
||||||
var tracelogs: Bool = false
|
var tracelogs: Bool = false
|
||||||
|
@ -217,6 +218,7 @@ class Ryujinx : ObservableObject {
|
||||||
|
|
||||||
init(gamepath: String = "",
|
init(gamepath: String = "",
|
||||||
inputids: [String] = [],
|
inputids: [String] = [],
|
||||||
|
inputDSUServers: [String] = [],
|
||||||
debuglogs: Bool = false,
|
debuglogs: Bool = false,
|
||||||
tracelogs: Bool = false,
|
tracelogs: Bool = false,
|
||||||
listinputids: Bool = false,
|
listinputids: Bool = false,
|
||||||
|
@ -244,6 +246,7 @@ class Ryujinx : ObservableObject {
|
||||||
) {
|
) {
|
||||||
self.gamepath = gamepath
|
self.gamepath = gamepath
|
||||||
self.inputids = inputids
|
self.inputids = inputids
|
||||||
|
self.inputDSUServers = inputDSUServers
|
||||||
self.debuglogs = debuglogs
|
self.debuglogs = debuglogs
|
||||||
self.tracelogs = tracelogs
|
self.tracelogs = tracelogs
|
||||||
self.listinputids = listinputids
|
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)
|
args.append(contentsOf: config.additionalArgs)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
|
@ -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) {
|
private func start(displayid: UInt32) {
|
||||||
guard let game else { return }
|
guard let game else { return }
|
||||||
|
|
||||||
|
@ -346,10 +365,17 @@ struct ContentView: View {
|
||||||
|
|
||||||
configureEnvironmentVariables()
|
configureEnvironmentVariables()
|
||||||
|
|
||||||
|
registerMotionForMatchingControllers()
|
||||||
|
|
||||||
if config.inputids.isEmpty {
|
if config.inputids.isEmpty {
|
||||||
config.inputids.append("0")
|
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 {
|
do {
|
||||||
try ryujinx.start(with: config)
|
try ryujinx.start(with: config)
|
||||||
} catch {
|
} 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.")]
|
[Option("input-id-handheld", Required = false, HelpText = "Set the input id in use for the Handheld Player.")]
|
||||||
public string InputIdHandheld { get; set; }
|
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).")]
|
[Option("enable-keyboard", Required = false, Default = false, HelpText = "Enable or disable keyboard support (Independent from controllers binding).")]
|
||||||
public bool EnableKeyboard { get; set; }
|
public bool EnableKeyboard { get; set; }
|
||||||
|
|
||||||
|
|
|
@ -894,7 +894,7 @@ namespace Ryujinx.Headless.SDL2
|
||||||
return gameInfo;
|
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)
|
if (inputId == null)
|
||||||
{
|
{
|
||||||
|
@ -1074,6 +1074,28 @@ namespace Ryujinx.Headless.SDL2
|
||||||
EnableRumble = true,
|
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
|
else
|
||||||
|
@ -1218,9 +1240,9 @@ namespace Ryujinx.Headless.SDL2
|
||||||
_enableKeyboard = option.EnableKeyboard;
|
_enableKeyboard = option.EnableKeyboard;
|
||||||
_enableMouse = option.EnableMouse;
|
_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)
|
if (inputConfig != null)
|
||||||
{
|
{
|
||||||
|
@ -1228,15 +1250,15 @@ namespace Ryujinx.Headless.SDL2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadPlayerConfiguration(option.InputProfile1Name, option.InputId1, PlayerIndex.Player1, option);
|
LoadPlayerConfiguration(option.InputProfile1Name, option.InputId1, option.InputDSUServer1, PlayerIndex.Player1, option);
|
||||||
LoadPlayerConfiguration(option.InputProfile2Name, option.InputId2, PlayerIndex.Player2, option);
|
LoadPlayerConfiguration(option.InputProfile2Name, option.InputId2, option.InputDSUServer2, PlayerIndex.Player2, option);
|
||||||
LoadPlayerConfiguration(option.InputProfile3Name, option.InputId3, PlayerIndex.Player3, option);
|
LoadPlayerConfiguration(option.InputProfile3Name, option.InputId3, option.InputDSUServer3, PlayerIndex.Player3, option);
|
||||||
LoadPlayerConfiguration(option.InputProfile4Name, option.InputId4, PlayerIndex.Player4, option);
|
LoadPlayerConfiguration(option.InputProfile4Name, option.InputId4, option.InputDSUServer4, PlayerIndex.Player4, option);
|
||||||
LoadPlayerConfiguration(option.InputProfile5Name, option.InputId5, PlayerIndex.Player5, option);
|
LoadPlayerConfiguration(option.InputProfile5Name, option.InputId5, option.InputDSUServer5, PlayerIndex.Player5, option);
|
||||||
LoadPlayerConfiguration(option.InputProfile6Name, option.InputId6, PlayerIndex.Player6, option);
|
LoadPlayerConfiguration(option.InputProfile6Name, option.InputId6, option.InputDSUServer6, PlayerIndex.Player6, option);
|
||||||
LoadPlayerConfiguration(option.InputProfile7Name, option.InputId7, PlayerIndex.Player7, option);
|
LoadPlayerConfiguration(option.InputProfile7Name, option.InputId7, option.InputDSUServer7, PlayerIndex.Player7, option);
|
||||||
LoadPlayerConfiguration(option.InputProfile8Name, option.InputId8, PlayerIndex.Player8, option);
|
LoadPlayerConfiguration(option.InputProfile8Name, option.InputId8, option.InputDSUServer8, PlayerIndex.Player8, option);
|
||||||
LoadPlayerConfiguration(option.InputProfileHandheldName, option.InputIdHandheld, PlayerIndex.Handheld, option);
|
LoadPlayerConfiguration(option.InputProfileHandheldName, option.InputIdHandheld, option.InputDSUServerHandheld, PlayerIndex.Handheld, option);
|
||||||
|
|
||||||
if (_inputConfiguration.Count == 0)
|
if (_inputConfiguration.Count == 0)
|
||||||
{
|
{
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue