Files
seedds 0f99c353d3 Haptics: a Core Haptics engine that fails to start is replaced, not retried for good (general, from PR #143). A Bluetooth Switch Pro controller that vibrates through iOS Settings' Identify stayed silent in a game, which logged Core Haptics -4810 (server init failed): the adapter made each engine once per route and called startAndReturnError on that same broken engine at every later effect, and its stop/reset handlers were installed only after the start. Now haptics_apple.m makes an engine when the first effect needs it (the phone's too, so after the audio session is active), installs the handlers before starting it, and a failure to make or start the engine, make or start its player or send its parameters releases that engine: a fresh game signal makes a new one after a backoff of 1, 2, 4 or 8 s, effects from before the failure are not replayed, and the old engine's callbacks are stale (generation). A controller whose left and right handle engines cannot be made or started falls back to its default locality until it reconnects; a player or parameter failure (a service reset, say) is retried on the same handle. Idle release (1 s of silence), focus loss and engine resets stop an engine but keep it, so the next effect restarts the same engine, as before. Errors name the target, the stage, the error domain and code and the underlying error, and the log names the controller, its localities and the output channels. From PR #143 (nanda86), with two changes: the PR dropped the engine at every stop, so each rumble after a second of silence made a new engine (a new connection to the haptics service on a controller, a new initWithAudioSession: engine on the phone), and any handle failure moved the route to the default locality for good. t_haptics_apple covers each failure stage, the backoff, the fallback (and no fallback on a player failure), stale callbacks, a restarted engine after idle release, a disconnect and a controller without haptics, with fake controllers and engines. Checked: the host unit tests pass (GAME=0100DCA0064A6000), haptics_apple.m compiles for iOS arm64 with -Wall -Werror. Not checked: a physical controller or the iPhone's own haptics
2026-10-25 09:55:44 +08:00

237 lines
13 KiB
Objective-C

// Core Haptics adapts the game's bands; it cannot reproduce the Switch's exact waveforms.
// All engines and players belong to one serial queue. Guest threads enqueue ordered snapshots only.
#import "haptics_apple.h"
#import <CoreHaptics/CoreHaptics.h>
#import <TargetConditionals.h>
#if TARGET_OS_IPHONE
#import <UIKit/UIKit.h>
#import <AVFAudio/AVFAudio.h>
#else
#import <AppKit/AppKit.h>
#endif
#include <math.h>
#include <pthread.h>
#include <time.h>
@interface DmRumbleChannel : NSObject
@property(nonatomic) CHHapticEngine *engine;
@property(nonatomic) id<CHHapticAdvancedPatternPlayer> pattern;
@property(nonatomic, copy) CHHapticEngine *(^createEngine)(NSError **);
@property(nonatomic, copy) NSString *target;
@property(nonatomic, copy) void (^fallback)(NSTimeInterval);
@property(nonatomic) int player;
@property(nonatomic) uint64_t generation;
@property(nonatomic) uint64_t silenceGeneration;
@property(nonatomic) NSTimeInterval retryAfter;
@property(nonatomic) unsigned failures;
- (void)apply:(dm_haptic_level)level;
- (void)stop;
@end
static dispatch_queue_t queue;
static NSMutableArray<DmRumbleChannel *> *channels[DM_PLAYERS];
static GCController *controllers[DM_PLAYERS];
#if TARGET_OS_IPHONE
static BOOL phoneSupported;
#endif
static float gain = 1;
static pthread_mutex_t routeLock = PTHREAD_MUTEX_INITIALIZER;
static unsigned errors;
static NSTimeInterval now(void) {
struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec + t.tv_nsec / 1e9;
}
static void report(NSError *e, NSString *target, const char *stage) {
if (++errors <= 8 || errors % 64 == 0) {
fprintf(stderr, "[RED] haptics: %s %s: %s (%s %ld)\n", target.UTF8String, stage,
e.localizedDescription.UTF8String ?: "engine unavailable", e.domain.UTF8String ?: "no error", (long)e.code);
NSError *underlying = e.userInfo[NSUnderlyingErrorKey];
if (underlying) fprintf(stderr, "[RED] haptics: underlying %s %ld: %s\n", underlying.domain.UTF8String,
(long)underlying.code, underlying.userInfo[NSDebugDescriptionErrorKey] ?
[underlying.userInfo[NSDebugDescriptionErrorKey] UTF8String] : underlying.localizedDescription.UTF8String);
}
}
@implementation DmRumbleChannel
- (void)stop {
self.generation++; self.silenceGeneration++;
self.engine.stoppedHandler = ^(CHHapticEngineStoppedReason reason) { (void)reason; }; self.engine.resetHandler = ^{};
[self.pattern stopAtTime:CHHapticTimeImmediate error:nil]; self.pattern = nil;
[self.engine stopWithCompletionHandler:nil]; // the engine stays: the next effect restarts it
}
- (void)failed:(NSError *)error stage:(const char *)stage {
report(error, self.target, stage);
[self stop]; self.engine = nil; // A failed helper connection must not be reused for every later vibration.
self.failures = MIN(self.failures + 1, 4u);
self.retryAfter = now() + (1u << (self.failures - 1));
dm_haptics_clear(self.player);
}
- (void)engineFailed:(NSError *)error stage:(const char *)stage {
[self failed:error stage:stage];
// Only an engine that cannot be made or started moves a split route to the default locality;
// a player or parameter failure (a service reset, say) is retried on the same handle.
if (self.fallback) self.fallback(self.retryAfter);
}
- (void)apply:(dm_haptic_level)level {
uint64_t silence = ++self.silenceGeneration;
NSError *error = nil;
if (!self.pattern) {
if (level.intensity == 0) return;
if (now() < self.retryAfter) {
// Discard this effect, so an identical fresh send after the backoff is not deduplicated away.
dm_haptics_clear(self.player); return;
}
if (!self.engine) {
self.engine = self.createEngine(&error);
if (!self.engine) { [self engineFailed:error stage:"create engine"]; return; }
self.engine.playsHapticsOnly = YES; self.engine.autoShutdownEnabled = NO;
}
__weak DmRumbleChannel *weak = self;
uint64_t generation = ++self.generation;
void (^reset)(void) = ^{
dispatch_async(queue, ^{
DmRumbleChannel *c = weak; if (!c || c.generation != generation) return;
[c stop];
dm_haptics_clear(c.player); // queued effects from before an interruption cannot restart
});
};
self.engine.stoppedHandler = ^(CHHapticEngineStoppedReason reason) { (void)reason; reset(); };
self.engine.resetHandler = reset;
// Startup can itself fail or reset the service: install handlers before contacting it.
if (![self.engine startAndReturnError:&error]) { [self engineFailed:error stage:"start engine"]; return; }
CHHapticEvent *event = [[CHHapticEvent alloc] initWithEventType:CHHapticEventTypeHapticContinuous parameters:@[
[[CHHapticEventParameter alloc] initWithParameterID:CHHapticEventParameterIDHapticIntensity value:1],
[[CHHapticEventParameter alloc] initWithParameterID:CHHapticEventParameterIDHapticSharpness value:0.5]
] relativeTime:0 duration:30];
CHHapticPattern *pattern = [[CHHapticPattern alloc] initWithEvents:@[event] parameters:@[] error:&error];
self.pattern = pattern ? [self.engine createAdvancedPlayerWithPattern:pattern error:&error] : nil;
if (!self.pattern) { [self failed:error stage:"create player"]; return; }
self.pattern.loopEnabled = YES;
self.pattern.isMuted = YES;
if (![self.pattern startAtTime:CHHapticTimeImmediate error:&error]) { [self failed:error stage:"start player"]; return; }
}
// Controls live outside the loop: the loop boundary never replays an initial intensity.
NSArray *parameters = @[
[[CHHapticDynamicParameter alloc] initWithParameterID:CHHapticDynamicParameterIDHapticIntensityControl value:level.intensity relativeTime:0],
[[CHHapticDynamicParameter alloc] initWithParameterID:CHHapticDynamicParameterIDHapticSharpnessControl value:level.sharpness - 0.5f relativeTime:0]
];
if (![self.pattern sendParameters:parameters atTime:CHHapticTimeImmediate error:&error]) {
[self failed:error stage:"send parameters"]; return;
}
self.failures = 0;
self.pattern.isMuted = NO;
if (level.intensity == 0) {
// Reuse a warm player for nearby pulses, then release idle hardware. This never times out an active effect.
__weak DmRumbleChannel *weak = self;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC), queue, ^{
DmRumbleChannel *c = weak; if (c && c.silenceGeneration == silence) [c stop];
});
}
}
@end
static void apply(dm_haptic_state s) {
dispatch_async(queue, ^{ @autoreleasepool {
if (s.epoch != dm_haptics_epoch(s.player)) return;
NSArray<DmRumbleChannel *> *out = channels[s.player];
for (NSUInteger i = 0; i < out.count; i++) {
if (s.epoch != dm_haptics_epoch(s.player)) break; // a failed channel may replace this route
dm_haptic_level level = out.count == 2 ? dm_haptics_level(&s.motor[i], 1, gain) : dm_haptics_level(s.motor, 2, gain);
[out[i] apply:level];
}
} });
}
static void add_engine(int p, NSString *target, CHHapticEngine *(^create)(NSError **)) {
DmRumbleChannel *c = [DmRumbleChannel new]; c.createEngine = create; c.target = target; c.player = p;
[channels[p] addObject:c];
}
static void default_controller(int p, GCDeviceHaptics *h, NSTimeInterval retryAfter) {
add_engine(p, @"controller default", ^CHHapticEngine *(NSError **e) { (void)e; return [h createEngineWithLocality:GCHapticsLocalityDefault]; });
channels[p].lastObject.retryAfter = retryAfter;
}
static void configure(int p, GCController *controller) {
for (DmRumbleChannel *c in channels[p]) [c stop];
channels[p] = [NSMutableArray new];
if (controller) {
GCDeviceHaptics *h = controller.haptics;
NSSet *localities = h.supportedLocalities;
if ([localities containsObject:GCHapticsLocalityLeftHandle] && [localities containsObject:GCHapticsLocalityRightHandle]) {
add_engine(p, @"controller left handle", ^CHHapticEngine *(NSError **e) { (void)e; return [h createEngineWithLocality:GCHapticsLocalityLeftHandle]; });
add_engine(p, @"controller right handle", ^CHHapticEngine *(NSError **e) { (void)e; return [h createEngineWithLocality:GCHapticsLocalityRightHandle]; });
// Advertised handle localities can still fail to start. Fall back to the standard
// controller engine, once, rather than keeping two unusable helper connections.
__weak NSArray *route = channels[p];
for (DmRumbleChannel *c in channels[p]) c.fallback = ^(NSTimeInterval retryAfter) {
if (!route || channels[p] != route) return;
for (DmRumbleChannel *old in channels[p]) [old stop];
channels[p] = [NSMutableArray new];
default_controller(p, h, retryAfter);
dm_haptics_route(p, 1);
fprintf(stderr, "haptics: player %d retrying with the default controller locality\n", p + 1);
};
}
else if (localities.count) default_controller(p, h, 0);
fprintf(stderr, "haptics: player %d controller %s, localities %s, %lu channels\n", p + 1,
controller.vendorName.UTF8String ?: "unknown", [[localities.allObjects sortedArrayUsingSelector:@selector(compare:)] componentsJoinedByString:@", "].UTF8String ?: "none", (unsigned long)channels[p].count);
}
#if TARGET_OS_IPHONE
else if (!p && phoneSupported) {
// Do not initialize the phone's engine before the game's audio session is active.
add_engine(p, @"phone", ^CHHapticEngine *(NSError **e) { return [[CHHapticEngine alloc] initWithAudioSession:AVAudioSession.sharedInstance error:e]; });
}
#endif
dm_haptics_route(p, channels[p].count != 0);
}
void dm_haptics_apple_controller(int p, GCController *controller) {
if (!queue || p < 0 || p >= DM_PLAYERS) return;
pthread_mutex_lock(&routeLock);
if (controllers[p] != controller) {
controllers[p] = controller;
dm_haptics_route(p, 0); // invalidate queued work before changing hardware
dispatch_sync(queue, ^{ configure(p, controller); });
}
pthread_mutex_unlock(&routeLock);
}
static void suspend(unsigned reason, BOOL on) {
dm_haptics_suspend(reason, on);
void (^stop)(void) = ^{
for (int p = 0; p < DM_PLAYERS; p++) for (DmRumbleChannel *c in channels[p]) [c stop];
};
if (on) { if (dispatch_get_specific(&queue)) stop(); else dispatch_sync(queue, stop); }
}
void dm_haptics_apple_shutdown(void) {
if (queue) suspend(DM_HAPTICS_INACTIVE, YES);
}
void dm_haptics_apple_init(void) {
static dispatch_once_t once;
dispatch_once(&once, ^{
queue = dispatch_queue_create("dm.rumble", DISPATCH_QUEUE_SERIAL);
dispatch_queue_set_specific(queue, &queue, &queue, NULL);
const char *e = getenv("DM_RUMBLE_GAIN");
if (e) { float v = strtof(e, NULL); if (isfinite(v)) gain = fmaxf(0, fminf(3, v)); }
e = getenv("DM_RUMBLE");
dm_haptics_init(apply, !e || atoi(e) != 0);
#if TARGET_OS_IPHONE
phoneSupported = CHHapticEngine.capabilitiesForHardware.supportsHaptics;
#endif
dispatch_sync(queue, ^{ configure(0, nil); });
NSNotificationCenter *nc = NSNotificationCenter.defaultCenter;
#if TARGET_OS_IPHONE
[nc addObserverForName:UIApplicationWillResignActiveNotification object:nil queue:nil usingBlock:^(NSNotification *n) { (void)n; suspend(DM_HAPTICS_INACTIVE, YES); }];
[nc addObserverForName:UIApplicationDidBecomeActiveNotification object:nil queue:nil usingBlock:^(NSNotification *n) { (void)n; suspend(DM_HAPTICS_INACTIVE, NO); }];
[nc addObserverForName:UIApplicationWillTerminateNotification object:nil queue:nil usingBlock:^(NSNotification *n) { (void)n; dm_haptics_apple_shutdown(); }];
[nc addObserverForName:AVAudioSessionInterruptionNotification object:nil queue:nil usingBlock:^(NSNotification *n) {
suspend(DM_HAPTICS_INTERRUPTED, [n.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue] == AVAudioSessionInterruptionTypeBegan);
}];
[nc addObserverForName:AVAudioSessionRouteChangeNotification object:nil queue:nil usingBlock:^(NSNotification *n) {
(void)n; dm_haptics_clear(0);
}];
#else
[nc addObserverForName:NSApplicationWillResignActiveNotification object:nil queue:nil usingBlock:^(NSNotification *n) { (void)n; suspend(DM_HAPTICS_INACTIVE, YES); }];
[nc addObserverForName:NSApplicationDidBecomeActiveNotification object:nil queue:nil usingBlock:^(NSNotification *n) { (void)n; suspend(DM_HAPTICS_INACTIVE, NO); }];
[nc addObserverForName:NSApplicationWillTerminateNotification object:nil queue:nil usingBlock:^(NSNotification *n) { (void)n; dm_haptics_apple_shutdown(); }];
#endif
atexit(dm_haptics_apple_shutdown);
});
}