From 84b6b22f754630478750aa42f541604eb452e3c6 Mon Sep 17 00:00:00 2001 From: chrisnch <108147436+chrisnch@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:07:08 +0100 Subject: [PATCH] Add native rumble support for GameSir G8+ MFi --- VoidLink.xcodeproj/project.pbxproj | 10 + VoidLink/Input/ControllerSupport.m | 43 +++- VoidLink/Input/GameSirG8MFiRumble.h | 23 +++ VoidLink/Input/GameSirG8MFiRumble.m | 292 ++++++++++++++++++++++++++++ VoidLink/Limelight-Info.plist | 4 + 5 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 VoidLink/Input/GameSirG8MFiRumble.h create mode 100644 VoidLink/Input/GameSirG8MFiRumble.m diff --git a/VoidLink.xcodeproj/project.pbxproj b/VoidLink.xcodeproj/project.pbxproj index dcbf8a99..a855910c 100644 --- a/VoidLink.xcodeproj/project.pbxproj +++ b/VoidLink.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ 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 */; }; + 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 */; }; 1888C1922E24C33100108730 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1912E24C33100108730 /* AboutViewController.swift */; }; @@ -200,6 +202,9 @@ 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 = ""; }; + 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 = ""; }; 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WidgetPanelStackView.m; sourceTree = ""; }; @@ -471,6 +476,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A76B00052FFCC00100000005 /* ExternalAccessory.framework in Frameworks */, 18A0EE272EFB9B5A00BA853A /* SVGKitSwift in Frameworks */, 18A0EE252EFB9B5A00BA853A /* SVGKit in Frameworks */, 9890CF6B203B7EE1006C4B06 /* libxml2.tbd in Frameworks */, @@ -648,6 +654,7 @@ FB290CF019B2C406004C83CF /* Frameworks */ = { isa = PBXGroup; children = ( + A76B00042FFCC00100000004 /* ExternalAccessory.framework */, 18780CC62F10D66000B90483 /* StoreKit.framework */, 98882A032AF60F5300C5A11C /* libavcodec.a */, 98882A052AF60F5300C5A11C /* libavformat.a */, @@ -750,6 +757,8 @@ isa = PBXGroup; children = ( 84351B052C29ACAF00A77989 /* CustomOSC */, + A76B00012FFCC00100000001 /* GameSirG8MFiRumble.h */, + A76B00022FFCC00100000002 /* GameSirG8MFiRumble.m */, FB89460A19F646E200339C8A /* ControllerSupport.h */, FB89460B19F646E200339C8A /* ControllerSupport.m */, FB89460C19F646E200339C8A /* StreamView.h */, @@ -1243,6 +1252,7 @@ FBD3494319FC9C04002D2A60 /* AppAssetManager.m in Sources */, FB6549561A57907E001C8F39 /* DiscoveryWorker.m in Sources */, 84351B0E2C29ACCB00A77989 /* LayoutOnScreenControls.m in Sources */, + A76B00032FFCC00100000003 /* GameSirG8MFiRumble.m 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 b11424d4..fabfa5cc 100644 --- a/VoidLink/Input/ControllerSupport.m +++ b/VoidLink/Input/ControllerSupport.m @@ -10,9 +10,10 @@ // #import "ControllerSupport.h" +#import "GameSirG8MFiRumble.h" +#import "OnScreenControls.h" #import "VoidController.h" #import "VoidLink-Swift.h" -#import "OnScreenControls.h" #import "DataManager.h" #include "Limelight.h" @@ -118,6 +119,9 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger, TemporarySettings* tempSettings; OSCProfile* oscProfile; OSCProfilesManager* oscProfileMan; +#if !TARGET_OS_TV + GameSirG8MFiRumble *_gameSirG8MFiRumble; +#endif #define EMULATING_SELECT 0x1 #define EMULATING_SPECIAL 0x2 @@ -173,23 +177,49 @@ static void ApplyAdaptiveTriggerEffect(GCDualSenseAdaptiveTrigger* trigger, // No connected controller for this player return; } + +#if !TARGET_OS_TV + BOOL useGameSirG8MFiRumble = [_gameSirG8MFiRumble canHandleController:voidController.gamepad]; +#endif // 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]; break; case RumbleDevice: +#if !TARGET_OS_TV + if (useGameSirG8MFiRumble) { + [_gameSirG8MFiRumble setLowFrequencyMotor:0 highFrequencyMotor:0]; + } +#endif [_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]; break; case RumbleOff: +#if !TARGET_OS_TV + if (useGameSirG8MFiRumble) { + [_gameSirG8MFiRumble setLowFrequencyMotor:0 highFrequencyMotor:0]; + } +#endif break; default: break; @@ -2005,6 +2035,9 @@ double rc_expo(double x, double expo) { _voidControllers = [[NSMutableDictionary alloc] init]; [ControllerUtil.activeStreamingGCControllers removeAllObjects]; _controllerNumbers = 0; +#if !TARGET_OS_TV + _gameSirG8MFiRumble = [[GameSirG8MFiRumble alloc] init]; +#endif _captureMouse = (streamConfig.localMousePointerMode == 0); if (@available(iOS 14.0, tvOS 14.0, *)) { @@ -2064,6 +2097,11 @@ 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]; + } +#endif [self stopTimerForController:voidController]; // Stop haptics on this controller @@ -2267,6 +2305,9 @@ double rc_expo(double x, double expo) { -(void) cleanup { [ControllerUtil stopAllDualSenseHaptics]; +#if !TARGET_OS_TV + [_gameSirG8MFiRumble invalidate]; +#endif if (VLSharedControllerSupport == self) { VLSharedControllerSupport = nil; diff --git a/VoidLink/Input/GameSirG8MFiRumble.h b/VoidLink/Input/GameSirG8MFiRumble.h new file mode 100644 index 00000000..ce5dafee --- /dev/null +++ b/VoidLink/Input/GameSirG8MFiRumble.h @@ -0,0 +1,23 @@ +// +// 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 new file mode 100644 index 00000000..5e7b760e --- /dev/null +++ b/VoidLink/Input/GameSirG8MFiRumble.m @@ -0,0 +1,292 @@ +// +// 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]; + if (NSThread.isMainThread) { + [self writeStopPacketBestEffort]; + } + [self closeSession]; +} + +- (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 { + 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 { + [self writeStopPacketBestEffort]; + [self closeSession]; +} + +- (void)stopAndClose { + 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/Limelight-Info.plist b/VoidLink/Limelight-Info.plist index 4fc1f243..ed472319 100644 --- a/VoidLink/Limelight-Info.plist +++ b/VoidLink/Limelight-Info.plist @@ -116,6 +116,10 @@ VoidLink uses microphone to stream your voice to PC host. Microphone redirection UIStatusBarHidden + UISupportedExternalAccessoryProtocols + + com.xiaoji.M2boot + UISupportedInterfaceOrientations UIInterfaceOrientationLandscapeLeft