From 3eb1d76f5ddd56277f68fefbe162ec83a54899a1 Mon Sep 17 00:00:00 2001 From: seedds Date: Sat, 24 Oct 2026 11:02:21 +0800 Subject: [PATCH] Add game rumble for iPhone and Apple game controllers Preserve per-player motor state and SDK vibration handles, apply batches atomically, and route Core Haptics output through existing controller assignments. Add independent launcher controls and lifecycle tests. Supersedes #86. Physical feel and controller compatibility still require device testing. --- README.md | 2 + dm/games/settings.tsv | 2 + dm/host/Makefile | 6 +- dm/host/front_mac.m | 6 +- dm/host/haptics_apple.h | 7 ++ dm/host/haptics_apple.m | 182 ++++++++++++++++++++++++++++++++ dm/host/hle_haptics.c | 90 ++++++++++++++++ dm/host/hle_haptics.h | 22 ++++ dm/host/hle_hid.c | 61 +++++++++-- dm/host/tests/Makefile | 12 ++- dm/host/tests/t_haptics.c | 54 ++++++++++ dm/host/tests/t_haptics_apple.m | 76 +++++++++++++ dm/host/tests/t_hid.c | 39 +++++++ dm/ios/Sources/DmGamepad.m | 4 +- dm/ios/Sources/front_ios.m | 2 + docs/development.md | 2 + 16 files changed, 548 insertions(+), 19 deletions(-) create mode 100644 dm/host/haptics_apple.h create mode 100644 dm/host/haptics_apple.m create mode 100644 dm/host/hle_haptics.c create mode 100644 dm/host/hle_haptics.h create mode 100644 dm/host/tests/t_haptics.c create mode 100644 dm/host/tests/t_haptics_apple.m diff --git a/README.md b/README.md index 8884bfd..49dfac1 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ With an Apple developer account you can instead build and install from Xcode: `- **Touch controls.** Tap **2D** / **3D** at the top to switch layouts; the app remembers your choice. Wonder, Engage, Dread, 13 Sentinels, Link's Awakening, Ace Attorney Investigations Collection, Unicorn Overlord, Rhythm Heaven Groove, Octopath Traveler 0, Pokémon Legends: Z-A, Cuphead, Absolum, Brain Memory Training Academy, The Great Ace Attorney Chronicles, STEINS;GATE RE:BOOT, Big Brain Academy: Brain vs. Brain, Patrick's Parabox, Celeste and Raging Loop start in 2D, the other games in 3D (STEINS;GATE RE:BOOT's quick save is R3, in the 3D layout). In 3D, the right stick (the camera in most games) is not drawn: swipe on the right third of the screen, starting anywhere off the buttons. In Super Mario Galaxy and Super Mario Galaxy 2, other touches outside the controls move the pointer; in Captain Toad: Treasure Tracker they touch the game's screen (touch blocks, stun enemies), and in Big Brain Academy: Brain vs. Brain too (its menus and activities). A connected game controller hides the touch controls and keeps every button. +**Rumble.** Game vibration uses the iPhone's haptic engine or a compatible connected controller. Controllers with separate left/right haptic outputs keep those effects separate. The launcher's **Game rumble** and **Rumble strength** settings control it independently of **Touch feedback**. Phone rumble approximates the Switch's effects; physical feel and controller compatibility still need device testing. + **Shaking.** Shake the iPhone to shake the controller, in games that read its motion: Super Mario Odyssey's motion moves (such as the cap throw on a shake) need it, and Odyssey only takes them in its default docked mode. A light bump does not count; a sharp flick of the phone does. - **Game language** is English by default. Pick another when packing with `--language` (`-l`), by code or by name: `-l ja`, `-l zh-Hans`, `-l French` (`LANGUAGE` in Spyder). It must be one the game has; the script prints the game's languages and stops on one it lacks. To change it without rebuilding, pick another on the settings screen. diff --git a/dm/games/settings.tsv b/dm/games/settings.tsv index f688195..071c625 100644 --- a/dm/games/settings.tsv +++ b/dm/games/settings.tsv @@ -13,3 +13,5 @@ DM_LANG choice languages build:DM_LANG languages>1 Game language DM_SHOW_FPS toggle - build:DM_SHOW_FPS always Frame-rate counter DM_METAL_HUD toggle - 0 always Performance HUD DM_TOUCH_HAPTICS toggle - 1 always Touch feedback Haptic clicks when pressing touch controls. +DM_RUMBLE toggle - 1 always Game rumble Vibration from the game on your phone or connected controller. +DM_RUMBLE_GAIN range 0:3:0.1 1.0 always Rumble strength Adjusts game rumble alongside the game's own vibration setting. diff --git a/dm/host/Makefile b/dm/host/Makefile index 746bd84..45a19dc 100644 --- a/dm/host/Makefile +++ b/dm/host/Makefile @@ -16,7 +16,7 @@ endif GAME_H = gen/$(GAME)/game_profile.h CFLAGS=-O1 -g -Wall -Wno-unused-function # Service-layer takeover hle_*.c files are included by wildcard: adding a file does not touch this line (avoids merge conflicts) -SRC=main.c loader.c hle.c crash.c probe.c thread.c memstat.m $(sort $(wildcard hle_*.c)) front_mac.m entry.S +SRC=main.c loader.c hle.c crash.c probe.c thread.c memstat.m haptics_apple.m $(sort $(wildcard hle_*.c)) front_mac.m entry.S # NVN null device + nn::vi + the Vulkan layer (dm/gfx/) # Sources under dm/gfx, dm/gfx/nvn, dm/gfx/tex, dm/gfx/vk and dm/gfx/gl are included by wildcard (tests/ and metal/ do not go into the host): adding a file does not touch this line GFX=$(sort $(wildcard ../gfx/*.c ../gfx/nvn/*.c ../gfx/nvn/*.m ../gfx/nvn/*.S ../gfx/tex/*.c ../gfx/tex/*.m ../gfx/vk/*.m ../gfx/gl/*.m)) @@ -50,8 +50,8 @@ endif endif .PHONY: FORCE FORCE: -dmhost: $(SRC) $(GFX) ../third_party/stb_vorbis/stb_vorbis.c dm.h hle.h dm_input.h pad_slots.h front.h $(GFX_H) $(GAME_H) $(GAME_STAMP) $(VPX_LIB) - clang $(CFLAGS) -I../gfx/nvn/include -I../third_party/vulkan-headers/include -I$(dir $(GAME_H)) -I$(VPX) -I../third_party/stb_vorbis -fobjc-arc -o $@ $(SRC) $(GFX) $(VPX_LIB) -framework Cocoa -framework QuartzCore -framework Metal -framework GameController -framework AudioToolbox -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework ImageIO -framework UniformTypeIdentifiers +dmhost: $(SRC) $(GFX) ../third_party/stb_vorbis/stb_vorbis.c dm.h hle.h dm_input.h pad_slots.h hle_haptics.h haptics_apple.h front.h $(GFX_H) $(GAME_H) $(GAME_STAMP) $(VPX_LIB) + clang $(CFLAGS) -I../gfx/nvn/include -I../third_party/vulkan-headers/include -I$(dir $(GAME_H)) -I$(VPX) -I../third_party/stb_vorbis -fobjc-arc -o $@ $(SRC) $(GFX) $(VPX_LIB) -framework Cocoa -framework QuartzCore -framework Metal -framework GameController -framework CoreHaptics -framework AudioToolbox -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework ImageIO -framework UniformTypeIdentifiers clean: # the host and everything make generates for it (gen/: the profile headers, the game stamp and the libvpx build; dmhost_* and *.dSYM beside them, as in .gitignore) rm -rf gen dmhost dmhost_* *.dSYM .PHONY: clean diff --git a/dm/host/front_mac.m b/dm/host/front_mac.m index 850ffcc..19e5b66 100644 --- a/dm/host/front_mac.m +++ b/dm/host/front_mac.m @@ -14,6 +14,7 @@ #include "front.h" #include "hle.h" #include "pad_slots.h" +#include "haptics_apple.h" #include #include @@ -114,6 +115,7 @@ static int g_merge, g_kbd_own; static int32_t pad_axis(float v) { return (int32_t)(fmaxf(-1.f, fminf(1.f, v)) * DM_STICK_MAX); } static void pad_read(GCExtendedGamepad *g) { int p = g_merge ? 0 : pad_slot_of(&g_slots, (__bridge const void *)g.controller); if (p < 0) return; + dm_haptics_apple_controller(p, g.controller); uint64_t m = 0; #define K(btn, bit) if (btn.isPressed) m |= bit K(g.buttonB, DM_BTN_A); K(g.buttonA, DM_BTN_B); K(g.buttonY, DM_BTN_X); K(g.buttonX, DM_BTN_Y); // by position: right A, bottom B, top X, left Y @@ -133,13 +135,14 @@ static void pads_changed(GCController *gone) { any = 1; c.extendedGamepad.valueChangedHandler = ^(GCExtendedGamepad *g, GCControllerElement *el) { (void)el; pad_read(g); }; pad_read(c.extendedGamepad); } - if (!any) dm_pad_set(0, 0, 0, 0, 0); + if (!any) { dm_pad_set(0, 0, 0, 0, 0); dm_haptics_apple_controller(0, nil); } return; } NSArray *now = GCController.controllers; char nb[16]; for (int p = 0; p < DM_PLAYERS; p++) if (g_held[p] && (g_held[p] == gone || ![now containsObject:g_held[p]])) { npad_name(p, nb, sizeof nb); LOG("controller '%s' disconnected: player %d (npad %s) is free", pad_name(g_held[p]), p + 1, nb); g_held[p].extendedGamepad.valueChangedHandler = nil; + dm_haptics_apple_controller(p, nil); pad_slot_free(&g_slots, (__bridge const void *)g_held[p]); g_held[p] = nil; dm_pad_set_player(p, 0, 0, 0, 0, 0); // nothing stays pressed } @@ -240,6 +243,7 @@ void front_run(void (*guest)(void)) { GCController *c = n.object; int conn = n.name == GCControllerDidConnectNotification; if (!c.extendedGamepad || g_merge) LOG("controller %s: %s%s", conn ? "connected" : "disconnected", pad_name(c), c.extendedGamepad ? "" : " (no extended gamepad profile: not used)"); pads_changed(conn ? nil : c); }]; + dm_haptics_apple_init(); pads_start(); [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown | NSEventMaskKeyUp handler:^NSEvent *(NSEvent *e) { if (e.window != win) return e; // Only swallow keys for the game window; other windows such as the software keyboard input box receive typing as usual diff --git a/dm/host/haptics_apple.h b/dm/host/haptics_apple.h new file mode 100644 index 0000000..2fd1b0f --- /dev/null +++ b/dm/host/haptics_apple.h @@ -0,0 +1,7 @@ +// Shared Core Haptics output for the Mac and iOS front ends. Configure routes before guest boot. +#pragma once +#import +#include "hle_haptics.h" +void dm_haptics_apple_init(void); +void dm_haptics_apple_controller(int player, GCController *controller); +void dm_haptics_apple_shutdown(void); diff --git a/dm/host/haptics_apple.m b/dm/host/haptics_apple.m new file mode 100644 index 0000000..530f6c9 --- /dev/null +++ b/dm/host/haptics_apple.m @@ -0,0 +1,182 @@ +// 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 +#import +#if TARGET_OS_IPHONE +#import +#import +#else +#import +#endif +#include +#include + +@interface DmRumbleChannel : NSObject +@property(nonatomic) CHHapticEngine *engine; +@property(nonatomic) id pattern; +@property(nonatomic) int player; +@property(nonatomic) uint64_t generation; +@property(nonatomic) uint64_t silenceGeneration; +- (void)apply:(dm_haptic_level)level; +- (void)stop; +@end + +static dispatch_queue_t queue; +static NSMutableArray *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 void report(NSError *e) { + if (++errors <= 8 || errors % 64 == 0) fprintf(stderr, "[RED] haptics: %s\n", e.localizedDescription.UTF8String ?: "engine unavailable"); +} + +@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]; +} +- (void)apply:(dm_haptic_level)level { + uint64_t silence = ++self.silenceGeneration; + NSError *error = nil; + if (!self.pattern) { + if (level.intensity == 0) return; + if (![self.engine startAndReturnError:&error]) { report(error); dm_haptics_clear(self.player); return; } + __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.pattern = nil; + 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; + 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) { report(error); dm_haptics_clear(self.player); return; } + self.pattern.loopEnabled = YES; + self.pattern.isMuted = YES; + if (![self.pattern startAtTime:CHHapticTimeImmediate error:&error]) { report(error); self.pattern = nil; dm_haptics_clear(self.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]) { + report(error); [self stop]; dm_haptics_clear(self.player); return; + } + 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 *out = channels[s.player]; + for (NSUInteger i = 0; i < out.count; i++) { + 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, CHHapticEngine *engine) { + if (!engine) return; + engine.playsHapticsOnly = YES; engine.autoShutdownEnabled = NO; + DmRumbleChannel *c = [DmRumbleChannel new]; c.engine = engine; c.player = p; + [channels[p] addObject:c]; +} +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]) { + CHHapticEngine *left = [h createEngineWithLocality:GCHapticsLocalityLeftHandle]; + CHHapticEngine *right = [h createEngineWithLocality:GCHapticsLocalityRightHandle]; + if (left && right) { add_engine(p, left); add_engine(p, right); } + } + if (!channels[p].count) add_engine(p, [h createEngineWithLocality:GCHapticsLocalityDefault]); + } +#if TARGET_OS_IPHONE + else if (!p && phoneSupported) { + NSError *error = nil; + CHHapticEngine *engine = [[CHHapticEngine alloc] initWithAudioSession:AVAudioSession.sharedInstance error:&error]; + if (!engine) report(error); + add_engine(p, engine); + } +#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); + }); +} diff --git a/dm/host/hle_haptics.c b/dm/host/hle_haptics.c new file mode 100644 index 0000000..2b31d06 --- /dev/null +++ b/dm/host/hle_haptics.c @@ -0,0 +1,90 @@ +// nn::hid vibration is persistent device state: an unwritten motor keeps its value until stopped. +// No game-specific timing, strength compensation or save-file access belongs here. +#include "hle_haptics.h" +#include +#include +#include + +_Static_assert(sizeof(dm_vibration) == 16, "VibrationValue"); +static const dm_vibration idle = {0, 160, 0, 320}; +static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; +static dm_haptic_state states[DM_PLAYERS]; +static void (*output)(dm_haptic_state); +static uint32_t available; +static unsigned suspended; +static int enabled; + +static int valid(int p) { return p >= 0 && p < DM_PLAYERS; } +static void clear_locked(int p) { + states[p].player = p; states[p].epoch++; + states[p].motor[0] = states[p].motor[1] = idle; + if (output) output(states[p]); +} +void dm_haptics_init(void (*sink)(dm_haptic_state), int on) { + pthread_mutex_lock(&lock); + output = sink; enabled = on; available = 0; suspended = 0; + for (int p = 0; p < DM_PLAYERS; p++) clear_locked(p); + pthread_mutex_unlock(&lock); +} +void dm_haptics_route(int p, int on) { + if (!valid(p)) return; + pthread_mutex_lock(&lock); + if (on) available |= 1u << p; else available &= ~(1u << p); + clear_locked(p); + pthread_mutex_unlock(&lock); +} +void dm_haptics_clear(int p) { + if (!valid(p)) return; + pthread_mutex_lock(&lock); clear_locked(p); pthread_mutex_unlock(&lock); +} +uint64_t dm_haptics_epoch(int p) { + pthread_mutex_lock(&lock); + uint64_t e = valid(p) ? states[p].epoch : 0; + pthread_mutex_unlock(&lock); return e; +} +uint32_t dm_haptics_available(void) { + pthread_mutex_lock(&lock); + uint32_t m = enabled && output ? available : 0; + pthread_mutex_unlock(&lock); return m; +} +void dm_haptics_suspend(unsigned reason, int on) { + pthread_mutex_lock(&lock); + unsigned before = suspended; + if (on) suspended |= reason; else suspended &= ~reason; + if (before != suspended) for (int p = 0; p < DM_PLAYERS; p++) clear_locked(p); + pthread_mutex_unlock(&lock); +} +static float amplitude(float x) { return isfinite(x) ? fmaxf(0, fminf(1, x)) : 0; } +static float frequency(float x, float fallback) { return isfinite(x) && x > 0 ? fmaxf(40, fminf(1250, x)) : fallback; } +void dm_haptics_send(const dm_vibration_update *u, int n) { + if (!u || n <= 0) return; + pthread_mutex_lock(&lock); + if (!enabled || !output || suspended) { pthread_mutex_unlock(&lock); return; } + uint32_t changed = 0; + for (int i = 0; i < n; i++) { + int p = u[i].player, m = u[i].motor; + if (!valid(p) || m < 0 || m > 1 || !(available >> p & 1)) continue; + dm_vibration v = u[i].value; + v.amplitude_low = amplitude(v.amplitude_low); v.amplitude_high = amplitude(v.amplitude_high); + v.frequency_low = frequency(v.frequency_low, 160); v.frequency_high = frequency(v.frequency_high, 320); + if (!memcmp(&v, &states[p].motor[m], sizeof v)) continue; + states[p].motor[m] = v; changed |= 1u << p; + } + for (int p = 0; p < DM_PLAYERS; p++) if (changed >> p & 1) output(states[p]); + pthread_mutex_unlock(&lock); +} +dm_vibration dm_haptics_actual(int p, int m) { + pthread_mutex_lock(&lock); + dm_vibration v = enabled && !suspended && valid(p) && m >= 0 && m < 2 && (available >> p & 1) ? states[p].motor[m] : idle; + pthread_mutex_unlock(&lock); return v; +} +dm_haptic_level dm_haptics_level(const dm_vibration *v, int n, float gain) { + float power = 0, weighted = 0; + for (int i = 0; i < n; i++) { + float lo = amplitude(v[i].amplitude_low), hi = amplitude(v[i].amplitude_high); + lo *= lo; hi *= hi; power += lo + hi; + weighted += lo * logf(frequency(v[i].frequency_low, 160) / 40) + hi * logf(frequency(v[i].frequency_high, 320) / 40); + } + if (!isfinite(gain) || gain < 0) gain = 0; + return (dm_haptic_level){fminf(1, sqrtf(power) * gain), power > 0 ? fminf(1, weighted / (power * logf(1250.0f / 40))) : 0}; +} diff --git a/dm/host/hle_haptics.h b/dm/host/hle_haptics.h new file mode 100644 index 0000000..1704d4f --- /dev/null +++ b/dm/host/hle_haptics.h @@ -0,0 +1,22 @@ +// Game vibration state, before adapting it to a phone or controller. Players use dm_input.h's slots. +#pragma once +#include "dm_input.h" + +typedef struct { float amplitude_low, frequency_low, amplitude_high, frequency_high; } dm_vibration; +typedef struct { int player, motor; dm_vibration value; } dm_vibration_update; +typedef struct { int player; uint64_t epoch; dm_vibration motor[2]; } dm_haptic_state; +typedef struct { float intensity, sharpness; } dm_haptic_level; +enum { DM_HAPTICS_INACTIVE = 1, DM_HAPTICS_INTERRUPTED = 2 }; + +// The sink copies/enqueues each snapshot in order; called under the state lock, it must not reenter. +// No hardware calls on the sending thread. A changed epoch invalidates all older queued snapshots. +void dm_haptics_init(void (*sink)(dm_haptic_state), int enabled); +void dm_haptics_route(int player, int available); // clears old state even when availability stays the same +void dm_haptics_clear(int player); // engine reset: needs a fresh game signal +uint64_t dm_haptics_epoch(int player); +uint32_t dm_haptics_available(void); +void dm_haptics_suspend(unsigned reason, int suspended); +void dm_haptics_send(const dm_vibration_update *updates, int count); // one atomic batch +dm_vibration dm_haptics_actual(int player, int motor); +// Approximate amplitude/frequency as Core Haptics intensity/sharpness, for one motor or a phone mix. +dm_haptic_level dm_haptics_level(const dm_vibration *values, int count, float gain); diff --git a/dm/host/hle_hid.c b/dm/host/hle_hid.c index 5c471bc..a16a9ae 100644 --- a/dm/host/hle_hid.c +++ b/dm/host/hle_hid.c @@ -3,8 +3,9 @@ // players 2..8 are full-key controllers on No2..No8 (ids 1..7) while the front end reports a controller for them (dm_pad_players; the Mac gives each physical controller a player // of its own, front_mac.m). A player whose npad the game does not support (SetSupportedNpadIdType) is not reported: in a one-player game it feeds player 1, otherwise it is not used. // Six-axis sensor: one per connected controller (two for handheld), at rest lying flat, player 1's shaken side to side while the front end holds DM_BTN_SHAKE (see the six-axis section below). -// Vibration, mouse, keyboard: not provided by the host (device count 0 / not connected); the controller applet reports the connected players with player 1 selected. +// Vibration follows the player assignments through hle_haptics.c. Mouse and keyboard: not connected; the controller applet reports the connected players with player 1 selected. #include "hle.h" +#include "hle_haptics.h" #include "dm_input.h" #include "front.h" #include @@ -251,6 +252,7 @@ HLE_FORCE("_ZN2nn3hid22SetSupportedNpadIdTypeEPKjm", "supported controller ids; uint32_t m = 0; for (uint64_t i = 0; ids && i < n; i++) m |= id_bit(ids[i]); uint32_t was = 0; for (int p = 1; p < DM_PLAYERS; p++) was |= (uint32_t)reported(p) << p; if (atomic_exchange(&g_ids_supported, m) == m) return; + for (int p = 0; p < DM_PLAYERS; p++) dm_haptics_clear(p); LOG("hid supported controller ids %#x (bit n: No n+1, bit 8 Handheld, bit 9 Other)", m); for (int p = 1; p < DM_PLAYERS; p++) if ((uint32_t)reported(p) != (was >> p & 1)) style_changed((uint32_t)p); note_merged(); @@ -328,15 +330,47 @@ HLE_FORCE("_ZN2nn3hid23GetSixAxisSensorHandlesEPNS0_19SixAxisSensorHandleEiRKjNS HLE_FORCE("_ZN2nn3hid18StartSixAxisSensorERKNS0_19SixAxisSensorHandleE", "six-axis sensor; always sampling on the host", void, h_hid_SixStart, const uint32_t *h) { (void)h; } HLE_FORCE("_ZN2nn3hid22GetSixAxisSensorStatesEPNS0_18SixAxisSensorStateEiRKNS0_19SixAxisSensorHandleE", "six-axis sensor states: every requested entry, newest first", int, h_hid_SixStates, SixAxisState *out, int n, const uint32_t *h) { return six_states(out, n, *h); } -// Vibration: no devices -HLE_FORCE("_ZN2nn3hid20IsVibrationPermittedEv", "vibration permission; not supported by the host", unsigned, h_hid_VibPermit, void) { return 0; } -HLE_FORCE("_ZN2nn3hid25GetVibrationDeviceHandlesEPNS0_21VibrationDeviceHandleEiRKjNS_4util10BitFlagSetILi32ENS0_12NpadStyleTagEEE", "vibration devices: 0", - int, h_hid_VibHandles, void *h, int n, const uint32_t *id, uint32_t style) { (void)h; (void)n; (void)id; (void)style; return 0; } -HLE_FORCE("_ZN2nn3hid25InitializeVibrationDeviceERKNS0_21VibrationDeviceHandleE", "vibration; not supported by the host", void, h_hid_InitVib, const void *h) { (void)h; } -HLE_FORCE("_ZN2nn3hid18SendVibrationValueERKNS0_21VibrationDeviceHandleERKNS0_14VibrationValueE", "vibration; not supported by the host", void, h_hid_SendVib, const void *h, const void *v) { (void)h; (void)v; } +// Vibration handles: byte 0 = SDK style index (FullKey 3, Handheld 4), byte 1 = npad id, byte 2 = motor. +// See libnx hid.c _hidGetVibrationDeviceHandles. Only the styles this host connects are exposed. +static uint32_t vib_targets(uint32_t h) { + unsigned type = h & 0xff, id = h >> 8 & 0xff, motor = h >> 16 & 0xff; + if (h >> 24 || motor > 1 || (type != 3 && type != 4)) return 0; + if (style_of(id) != (1u << (type - 3))) return 0; + int p = player_of(id); + return dm_haptics_available() & (p == 0 ? 1u | merged_players() : 1u << p); +} +static void vib_send(const uint32_t *handles, const dm_vibration *values, int n) { + if (!handles || !values || n <= 0) return; + dm_vibration_update updates[DM_PLAYERS * 2]; + uint32_t used = 0; + for (int i = 0; i < n; i++) { + uint32_t targets = vib_targets(handles[i]); + for (int p = 0; p < DM_PLAYERS; p++) if (targets >> p & 1) { + int m = handles[i] >> 16 & 1, k = p * 2 + m; + updates[k] = (dm_vibration_update){p, m, values[i]}; used |= 1u << k; + } + } + int count = 0; + for (int k = 0; k < DM_PLAYERS * 2; k++) if (used >> k & 1) updates[count++] = updates[k]; + dm_haptics_send(updates, count); +} +HLE_FORCE("_ZN2nn3hid20IsVibrationPermittedEv", "vibration permission: enabled and an output available", unsigned, h_hid_VibPermit, void) { return dm_haptics_available() != 0; } +HLE_FORCE("_ZN2nn3hid25GetVibrationDeviceHandlesEPNS0_21VibrationDeviceHandleEiRKjNS_4util10BitFlagSetILi32ENS0_12NpadStyleTagEEE", "vibration devices of the connected controller", + int, h_hid_VibHandles, uint32_t *h, int n, const uint32_t *id, uint32_t style) { + if (!h || !id || n <= 0 || !(style & style_of(*id))) return 0; + uint32_t base = (*id << 8) | (*id == NPAD_HANDHELD ? 4u : 3u); + if (!vib_targets(base)) return 0; + int count = n < 2 ? n : 2; + for (int i = 0; i < count; i++) h[i] = base | (uint32_t)i << 16; + return count; +} +HLE_FORCE("_ZN2nn3hid25InitializeVibrationDeviceERKNS0_21VibrationDeviceHandleE", "vibration: initialize one channel", void, h_hid_InitVib, const uint32_t *h) { + const dm_vibration idle = {0, 160, 0, 320}; vib_send(h, &idle, 1); +} +HLE_FORCE("_ZN2nn3hid18SendVibrationValueERKNS0_21VibrationDeviceHandleERKNS0_14VibrationValueE", "vibration: update one channel", void, h_hid_SendVib, const uint32_t *h, const dm_vibration *v) { vib_send(h, v, 1); } // Values queued by the vibration graph (VibrationNode::Update) reach it from the sdk's internal FlushEnqueuedVibrationValues through the PLT, into the hid service proxy (sm abort). Metroid Dread // sets handles on its VibrationTargets even when the host reports 0 devices, so values get queued; return success and the flush clears its count as usual -HLE_FORCE("_ZN2nn3hid6detail19SendVibrationValuesEPKNS0_21VibrationDeviceHandleEPKNS0_14VibrationValueEi", "vibration (batch, used by the vibration-pattern flush); not supported by the host", Result, h_hid_SendVibs, const void *h, const void *v, int n) { (void)h; (void)v; (void)n; return 0; } +HLE_FORCE("_ZN2nn3hid6detail19SendVibrationValuesEPKNS0_21VibrationDeviceHandleEPKNS0_14VibrationValueEi", "vibration batch: update all channels atomically", Result, h_hid_SendVibs, const uint32_t *h, const dm_vibration *v, int n) { vib_send(h, v, n); return RES_OK; } // Mouse / keyboard: not connected HLE_FORCE("_ZN2nn3hid15InitializeMouseEv", "mouse; not provided by the host", void, h_hid_InitMouse, void) {} HLE_FORCE("_ZN2nn3hid14GetMouseStatesEPNS0_10MouseStateEi", "mouse states: 0 entries", int, h_hid_MouseStates, void *out, int n) { (void)out; (void)n; return 0; } @@ -445,6 +479,11 @@ HLE_FORCE("_ZN2nn3hid32GetSixAxisSensorFusionParametersEPfS1_RKNS0_19SixAxisSens HLE_FORCE("_ZN2nn3hid32SetSixAxisSensorFusionParametersERKNS0_19SixAxisSensorHandleEff", "six-axis sensor fusion parameters; ignored by the host", void, h_hid_FusionSet, const uint32_t *h, float a, float b) { (void)h; (void)a; (void)b; } HLE_FORCE("_ZN2nn3hid34ResetSixAxisSensorFusionParametersERKNS0_19SixAxisSensorHandleE", "six-axis sensor fusion parameters; ignored by the host", void, h_hid_FusionReset, const uint32_t *h) { (void)h; } HLE_FORCE("_ZN2nn3hid41IsFirmwareUpdateAvailableForSixAxisSensorERKNS0_19SixAxisSensorHandleE", "six-axis sensor firmware update: none", unsigned, h_hid_SixFw, const uint32_t *h) { (void)h; return 0; } -// Vibration device info / actual value: no device, zeroed -HLE_FORCE("_ZN2nn3hid22GetVibrationDeviceInfoEPNS0_19VibrationDeviceInfoERKNS0_21VibrationDeviceHandleE", "vibration device info: none", void, h_hid_VibInfo, uint32_t *info, const void *h) { (void)h; info[0] = 0; info[1] = 0; } -HLE_FORCE("_ZN2nn3hid23GetActualVibrationValueEPNS0_14VibrationValueERKNS0_21VibrationDeviceHandleE", "actual vibration value: 0", void, h_hid_VibActual, float *v, const void *h) { (void)h; memset(v, 0, 16); } +// GetActual returns the sanitized guest value accepted by the host, before output-device adaptation. +HLE_FORCE("_ZN2nn3hid22GetVibrationDeviceInfoEPNS0_19VibrationDeviceInfoERKNS0_21VibrationDeviceHandleE", "vibration: linear resonant actuator, left or right", void, h_hid_VibInfo, uint32_t *info, const uint32_t *h) { + int on = h && vib_targets(*h); info[0] = on ? 1 : 0; info[1] = on ? 1 + (*h >> 16 & 1) : 0; +} +HLE_FORCE("_ZN2nn3hid23GetActualVibrationValueEPNS0_14VibrationValueERKNS0_21VibrationDeviceHandleE", "vibration: last accepted channel value", void, h_hid_VibActual, dm_vibration *v, const uint32_t *h) { + uint32_t targets = h ? vib_targets(*h) : 0; + *v = targets ? dm_haptics_actual(__builtin_ctz(targets), *h >> 16 & 1) : (dm_vibration){0, 160, 0, 320}; +} diff --git a/dm/host/tests/Makefile b/dm/host/tests/Makefile index 86f6575..e2ecd01 100644 --- a/dm/host/tests/Makefile +++ b/dm/host/tests/Makefile @@ -4,7 +4,7 @@ B = build # tests that use the game profile need GAME= (there is no default game); GINC only expands when used, so tests without the profile build as before GINC = $(if $(strip $(GAME)),-I../gen/$(GAME) -I../../games/$(GAME),$(error GAME is not set: make GAME=: $(shell python3 ../../games/game.py list | awk -F'\t' '{printf "%s (%s) ", $$1, $$3}'))) GAME_H = ../gen/$(GAME)/game_profile.h -run: $(B)/t_touch $(B)/t_oe $(B)/t_fs $(B)/t_fs_commit_list $(B)/t_thread_leak $(B)/t_fs_case $(B)/t_audio $(B)/t_audio_spsc $(B)/t_swkbd $(B)/t_altstack $(B)/t_condvar $(B)/t_condvar_old $(B)/t_pctl $(B)/t_time $(B)/t_shared_font $(B)/t_vorbis $(B)/t_vorbis_divides $(B)/t_hid +run: $(B)/t_touch $(B)/t_oe $(B)/t_fs $(B)/t_fs_commit_list $(B)/t_thread_leak $(B)/t_fs_case $(B)/t_audio $(B)/t_audio_spsc $(B)/t_swkbd $(B)/t_altstack $(B)/t_condvar $(B)/t_condvar_old $(B)/t_pctl $(B)/t_time $(B)/t_shared_font $(B)/t_vorbis $(B)/t_vorbis_divides $(B)/t_hid $(B)/t_haptics $(B)/t_haptics_apple $(B)/t_touch $(B)/t_oe 2>/dev/null $(B)/t_fs 2>/dev/null @@ -23,6 +23,8 @@ run: $(B)/t_touch $(B)/t_oe $(B)/t_fs $(B)/t_fs_commit_list $(B)/t_thread_leak $ $(B)/t_vorbis $(B)/t_vorbis_divides $(B)/t_hid + $(B)/t_haptics + $(B)/t_haptics_apple $(GAME_H): $(MAKE) -C .. game_profile GAME=$(GAME) $(B)/t_touch: t_touch.c ../hle_touch.c ../front.h ../dm.h $(GAME_H) | $(B) @@ -49,8 +51,12 @@ $(B)/t_pctl: t_pctl.c ../hle_misc.c ../hle.h ../dm.h $(GAME_H) | $(B) clang -O1 -Wall -Werror -Wno-unused-function -I.. $(GINC) -o $@ t_pctl.c $(B)/t_shared_font: t_shared_font.c ../hle_sdk16.c ../hle.h ../dm.h $(GAME_H) | $(B) clang -O1 -Wall -Werror -Wno-unused-function -Wno-frame-address -I.. $(GINC) -o $@ t_shared_font.c -$(B)/t_hid: t_hid.c ../hle_hid.c ../pad_slots.h ../dm_input.h ../hle.h ../dm.h $(GAME_H) | $(B) - clang -O1 -Wall -Werror -Wno-unused-function -I.. $(GINC) -o $@ t_hid.c +$(B)/t_hid: t_hid.c ../hle_hid.c ../hle_haptics.c ../hle_haptics.h ../pad_slots.h ../dm_input.h ../hle.h ../dm.h $(GAME_H) | $(B) + clang -O1 -Wall -Werror -Wno-unused-function -I.. $(GINC) -o $@ t_hid.c ../hle_haptics.c +$(B)/t_haptics: t_haptics.c ../hle_haptics.c ../hle_haptics.h ../dm_input.h | $(B) + clang -O1 -g -Wall -Werror -I.. -o $@ t_haptics.c ../hle_haptics.c +$(B)/t_haptics_apple: t_haptics_apple.m ../haptics_apple.m ../haptics_apple.h ../hle_haptics.c ../hle_haptics.h | $(B) + clang -O1 -g -fobjc-arc -Wall -Werror -I.. -o $@ t_haptics_apple.m ../hle_haptics.c -framework Cocoa -framework GameController -framework CoreHaptics $(B)/t_time: t_time.c ../hle_time.c ../hle.h ../dm.h $(GAME_H) | $(B) clang -O1 -Wall -Werror -Wno-unused-function -I.. $(GINC) -o $@ t_time.c $(B)/t_oe: t_oe.c ../hle_oe.c ../hle.h ../dm.h $(GAME_H) | $(B) diff --git a/dm/host/tests/t_haptics.c b/dm/host/tests/t_haptics.c new file mode 100644 index 0000000..cb179e5 --- /dev/null +++ b/dm/host/tests/t_haptics.c @@ -0,0 +1,54 @@ +// Exercise the same state interface used by HID and the Apple output, with a recording sink. +#include "hle_haptics.h" +#include +#include +#include +#include + +static dm_haptic_state last[DM_PLAYERS]; +static int calls; +static void record(dm_haptic_state s) { last[s.player] = s; calls++; } +static void send(int p, int m, float a) { + dm_vibration_update u = {p, m, {a, 160, 0, 320}}; dm_haptics_send(&u, 1); +} +int main(void) { + dm_haptics_init(record, 1); + assert(!dm_haptics_available()); + send(0, 0, 0.5); assert(dm_haptics_actual(0, 0).amplitude_low == 0); + dm_haptics_route(0, 1); dm_haptics_route(1, 1); + calls = 0; + dm_vibration_update batch[] = {{0, 0, {0.2, 160, 0, 320}}, {0, 1, {0.4, 160, 0, 320}}}; + dm_haptics_send(batch, 2); + assert(calls == 1 && last[0].motor[0].amplitude_low == 0.2f && last[0].motor[1].amplitude_low == 0.4f); + send(1, 0, 0.7); send(0, 1, 0); + assert(last[1].motor[0].amplitude_low == 0.7f && last[0].motor[0].amplitude_low == 0.2f); + calls = 0; send(0, 1, 0.3); send(0, 1, 0); + assert(calls == 2 && last[0].motor[1].amplitude_low == 0); // both edges of a short pulse survive + usleep(600000); + assert(dm_haptics_actual(0, 0).amplitude_low == 0.2f); // steady state never times out + uint64_t old = last[0].epoch; + dm_haptics_route(0, 0); dm_haptics_route(0, 1); + assert(dm_haptics_epoch(0) != old && dm_haptics_actual(0, 0).amplitude_low == 0); + assert(dm_haptics_actual(1, 0).amplitude_low == 0.7f); // other player unaffected + dm_haptics_suspend(DM_HAPTICS_INACTIVE, 1); + dm_haptics_suspend(DM_HAPTICS_INTERRUPTED, 1); + send(1, 0, 0.8); + dm_haptics_suspend(DM_HAPTICS_INACTIVE, 0); + send(1, 0, 0.8); assert(dm_haptics_actual(1, 0).amplitude_low == 0); + dm_haptics_suspend(DM_HAPTICS_INTERRUPTED, 0); + assert(dm_haptics_actual(1, 0).amplitude_low == 0); + send(1, 0, 0.8); assert(dm_haptics_actual(1, 0).amplitude_low == 0.8f); + dm_vibration_update bad = {1, 1, {NAN, INFINITY, 2, -1}}; + dm_haptics_send(&bad, 1); + dm_vibration v = dm_haptics_actual(1, 1); + assert(v.amplitude_low == 0 && v.amplitude_high == 1 && v.frequency_low == 160 && v.frequency_high == 320); + calls = 0; send(-1, 0, 1); send(8, 0, 1); send(0, 2, 1); assert(!calls); + dm_vibration low = {0.2, 80, 0, 320}, high = {0.2, 640, 0, 320}; + dm_haptic_level l = dm_haptics_level(&low, 1, 1), h = dm_haptics_level(&high, 1, 1); + assert(l.intensity == h.intensity && l.sharpness < h.sharpness); + assert(dm_haptics_level(&low, 1, 0).intensity == 0); + assert(fabsf(dm_haptics_level(&low, 1, 2).intensity - 2 * l.intensity) < 0.0001f); + dm_haptics_init(record, 0); dm_haptics_route(0, 1); send(0, 0, 1); + assert(!dm_haptics_available() && dm_haptics_actual(0, 0).amplitude_low == 0); + puts("haptics: state, timing, routing, sanitization and mapping passed"); +} diff --git a/dm/host/tests/t_haptics_apple.m b/dm/host/tests/t_haptics_apple.m new file mode 100644 index 0000000..6c1f956 --- /dev/null +++ b/dm/host/tests/t_haptics_apple.m @@ -0,0 +1,76 @@ +// Run the actual serial-queue adapter with fake engines: no controller, actuator or app is opened. +#import "../haptics_apple.m" +#include + +@interface TestPlayer : NSObject +@property BOOL loopEnabled, isMuted; +@property int starts, stops; +@property NSMutableArray *levels; +@end +@implementation TestPlayer +- (instancetype)init { if ((self = [super init])) _levels = [NSMutableArray new]; return self; } +- (BOOL)startAtTime:(NSTimeInterval)t error:(NSError **)e { (void)t; (void)e; self.starts++; return YES; } +- (BOOL)stopAtTime:(NSTimeInterval)t error:(NSError **)e { (void)t; (void)e; self.stops++; return YES; } +- (BOOL)sendParameters:(NSArray *)p atTime:(NSTimeInterval)t error:(NSError **)e { + (void)t; (void)e; + for (CHHapticDynamicParameter *v in p) if ([v.parameterID isEqual:CHHapticDynamicParameterIDHapticIntensityControl]) [self.levels addObject:@(v.value)]; + return YES; +} +@end +@interface TestEngine : NSObject +@property BOOL playsHapticsOnly, autoShutdownEnabled; +@property(copy) void (^resetHandler)(void); +@property(copy) void (^stoppedHandler)(CHHapticEngineStoppedReason); +@property TestPlayer *testPlayer; +@property int starts, stops; +@property BOOL fail; +@end +@implementation TestEngine +- (BOOL)startAndReturnError:(NSError **)e { (void)e; self.starts++; return !self.fail; } +- (void)stopWithCompletionHandler:(CHHapticCompletionHandler)handler { self.stops++; if (handler) handler(nil); } +- (id)createAdvancedPlayerWithPattern:(CHHapticPattern *)p error:(NSError **)e { + (void)p; (void)e; self.testPlayer = [TestPlayer new]; return (id)self.testPlayer; +} +@end +static void drain(void) { dispatch_sync(queue, ^{}); } +static void send_pair(float left, float right) { + dm_vibration_update u[] = {{0, 0, {left, 160, 0, 320}}, {0, 1, {right, 160, 0, 320}}}; + dm_haptics_send(u, 2); drain(); +} +int main(void) { @autoreleasepool { + queue = dispatch_queue_create("test.rumble", DISPATCH_QUEUE_SERIAL); + dispatch_queue_set_specific(queue, &queue, &queue, NULL); + dm_haptics_init(apply, 1); drain(); + TestEngine *left = [TestEngine new], *right = [TestEngine new]; + dispatch_sync(queue, ^{ + channels[0] = [NSMutableArray new]; add_engine(0, (id)left); add_engine(0, (id)right); + dm_haptics_route(0, 1); + }); drain(); + send_pair(0.2, 0.7); + assert(left.testPlayer.levels.lastObject.floatValue == 0.2f && right.testPlayer.levels.lastObject.floatValue == 0.7f); + send_pair(0, 0); + assert(left.testPlayer.levels.lastObject.floatValue == 0 && right.testPlayer.levels.lastObject.floatValue == 0); + assert(left.testPlayer.levels.count == 2); // short pulse ends immediately, without a minimum duration + send_pair(0.4, 0.5); + usleep(1100000); drain(); + assert(left.testPlayer.levels.lastObject.floatValue == 0.4f && left.testPlayer.stops == 0); // old idle timer cannot stop a new effect + assert(left.starts == 1); // reused player; no per-frame engine restart + dm_haptic_state stale = {0, dm_haptics_epoch(0), {{1, 160, 0, 320}, {1, 160, 0, 320}}}; + suspend(DM_HAPTICS_INACTIVE, YES); suspend(DM_HAPTICS_INACTIVE, NO); drain(); + int starts = left.starts; apply(stale); drain(); assert(left.starts == starts); + send_pair(0.3, 0.6); assert(left.starts == starts + 1); + void (^oldReset)(void) = left.resetHandler; + suspend(DM_HAPTICS_INACTIVE, YES); suspend(DM_HAPTICS_INACTIVE, NO); drain(); + send_pair(0.5, 0.5); oldReset(); drain(); + assert(dm_haptics_actual(0, 0).amplitude_low == 0.5f); // delayed callback from a stopped engine is stale + left.resetHandler(); drain(); drain(); + assert(dm_haptics_actual(0, 0).amplitude_low == 0 && right.testPlayer.levels.lastObject.floatValue == 0); + left.fail = YES; send_pair(0.8, 0.8); drain(); + assert(dm_haptics_actual(0, 0).amplitude_low == 0); // failed output is cleared, so a fresh identical send may retry + left.fail = NO; send_pair(0.8, 0.8); + assert(left.testPlayer.levels.lastObject.floatValue == 0.8f); + send_pair(0, 0); usleep(1100000); drain(); + assert(left.testPlayer.stops == 1); // idle hardware eventually released + dm_haptics_apple_shutdown(); + puts("Apple haptics: independent channels, pulse edges, sustained output, idle release, resets and stale work passed"); +} } diff --git a/dm/host/tests/t_hid.c b/dm/host/tests/t_hid.c index e906b2b..a21d933 100644 --- a/dm/host/tests/t_hid.c +++ b/dm/host/tests/t_hid.c @@ -107,9 +107,48 @@ static void t_npads(void) { CHECK(info[0] == 2 && info[1] == NPAD_HANDHELD, "handheld: the applet selects the handheld controller"); } +static dm_haptic_state vibration[DM_PLAYERS]; +static int vibration_calls; +static void vibration_sink(dm_haptic_state s) { vibration[s.player] = s; vibration_calls++; } +static void t_vibration(void) { + uint32_t h[2] = {0}, id = 0, info[2]; + dm_vibration v = {0.25, 160, 0.5, 320}, got; + g_mode = 1; ids((uint32_t[]){0, 1, NPAD_HANDHELD}, 3); dm_pad_players(3); + CHECK(!h_hid_VibPermit() && !h_hid_VibHandles(h, 2, &id, STYLE_FULLKEY), "no sink: no vibration devices"); + dm_haptics_init(vibration_sink, 1); dm_haptics_route(0, 1); dm_haptics_route(1, 1); + CHECK(h_hid_VibPermit() && h_hid_VibHandles(h, 2, &id, STYLE_FULLKEY) == 2, "two vibration motors available"); + CHECK(h[0] == 3 && h[1] == 0x10003, "SDK vibration handle bytes: style 3, npad 0, motors 0 and 1"); + CHECK(!h_hid_VibHandles(h, 2, &id, STYLE_HANDHELD), "wrong controller style gets no motors"); + CHECK(!h_hid_VibHandles(h, 2, &(uint32_t){2}, STYLE_FULLKEY), "disconnected player gets no motors"); + CHECK(h_hid_VibHandles(h, 1, &id, STYLE_FULLKEY) == 1, "handle count respects caller capacity"); + h_hid_SendVib(&h[0], &v); h_hid_VibActual(&got, &h[0]); + CHECK(!memcmp(&got, &v, sizeof v) && vibration[1].motor[0].amplitude_low == 0, "game value is retained for the addressed player only"); + h_hid_VibInfo(info, &h[1]); CHECK(info[0] == 1 && info[1] == 2, "right motor is a right LRA"); + uint32_t invalid[] = {0, 0x20003, 0x1000003, 0x203}; + vibration_calls = 0; + for (unsigned i = 0; i < sizeof invalid / sizeof *invalid; i++) h_hid_SendVib(&invalid[i], &v); + CHECK(!vibration_calls, "invalid style, motor, reserved byte and disconnected player are ignored"); + dm_vibration pair[] = {{0.1, 160, 0, 320}, {0.6, 160, 0, 320}}; + h_hid_SendVibs(h, pair, 2); + CHECK(vibration_calls == 1 && vibration[0].motor[0].amplitude_low == 0.1f && vibration[0].motor[1].amplitude_low == 0.6f, "HID batch publishes one complete pair"); + h_hid_InitVib(&h[0]); + CHECK(dm_haptics_actual(0, 0).amplitude_low == 0 && dm_haptics_actual(0, 1).amplitude_low == 0.6f, "initializing one motor leaves its partner intact"); + ids((uint32_t[]){0, NPAD_HANDHELD}, 2); + h_hid_SendVib(&h[0], &v); + CHECK(dm_haptics_actual(1, 0).amplitude_low == 0.25f, "one-player rumble reaches controllers whose input is merged into player 1"); + dm_haptics_route(0, 0); + CHECK(h_hid_VibHandles(h, 2, &id, STYLE_FULLKEY) == 2, "merged controller supplies rumble when player 1 has no physical output"); + g_mode = 0; id = NPAD_HANDHELD; + CHECK(h_hid_VibHandles(h, 2, &id, STYLE_HANDHELD) == 2 && h[0] == 0x2004 && h[1] == 0x12004, "handheld SDK vibration handles"); + CHECK(!vib_targets(3), "old docked handle is not valid in handheld mode"); + dm_haptics_route(1, 0); + CHECK(!h_hid_VibPermit(), "disconnecting the last output disables vibration"); +} + int main(void) { t_slots(); t_npads(); + t_vibration(); printf("hid players: %d checks, %d failed\n", checks, fails); return fails != 0; } diff --git a/dm/ios/Sources/DmGamepad.m b/dm/ios/Sources/DmGamepad.m index f772fb3..c3ac8c1 100644 --- a/dm/ios/Sources/DmGamepad.m +++ b/dm/ios/Sources/DmGamepad.m @@ -21,6 +21,7 @@ #include "dm_input.h" #include "front.h" #include "game_profile.h" +#include "haptics_apple.h" enum { B_A = 0, B_B, B_X, B_Y, B_LS, B_RS, B_L, B_R, B_ZL, B_ZR, B_PLUS, B_MINUS, B_LEFT, B_UP, B_RIGHT, B_DOWN, B_N }; enum { C_NONE = 0, C_STICK, C_DPAD, C_FACE, C_BTN, C_SWAP, C_GAME }; // which control a touch holds; C_GAME: none, the touch goes to the game's touch screen (finger slot << 8) @@ -416,6 +417,7 @@ static void placeLabel(CATextLayer *t, CGPoint c, CGFloat fs) { // ---- Physical controllers ---- static int32_t axis(float v) { return (int32_t)(fmaxf(-1.f, fminf(1.f, v)) * 32767); } static void read_pad(GCExtendedGamepad *g) { + dm_haptics_apple_controller(0, g.controller); uint64_t m = 0; #define K(btn, bit) if (btn.isPressed) m |= 1ull << bit K(g.buttonB, B_A); K(g.buttonA, B_B); K(g.buttonY, B_X); K(g.buttonX, B_Y); // by position: right A, bottom B, top X, left Y @@ -436,7 +438,7 @@ static void read_pad(GCExtendedGamepad *g) { any = YES; c.extendedGamepad.valueChangedHandler = ^(GCExtendedGamepad *g, GCControllerElement *el) { (void)el; read_pad(g); }; read_pad(c.extendedGamepad); } - if (!any) { pthread_mutex_lock(&g_mu); g_pad_btn = 0; g_pad_lx = g_pad_ly = g_pad_rx = g_pad_ry = 0; pthread_mutex_unlock(&g_mu); publish(); } + if (!any) { dm_haptics_apple_controller(0, nil); pthread_mutex_lock(&g_mu); g_pad_btn = 0; g_pad_lx = g_pad_ly = g_pad_rx = g_pad_ry = 0; pthread_mutex_unlock(&g_mu); publish(); } dispatch_async(dispatch_get_main_queue(), ^{ self->_padOff = any; self->_controls.hidden = any; [self reset]; }); } @end diff --git a/dm/ios/Sources/front_ios.m b/dm/ios/Sources/front_ios.m index 5fc9fb5..d01c66d 100644 --- a/dm/ios/Sources/front_ios.m +++ b/dm/ios/Sources/front_ios.m @@ -8,6 +8,7 @@ #include "front.h" #include "hle.h" #include "dm.h" +#include "haptics_apple.h" UIView *dm_gamepad_view(CGRect frame); void dm_gamepad_reset(void); @@ -71,6 +72,7 @@ static void launcher_done(void) { [g_root startGame]; } [self.view addSubview:self.game]; g_layer = (CAMetalLayer *)self.game.layer; if (getenv("DM_METAL_HUD") && atoi(getenv("DM_METAL_HUD"))) { g_layer.developerHUDProperties = @{@"mode": @"default"}; LOG("front end: Metal performance HUD on (DM_METAL_HUD)"); } // Apple's HUD over the game: frame rate, frame and GPU time, memory + dm_haptics_apple_init(); self.pad = dm_gamepad_view(self.view.bounds); self.pad.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; [self.view addSubview:self.pad]; diff --git a/docs/development.md b/docs/development.md index 5bedef8..3f41b7f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -81,6 +81,8 @@ The game language (`--language`, `LANGUAGE`; English by default) is what `nn::oe On the device, environment switches go in `Documents/env.txt` (one `KEY=VALUE` per line), and game files are also accepted at `Documents/romfs`. +Game rumble is enabled by default (`DM_RUMBLE=0` disables it); `DM_RUMBLE_GAIN=0..3` scales its strength (default 1, zero is silent). Both are launcher settings, independent of `DM_TOUCH_HAPTICS`. `hle_hid.c` decodes SDK vibration handles (style index / npad / motor bytes, as in libnx's `_hidGetVibrationDeviceHandles`) and routes them through the same player assignments as input. `hle_haptics.c` retains each motor's two amplitude/frequency bands, commits batches atomically and invalidates queued output on lifecycle or route changes. `haptics_apple.m` provides shared Core Haptics output for Mac controllers and iOS: left/right handle localities when available, otherwise one combined output; on iPhone, the phone is used while no controller is selected. A connected controller without haptics produces no game rumble. Continuous effects keep running until the game stops them; short effects are not extended. Engine resets, focus loss and interruptions clear the old values and require a fresh game signal. The frequency-to-sharpness mapping is approximate and physical feel / controller compatibility require device testing. The headless Mac host has no output. `make -C dm/host/tests GAME= run` covers state and HID routing without hardware. + ### A Mac-only build for development The Mac is the development host, not a platform for players: there is no Mac app. To test or debug a game on the Mac, `--platform mac` (`PLATFORM = 'mac'` in Spyder) builds only what running it in the Mac host needs, from the same NSP folder: