From bd306ba07f8a5864d69ec8411b5338ebd62ff48f Mon Sep 17 00:00:00 2001 From: True Zhuanjia Date: Tue, 25 Aug 2026 00:30:31 +0800 Subject: [PATCH] gameSirG8MFi rumbler ready --- VoidLink.xcodeproj/project.pbxproj | 10 +- VoidLink/Input/ControllerSupport.m | 69 ++-- VoidLink/Input/ControllerUtil.swift | 5 + VoidLink/Input/GameSirG8MFiRumble.h | 23 -- VoidLink/Input/GameSirG8MFiRumble.m | 299 --------------- VoidLink/Input/GameSirG8MFiRumbler.swift | 456 +++++++++++++++++++++++ VoidLink/Input/VoidController.h | 4 + 7 files changed, 503 insertions(+), 363 deletions(-) delete mode 100644 VoidLink/Input/GameSirG8MFiRumble.h delete mode 100644 VoidLink/Input/GameSirG8MFiRumble.m create mode 100644 VoidLink/Input/GameSirG8MFiRumbler.swift diff --git a/VoidLink.xcodeproj/project.pbxproj b/VoidLink.xcodeproj/project.pbxproj index a855910c..460b98b2 100644 --- a/VoidLink.xcodeproj/project.pbxproj +++ b/VoidLink.xcodeproj/project.pbxproj @@ -20,7 +20,7 @@ 1850C82A2E667E4000845308 /* MicHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850C8292E667E4000845308 /* MicHandler.swift */; }; 18729EDE2E8BD68800101194 /* UUIDHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18729EDD2E8BD68800101194 /* UUIDHelper.swift */; }; 18780CC72F10D66000B90483 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 18780CC62F10D66000B90483 /* StoreKit.framework */; }; - A76B00032FFCC00100000003 /* GameSirG8MFiRumble.m in Sources */ = {isa = PBXBuildFile; fileRef = A76B00022FFCC00100000002 /* GameSirG8MFiRumble.m */; }; + A76B00032FFCC00100000003 /* GameSirG8MFiRumbler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B00022FFCC00100000002 /* GameSirG8MFiRumbler.swift */; }; A76B00052FFCC00100000005 /* ExternalAccessory.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A76B00042FFCC00100000004 /* ExternalAccessory.framework */; }; 18780CCA2F10D6CC00B90483 /* IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18780CC92F10D6CC00B90483 /* IAPManager.swift */; }; 187F4B282E04658A00EECC59 /* WidgetPanelStackView.m in Sources */ = {isa = PBXBuildFile; fileRef = 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */; }; @@ -202,8 +202,7 @@ 1850C8292E667E4000845308 /* MicHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicHandler.swift; sourceTree = ""; }; 18729EDD2E8BD68800101194 /* UUIDHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDHelper.swift; sourceTree = ""; }; 18780CC62F10D66000B90483 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; - A76B00012FFCC00100000001 /* GameSirG8MFiRumble.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GameSirG8MFiRumble.h; sourceTree = ""; }; - A76B00022FFCC00100000002 /* GameSirG8MFiRumble.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GameSirG8MFiRumble.m; sourceTree = ""; }; + A76B00022FFCC00100000002 /* GameSirG8MFiRumbler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameSirG8MFiRumbler.swift; sourceTree = ""; }; A76B00042FFCC00100000004 /* ExternalAccessory.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ExternalAccessory.framework; path = System/Library/Frameworks/ExternalAccessory.framework; sourceTree = SDKROOT; }; 18780CC92F10D6CC00B90483 /* IAPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPManager.swift; sourceTree = ""; }; 187F4B232E04633100EECC59 /* WidgetPanelStackView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WidgetPanelStackView.h; sourceTree = ""; }; @@ -757,8 +756,7 @@ isa = PBXGroup; children = ( 84351B052C29ACAF00A77989 /* CustomOSC */, - A76B00012FFCC00100000001 /* GameSirG8MFiRumble.h */, - A76B00022FFCC00100000002 /* GameSirG8MFiRumble.m */, + A76B00022FFCC00100000002 /* GameSirG8MFiRumbler.swift */, FB89460A19F646E200339C8A /* ControllerSupport.h */, FB89460B19F646E200339C8A /* ControllerSupport.m */, FB89460C19F646E200339C8A /* StreamView.h */, @@ -1252,7 +1250,7 @@ FBD3494319FC9C04002D2A60 /* AppAssetManager.m in Sources */, FB6549561A57907E001C8F39 /* DiscoveryWorker.m in Sources */, 84351B0E2C29ACCB00A77989 /* LayoutOnScreenControls.m in Sources */, - A76B00032FFCC00100000003 /* GameSirG8MFiRumble.m in Sources */, + A76B00032FFCC00100000003 /* GameSirG8MFiRumbler.swift in Sources */, FB89462A19F646E200339C8A /* ControllerSupport.m in Sources */, FB9AFD3D1A7E111600872C98 /* AppAssetResponse.m in Sources */, 1888C1922E24C33100108730 /* AboutViewController.swift in Sources */, diff --git a/VoidLink/Input/ControllerSupport.m b/VoidLink/Input/ControllerSupport.m index fabfa5cc..cae6359f 100644 --- a/VoidLink/Input/ControllerSupport.m +++ b/VoidLink/Input/ControllerSupport.m @@ -10,7 +10,6 @@ // #import "ControllerSupport.h" -#import "GameSirG8MFiRumble.h" #import "OnScreenControls.h" #import "VoidController.h" #import "VoidLink-Swift.h" @@ -120,7 +119,7 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger, OSCProfile* oscProfile; OSCProfilesManager* oscProfileMan; #if !TARGET_OS_TV - GameSirG8MFiRumble *_gameSirG8MFiRumble; + GameSirG8MFiRumbler *_gameSirG8MFiRumbler; #endif #define EMULATING_SELECT 0x1 @@ -159,6 +158,20 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger, #define MAX_MAGNITUDE(x, y) (abs(x) > abs(y) ? (x) : (y)) +-(void) applyPhysicalControllerRumble:(VoidController*)controller lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor +{ +#if !TARGET_OS_TV + if (controller.hardware == ControllerHardwareG8PlusMFi) { + NSLog(@"[G8Rumble] route native rumble low=%hu high=%hu", lowFreqMotor, highFreqMotor); + [_gameSirG8MFiRumbler setLowFrequencyMotor:lowFreqMotor highFrequencyMotor:highFreqMotor]; + return; + } +#endif + + [controller.lowFreqMotor setMotorAmplitude:lowFreqMotor]; + [controller.highFreqMotor setMotorAmplitude:highFreqMotor]; +} + -(void) rumble:(unsigned short)controllerNumber lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor { VoidController* voidController = [_voidControllers objectForKey:[NSNumber numberWithInteger:controllerNumber]]; @@ -178,48 +191,29 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger, return; } -#if !TARGET_OS_TV - BOOL useGameSirG8MFiRumble = [_gameSirG8MFiRumble canHandleController:voidController.gamepad]; -#endif + NSLog(@"[G8Rumble] rumble request controller=%hu low=%hu high=%hu preference=%ld vendor=%@", + controllerNumber, + lowFreqMotor, + highFreqMotor, + (long)preference, + voidController.gamepad.vendorName); // physical controller connected: switch (preference) { case HapticEngineAuto: -#if !TARGET_OS_TV - if (useGameSirG8MFiRumble) { - [_gameSirG8MFiRumble setLowFrequencyMotor:lowFreqMotor highFrequencyMotor:highFreqMotor]; - break; - } -#endif // if controller has no haptic profile, it already falled bakc to device engine - [voidController.lowFreqMotor setMotorAmplitude:lowFreqMotor]; - [voidController.highFreqMotor setMotorAmplitude:highFreqMotor]; + [self applyPhysicalControllerRumble:voidController lowFreqMotor:lowFreqMotor highFreqMotor:highFreqMotor]; break; case RumbleDevice: -#if !TARGET_OS_TV - if (useGameSirG8MFiRumble) { - [_gameSirG8MFiRumble setLowFrequencyMotor:0 highFrequencyMotor:0]; - } -#endif + [self applyPhysicalControllerRumble:voidController lowFreqMotor:0 highFreqMotor:0]; [_oscController.lowFreqMotor setMotorAmplitude:lowFreqMotor]; [_oscController.highFreqMotor setMotorAmplitude:highFreqMotor]; break; case LeftRightSwapped: -#if !TARGET_OS_TV - if (useGameSirG8MFiRumble) { - [_gameSirG8MFiRumble setLowFrequencyMotor:highFreqMotor highFrequencyMotor:lowFreqMotor]; - break; - } -#endif - [voidController.lowFreqMotor setMotorAmplitude:highFreqMotor]; - [voidController.highFreqMotor setMotorAmplitude:lowFreqMotor]; + [self applyPhysicalControllerRumble:voidController lowFreqMotor:highFreqMotor highFreqMotor:lowFreqMotor]; break; case RumbleOff: -#if !TARGET_OS_TV - if (useGameSirG8MFiRumble) { - [_gameSirG8MFiRumble setLowFrequencyMotor:0 highFrequencyMotor:0]; - } -#endif + [self applyPhysicalControllerRumble:voidController lowFreqMotor:0 highFreqMotor:0]; break; default: break; @@ -1777,6 +1771,11 @@ double rc_expo(double x, double expo) { voidController.motionTypes = [[NSMutableSet alloc] init]; voidController.supportedEmulationFlags = EMULATING_SPECIAL | EMULATING_SELECT; voidController.gamepad = controller; +#if !TARGET_OS_TV + voidController.hardware = [_gameSirG8MFiRumbler isTargetController:controller] ? ControllerHardwareG8PlusMFi : ControllerHardwareGeneric; +#else + voidController.hardware = ControllerHardwareGeneric; +#endif voidController.hasAccelerometer = NO; voidController.hasGyroscope = NO; @@ -2036,7 +2035,7 @@ double rc_expo(double x, double expo) { [ControllerUtil.activeStreamingGCControllers removeAllObjects]; _controllerNumbers = 0; #if !TARGET_OS_TV - _gameSirG8MFiRumble = [[GameSirG8MFiRumble alloc] init]; + _gameSirG8MFiRumbler = [[GameSirG8MFiRumbler alloc] init]; #endif _captureMouse = (streamConfig.localMousePointerMode == 0); @@ -2098,8 +2097,8 @@ double rc_expo(double x, double expo) { VoidController* voidController = [self->_voidControllers objectForKey:[NSNumber numberWithInteger:controller.playerIndex]]; if (voidController) { #if !TARGET_OS_TV - if ([self->_gameSirG8MFiRumble isTargetController:controller]) { - [self->_gameSirG8MFiRumble stopAndClose]; + if ([self->_gameSirG8MFiRumbler isTargetController:controller]) { + [self->_gameSirG8MFiRumbler stopAndClose]; } #endif [self stopTimerForController:voidController]; @@ -2306,7 +2305,7 @@ double rc_expo(double x, double expo) { { [ControllerUtil stopAllDualSenseHaptics]; #if !TARGET_OS_TV - [_gameSirG8MFiRumble invalidate]; + [_gameSirG8MFiRumbler invalidate]; #endif if (VLSharedControllerSupport == self) { diff --git a/VoidLink/Input/ControllerUtil.swift b/VoidLink/Input/ControllerUtil.swift index d0dabe62..6dd60f91 100644 --- a/VoidLink/Input/ControllerUtil.swift +++ b/VoidLink/Input/ControllerUtil.swift @@ -267,6 +267,11 @@ import UIKit case xboxAndPs = 0x00 // LI_CTYPE_UNKNOWN } +@objc enum ControllerHardware: UInt8 { + case generic + case g8PlusMFi +} + @objc protocol ControllerUtilDelegate: AnyObject { func isStreaming() -> Bool func isInAppView() -> Bool diff --git a/VoidLink/Input/GameSirG8MFiRumble.h b/VoidLink/Input/GameSirG8MFiRumble.h deleted file mode 100644 index ce5dafee..00000000 --- a/VoidLink/Input/GameSirG8MFiRumble.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// GameSirG8MFiRumble.h -// VoidLink -// - -#import -#include - -@class GCController; - -NS_ASSUME_NONNULL_BEGIN - -@interface GameSirG8MFiRumble : NSObject - -- (BOOL)canHandleController:(GCController *)controller; -- (BOOL)isTargetController:(GCController *)controller; -- (void)setLowFrequencyMotor:(uint16_t)lowFrequencyMotor highFrequencyMotor:(uint16_t)highFrequencyMotor; -- (void)stopAndClose; -- (void)invalidate; - -@end - -NS_ASSUME_NONNULL_END diff --git a/VoidLink/Input/GameSirG8MFiRumble.m b/VoidLink/Input/GameSirG8MFiRumble.m deleted file mode 100644 index 6fc1f5ee..00000000 --- a/VoidLink/Input/GameSirG8MFiRumble.m +++ /dev/null @@ -1,299 +0,0 @@ -// -// GameSirG8MFiRumble.m -// VoidLink -// - -#import "GameSirG8MFiRumble.h" - -@import ExternalAccessory; -@import GameController; -@import UIKit; - -static NSString *const GameSirG8MFiProtocol = @"com.xiaoji.M2boot"; -static NSString *const GameSirManufacturer = @"GameSir"; -static NSString *const GameSirG8MFiModel = @"G8+ MFi"; - -@interface GameSirG8MFiRumble () -@end - -@implementation GameSirG8MFiRumble { - EASession *_session; - NSData *_inFlightPacket; - NSUInteger _inFlightOffset; - NSData *_queuedPacket; - NSData *_lastSentPacket; - BOOL _appActive; - BOOL _invalidated; -} - -- (instancetype)init { - self = [super init]; - if (self) { - _appActive = UIApplication.sharedApplication.applicationState == UIApplicationStateActive; - [NSNotificationCenter.defaultCenter addObserver:self - selector:@selector(applicationWillResignActive:) - name:UIApplicationWillResignActiveNotification - object:nil]; - [NSNotificationCenter.defaultCenter addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:UIApplicationDidBecomeActiveNotification - object:nil]; - } - return self; -} - -- (void)dealloc { - [NSNotificationCenter.defaultCenter removeObserver:self]; - NSAssert(NSThread.isMainThread || _session == nil, @"Active GameSir sessions must be closed on the main thread before deallocation"); - if (NSThread.isMainThread) { - [self stopAndCloseOnMainThread]; - } -} - -- (void)applicationWillResignActive:(NSNotification *)notification { - _appActive = NO; - [self stopAndCloseOnMainThread]; -} - -- (void)applicationDidBecomeActive:(NSNotification *)notification { - _appActive = YES; -} - -- (BOOL)isTargetController:(GCController *)controller { - NSString *vendorName = controller.vendorName; - if (vendorName.length == 0) { - return NO; - } - - NSStringCompareOptions options = NSCaseInsensitiveSearch | NSDiacriticInsensitiveSearch; - return [vendorName rangeOfString:GameSirManufacturer options:options].location != NSNotFound && - [vendorName rangeOfString:@"G8+" options:options].location != NSNotFound && [vendorName rangeOfString:@"MFi" options:options].location != NSNotFound; -} - -- (EAAccessory *)connectedAccessory { - for (EAAccessory *accessory in EAAccessoryManager.sharedAccessoryManager.connectedAccessories) { - if (!accessory.connected || ![accessory.protocolStrings containsObject:GameSirG8MFiProtocol]) { - continue; - } - if (accessory.manufacturer.length == 0 || accessory.modelNumber.length == 0 || - [accessory.manufacturer caseInsensitiveCompare:GameSirManufacturer] != NSOrderedSame || - [accessory.modelNumber caseInsensitiveCompare:GameSirG8MFiModel] != NSOrderedSame) { - continue; - } - return accessory; - } - return nil; -} - -- (BOOL)canHandleController:(GCController *)controller { - return [self isTargetController:controller] && [self connectedAccessory] != nil; -} - -+ (uint8_t)convertMotorAmplitude:(uint16_t)amplitude { - return (uint8_t)(((uint32_t)amplitude * 255u + 32767u) / 65535u); -} - -- (NSData *)packetForLowFrequencyMotor:(uint16_t)lowFrequencyMotor highFrequencyMotor:(uint16_t)highFrequencyMotor { - const uint8_t packet[] = { - 0x04, - [GameSirG8MFiRumble convertMotorAmplitude:lowFrequencyMotor], - 0x01, - [GameSirG8MFiRumble convertMotorAmplitude:highFrequencyMotor], - 0x01, - 0x00, - 0x00, - 0x00, - 0x00, - }; - return [NSData dataWithBytes:packet length:sizeof(packet)]; -} - -- (BOOL)openSessionIfNeeded { - if (_session != nil) { - return YES; - } - - EAAccessory *accessory = [self connectedAccessory]; - if (accessory == nil) { - return NO; - } - - EASession *session = [[EASession alloc] initWithAccessory:accessory forProtocol:GameSirG8MFiProtocol]; - if (session == nil || session.inputStream == nil || session.outputStream == nil) { - return NO; - } - - _session = session; - session.inputStream.delegate = self; - session.outputStream.delegate = self; - [session.inputStream scheduleInRunLoop:NSRunLoop.mainRunLoop forMode:NSDefaultRunLoopMode]; - [session.outputStream scheduleInRunLoop:NSRunLoop.mainRunLoop forMode:NSDefaultRunLoopMode]; - [session.inputStream open]; - [session.outputStream open]; - return YES; -} - -- (void)setLowFrequencyMotor:(uint16_t)lowFrequencyMotor highFrequencyMotor:(uint16_t)highFrequencyMotor { - @synchronized(self) { - if (_invalidated) { - return; - } - } - - NSData *packet = [self packetForLowFrequencyMotor:lowFrequencyMotor highFrequencyMotor:highFrequencyMotor]; - dispatch_async(dispatch_get_main_queue(), ^{ - @synchronized(self) { - if (self->_invalidated) { - return; - } - } - if (!self->_appActive) { - return; - } - - BOOL duplicateQueuedPacket = [self->_queuedPacket isEqualToData:packet]; - BOOL duplicateInFlightPacket = self->_queuedPacket == nil && [self->_inFlightPacket isEqualToData:packet]; - BOOL duplicateSentPacket = self->_queuedPacket == nil && self->_inFlightPacket == nil && [self->_lastSentPacket isEqualToData:packet]; - if (!duplicateQueuedPacket && !duplicateInFlightPacket && !duplicateSentPacket) { - self->_queuedPacket = packet; - } - - if ([self openSessionIfNeeded]) { - [self flushOutput]; - } - }); -} - -- (void)flushOutput { - NSOutputStream *outputStream = _session.outputStream; - if (outputStream.streamStatus != NSStreamStatusOpen || !outputStream.hasSpaceAvailable) { - return; - } - - while (outputStream.hasSpaceAvailable) { - if (_inFlightPacket == nil) { - if (_queuedPacket == nil) { - return; - } - _inFlightPacket = _queuedPacket; - _queuedPacket = nil; - _inFlightOffset = 0; - } - - const uint8_t *bytes = _inFlightPacket.bytes; - NSUInteger remainingLength = _inFlightPacket.length - _inFlightOffset; - NSInteger written = [outputStream write:&bytes[_inFlightOffset] maxLength:remainingLength]; - if (written <= 0) { - return; - } - - _inFlightOffset += (NSUInteger)written; - if (_inFlightOffset == _inFlightPacket.length) { - _lastSentPacket = _inFlightPacket; - _inFlightPacket = nil; - _inFlightOffset = 0; - } - } -} - -- (void)writeStopPacketBestEffort { - NSOutputStream *outputStream = _session.outputStream; - if (outputStream.streamStatus != NSStreamStatusOpen || !outputStream.hasSpaceAvailable) { - return; - } - - _queuedPacket = nil; - [self flushOutput]; - if (_inFlightPacket != nil) { - return; - } - - NSData *stopPacket = [self packetForLowFrequencyMotor:0 highFrequencyMotor:0]; - NSUInteger offset = 0; - while (offset < stopPacket.length && outputStream.hasSpaceAvailable) { - const uint8_t *bytes = stopPacket.bytes; - NSInteger written = [outputStream write:&bytes[offset] maxLength:stopPacket.length - offset]; - if (written <= 0) { - break; - } - offset += (NSUInteger)written; - } - if (offset == stopPacket.length) { - _lastSentPacket = stopPacket; - } -} - -- (void)closeSession { - NSAssert(NSThread.isMainThread, @"GameSir sessions must be closed on the main thread"); - if (_session != nil) { - [_session.inputStream close]; - [_session.outputStream close]; - [_session.inputStream removeFromRunLoop:NSRunLoop.mainRunLoop forMode:NSDefaultRunLoopMode]; - [_session.outputStream removeFromRunLoop:NSRunLoop.mainRunLoop forMode:NSDefaultRunLoopMode]; - _session.inputStream.delegate = nil; - _session.outputStream.delegate = nil; - } - - _session = nil; - _inFlightPacket = nil; - _inFlightOffset = 0; - _queuedPacket = nil; - _lastSentPacket = nil; -} - -- (void)stopAndCloseOnMainThread { - NSAssert(NSThread.isMainThread, @"GameSir sessions must be stopped on the main thread"); - [self writeStopPacketBestEffort]; - [self closeSession]; -} - -- (void)stopAndClose { - if (NSThread.isMainThread) { - [self stopAndCloseOnMainThread]; - return; - } - - dispatch_async(dispatch_get_main_queue(), ^{ - [self stopAndCloseOnMainThread]; - }); -} - -- (void)invalidate { - @synchronized(self) { - _invalidated = YES; - } - [self stopAndClose]; -} - -- (void)stream:(NSStream *)stream handleEvent:(NSStreamEvent)eventCode { - if (stream != _session.inputStream && stream != _session.outputStream) { - return; - } - - switch (eventCode) { - case NSStreamEventHasBytesAvailable: { - uint8_t buffer[64]; - while (_session.inputStream.hasBytesAvailable) { - if ([_session.inputStream read:buffer maxLength:sizeof(buffer)] <= 0) { - break; - } - } - break; - } - case NSStreamEventOpenCompleted: - case NSStreamEventHasSpaceAvailable: - if (stream == _session.outputStream) { - [self flushOutput]; - } - break; - case NSStreamEventErrorOccurred: - case NSStreamEventEndEncountered: - [self writeStopPacketBestEffort]; - [self closeSession]; - break; - case NSStreamEventNone: - break; - } -} - -@end diff --git a/VoidLink/Input/GameSirG8MFiRumbler.swift b/VoidLink/Input/GameSirG8MFiRumbler.swift new file mode 100644 index 00000000..85a404b1 --- /dev/null +++ b/VoidLink/Input/GameSirG8MFiRumbler.swift @@ -0,0 +1,456 @@ +// +// GameSirG8MFiRumble.m -> GameSirG8MFiRumbler.swift +// VoidLink +// +// Created by chrisnch on 2026/08/22 +// Copyright © 2026 chrisnch@github. All rights reserved. +// +// Modified by True砖家 on 2026/08/25 +// Copyright © 2026 True砖家 on Bilibili. All rights reserved. +// + +import ExternalAccessory +import Foundation +import GameController +import UIKit + +@objcMembers +final class GameSirG8MFiRumbler: NSObject, StreamDelegate { + private static let gameSirG8MFiProtocol = "com.xiaoji.M2boot" + private static let gameSirManufacturer = "GameSir" + private static let gameSirG8MFiModel = "G8+ MFi" + static var repeatInterval: TimeInterval = 0.167253 + + private var session: EASession? + private var inFlightPacket: Data? + private var inFlightOffset = 0 + private var queuedPacket: Data? + private var lastSentPacket: Data? + private var repeatTimer: Timer? + private var repeatPacket: Data? + private var appActive: Bool + private var invalidated = false + + override init() { + appActive = UIApplication.shared.applicationState == .active + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillResignActive(_:)), + name: UIApplication.willResignActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(_:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + assert(Thread.isMainThread || session == nil, "Active GameSir sessions must be closed on the main thread before deallocation") + if Thread.isMainThread { + stopAndCloseOnMainThread() + } + } + + func canHandleController(_ controller: GCController) -> Bool { + return isTargetController(controller) && connectedAccessory() != nil + } + + func isTargetController(_ controller: GCController) -> Bool { + guard let vendorName = controller.vendorName, !vendorName.isEmpty else { + return false + } + + return vendorName.range(of: Self.gameSirManufacturer, options: [.caseInsensitive, .diacriticInsensitive]) != nil && + vendorName.range(of: "G8+", options: [.caseInsensitive, .diacriticInsensitive]) != nil && + vendorName.range(of: "MFi", options: [.caseInsensitive, .diacriticInsensitive]) != nil + } + + @objc(setLowFrequencyMotor:highFrequencyMotor:) + func setLowFrequencyMotor(_ lowFrequencyMotor: UInt16, highFrequencyMotor: UInt16) { + objc_sync_enter(self) + let isInvalidated = invalidated + objc_sync_exit(self) + if isInvalidated { + return + } + + let packet = packet(lowFrequencyMotor: lowFrequencyMotor, highFrequencyMotor: highFrequencyMotor) + // NSLog("[G8Rumble] queue request low=%hu high=%hu packet=%@", lowFrequencyMotor, highFrequencyMotor, packet as NSData) + let shouldRepeat = lowFrequencyMotor != 0 || highFrequencyMotor != 0 + + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + + objc_sync_enter(self) + let isInvalidated = self.invalidated + objc_sync_exit(self) + if isInvalidated { + // NSLog("[G8Rumble] drop packet because rumble handler is invalidated") + return + } + if !self.appActive { + // NSLog("[G8Rumble] drop packet because app is inactive packet=%@", packet as NSData) + return + } + + if shouldRepeat { + self.stopRepeatTimer(clearPacket: false) + self.repeatPacket = packet + self.startRepeatTimer() + } else { + self.stopRepeatTimer() + } + + self.queuePacket(packet) + } + } + + func stopAndClose() { + if Thread.isMainThread { + stopAndCloseOnMainThread() + return + } + + DispatchQueue.main.async { [weak self] in + self?.stopAndCloseOnMainThread() + } + } + + func invalidate() { + objc_sync_enter(self) + invalidated = true + objc_sync_exit(self) + stopAndClose() + } + + @objc private func applicationWillResignActive(_ notification: Notification) { + appActive = false + stopAndCloseOnMainThread() + } + + @objc private func applicationDidBecomeActive(_ notification: Notification) { + appActive = true + } + + private func connectedAccessory() -> EAAccessory? { + var sawAccessory = false + for accessory in EAAccessoryManager.shared().connectedAccessories { + sawAccessory = true + /* + NSLog( + "[G8Rumble] EA accessory name=%@ manufacturer=%@ model=%@ protocols=%@ connected=%d", + accessory.name, + accessory.manufacturer, + accessory.modelNumber, + accessory.protocolStrings, + accessory.isConnected + ) */ + + guard accessory.isConnected, + accessory.protocolStrings.contains(Self.gameSirG8MFiProtocol), + !accessory.manufacturer.isEmpty, + !accessory.modelNumber.isEmpty, + accessory.manufacturer.caseInsensitiveCompare(Self.gameSirManufacturer) == .orderedSame, + accessory.modelNumber.caseInsensitiveCompare(Self.gameSirG8MFiModel) == .orderedSame else { + continue + } + + NSLog("[G8Rumble] matched GameSir G8+ MFi accessory name=%@", accessory.name) + return accessory + } + + if sawAccessory { + NSLog("[G8Rumble] no matching GameSir G8+ MFi accessory") + } else { + NSLog("[G8Rumble] no connected EA accessories") + } + return nil + } + + private static func convertMotorAmplitude(_ amplitude: UInt16) -> UInt8 { + return UInt8((UInt32(amplitude) * 255 + 32767) / 65535) + } + + private func packet(lowFrequencyMotor: UInt16, highFrequencyMotor: UInt16) -> Data { + return Data([ + 0x04, + Self.convertMotorAmplitude(lowFrequencyMotor), + 0x01, + Self.convertMotorAmplitude(highFrequencyMotor), + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + ]) + } + + private func openSessionIfNeeded() -> Bool { + if session != nil { + return true + } + + guard let accessory = connectedAccessory() else { + NSLog("[G8Rumble] cannot open session: accessory not found") + return false + } + + NSLog("[G8Rumble] opening EASession protocol=%@ accessory=%@", Self.gameSirG8MFiProtocol, accessory.name) + guard let newSession = EASession(accessory: accessory, forProtocol: Self.gameSirG8MFiProtocol), + let inputStream = newSession.inputStream, + let outputStream = newSession.outputStream else { + NSLog("[G8Rumble] failed to create EASession") + return false + } + + session = newSession + inputStream.delegate = self + outputStream.delegate = self + inputStream.schedule(in: .main, forMode: .default) + outputStream.schedule(in: .main, forMode: .default) + inputStream.open() + outputStream.open() + /* + NSLog( + "[G8Rumble] EASession opened inputStatus=%ld outputStatus=%ld", + inputStream.streamStatus.rawValue, + outputStream.streamStatus.rawValue + ) */ + return true + } + + private func startRepeatTimer() { + let timer = Timer(timeInterval: Self.repeatInterval, repeats: true) { [weak self] _ in + self?.repeatCurrentPacket() + } + repeatTimer = timer + RunLoop.main.add(timer, forMode: .common) + // NSLog("[G8Rumble] repeat timer started interval=%.3f", Self.repeatInterval) + } + + private func stopRepeatTimer(clearPacket: Bool = true) { + guard let timer = repeatTimer else { + if clearPacket { + repeatPacket = nil + } + return + } + + timer.invalidate() + repeatTimer = nil + if clearPacket { + repeatPacket = nil + } + // NSLog("[G8Rumble] repeat timer stopped") + } + + private func repeatCurrentPacket() { + objc_sync_enter(self) + let isInvalidated = invalidated + objc_sync_exit(self) + if isInvalidated || !appActive { + stopRepeatTimer() + return + } + + guard let packet = repeatPacket else { + stopRepeatTimer() + return + } + + queuePacket(packet) + } + + private func queuePacket(_ packet: Data) { + queuedPacket = packet + // NSLog("[G8Rumble] queued packet=%@", packet as NSData) + + if openSessionIfNeeded() { + flushOutput() + } else { + // NSLog("[G8Rumble] packet queued but session is not open packet=%@", packet as NSData) + } + } + + private func flushOutput() { + guard let outputStream = session?.outputStream else { + return + } + guard outputStream.streamStatus == .open, outputStream.hasSpaceAvailable else { + /* + NSLog( + "[G8Rumble] flush skipped status=%ld hasSpace=%d queued=%@ inFlight=%@", + outputStream.streamStatus.rawValue, + outputStream.hasSpaceAvailable, + (queuedPacket as NSData?) ?? NSNull(), + (inFlightPacket as NSData?) ?? NSNull() + ) */ + return + } + + while outputStream.hasSpaceAvailable { + if inFlightPacket == nil { + guard let packet = queuedPacket else { + return + } + inFlightPacket = packet + queuedPacket = nil + inFlightOffset = 0 + } + + guard let packet = inFlightPacket else { + return + } + + let remainingLength = packet.count - inFlightOffset + let written = packet.withUnsafeBytes { rawBuffer -> Int in + guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { + return -1 + } + return outputStream.write(baseAddress.advanced(by: inFlightOffset), maxLength: remainingLength) + } + + if written <= 0 { + NSLog( + "[G8Rumble] write failed packet=%@ offset=%lu remaining=%lu written=%ld status=%ld error=%@", + packet as NSData, + UInt(inFlightOffset), + UInt(remainingLength), + written, + outputStream.streamStatus.rawValue, + outputStream.streamError as NSError? ?? NSNull() + ) + return + } + + /* + NSLog( + "[G8Rumble] write packet=%@ offset=%lu remaining=%lu written=%ld status=%ld", + packet as NSData, + UInt(inFlightOffset), + UInt(remainingLength), + written, + outputStream.streamStatus.rawValue + ) */ + + inFlightOffset += written + if inFlightOffset == packet.count { + // NSLog("[G8Rumble] packet sent %@", packet as NSData) + lastSentPacket = packet + inFlightPacket = nil + inFlightOffset = 0 + } + } + } + + private func writeStopPacketBestEffort() { + guard let outputStream = session?.outputStream else { + return + } + guard outputStream.streamStatus == .open, outputStream.hasSpaceAvailable else { + return + } + + queuedPacket = nil + flushOutput() + if inFlightPacket != nil { + return + } + + let stopPacket = packet(lowFrequencyMotor: 0, highFrequencyMotor: 0) + var offset = 0 + while offset < stopPacket.count && outputStream.hasSpaceAvailable { + let written = stopPacket.withUnsafeBytes { rawBuffer -> Int in + guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { + return -1 + } + return outputStream.write(baseAddress.advanced(by: offset), maxLength: stopPacket.count - offset) + } + if written <= 0 { + NSLog( + "[G8Rumble] stop packet write failed offset=%lu written=%ld status=%ld error=%@", + UInt(offset), + written, + outputStream.streamStatus.rawValue, + outputStream.streamError as NSError? ?? NSNull() + ) + break + } + offset += written + } + if offset == stopPacket.count { + lastSentPacket = stopPacket + } + NSLog("[G8Rumble] stop packet written=%lu/%lu packet=%@", UInt(offset), UInt(stopPacket.count), stopPacket as NSData) + } + + private func closeSession() { + assert(Thread.isMainThread, "GameSir sessions must be closed on the main thread") + stopRepeatTimer() + if let session, + let inputStream = session.inputStream, + let outputStream = session.outputStream { + NSLog( + "[G8Rumble] closing EASession inputStatus=%ld outputStatus=%ld", + inputStream.streamStatus.rawValue, + outputStream.streamStatus.rawValue + ) + inputStream.close() + outputStream.close() + inputStream.remove(from: .main, forMode: .default) + outputStream.remove(from: .main, forMode: .default) + inputStream.delegate = nil + outputStream.delegate = nil + } + + session = nil + inFlightPacket = nil + inFlightOffset = 0 + queuedPacket = nil + lastSentPacket = nil + } + + private func stopAndCloseOnMainThread() { + assert(Thread.isMainThread, "GameSir sessions must be stopped on the main thread") + writeStopPacketBestEffort() + closeSession() + } + + func stream(_ stream: Stream, handle eventCode: Stream.Event) { + guard let session, + let inputStream = session.inputStream, + let outputStream = session.outputStream else { + return + } + guard stream === inputStream || stream === outputStream else { + return + } + + switch eventCode { + case .hasBytesAvailable: + var buffer = [UInt8](repeating: 0, count: 64) + while inputStream.hasBytesAvailable { + if inputStream.read(&buffer, maxLength: buffer.count) <= 0 { + break + } + } + case .openCompleted, .hasSpaceAvailable: + if stream === outputStream { + flushOutput() + } + case .errorOccurred, .endEncountered: + writeStopPacketBestEffort() + closeSession() + default: + break + } + } +} diff --git a/VoidLink/Input/VoidController.h b/VoidLink/Input/VoidController.h index 0515af56..cc1d7c1a 100644 --- a/VoidLink/Input/VoidController.h +++ b/VoidLink/Input/VoidController.h @@ -7,6 +7,7 @@ // #import "HapticContext.h" +#include @import GameController; @import CoreHaptics; @@ -14,6 +15,8 @@ @import CoreMotion; #endif +typedef NS_ENUM(uint8_t, ControllerHardware); + @interface VoidController : NSObject typedef struct { @@ -69,6 +72,7 @@ typedef NS_ENUM(NSInteger, ControllerDeviceBatteryState) { @property(nonatomic, strong) NSMutableSet* _Nullable motionTypes; @property(nonatomic, assign) uint16_t reportRateHz; @property(nonatomic, assign) uint16_t controllerNumber; +@property(nonatomic, assign) ControllerHardware hardware; @property(nonatomic, assign) BOOL hasAccelerometer; @property(nonatomic, assign) BOOL hasGyroscope;