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.
This commit is contained in:
seedds committed 2026-10-24 11:02:21 +08:00
1 parent ea3ed8690b
commit 3eb1d76f5d
16 files changed
+548 -19

No files matched your search

+2
View File
@@ -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.
+2
View File
@@ -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.
1 # Player-facing settings: the iOS launcher (dm/ios/Sources/Launcher.swift) shows them before the game starts. dm/games/settings.py writes one game's list into its app (dm/launcher.json,
13 DM_SHOW_FPS
14 DM_METAL_HUD
15 DM_TOUCH_HAPTICS
16 DM_RUMBLE
17 DM_RUMBLE_GAIN
+3 -3
View File
@@ -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
+5 -1
View File
@@ -14,6 +14,7 @@
#include "front.h"
#include "hle.h"
#include "pad_slots.h"
#include "haptics_apple.h"
#include <signal.h>
#include <math.h>
@@ -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<GCController *> *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
+7
View File
@@ -0,0 +1,7 @@
// Shared Core Haptics output for the Mac and iOS front ends. Configure routes before guest boot.
#pragma once
#import <GameController/GameController.h>
#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);
+182
View File
@@ -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 <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>
@interface DmRumbleChannel : NSObject
@property(nonatomic) CHHapticEngine *engine;
@property(nonatomic) id<CHHapticAdvancedPatternPlayer> 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<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 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<DmRumbleChannel *> *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);
});
}
+90
View File
@@ -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 <math.h>
#include <pthread.h>
#include <string.h>
_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};
}
+22
View File
@@ -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);
+50 -11
View File
@@ -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 <stdatomic.h>
@@ -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};
}
+9 -3
View File
@@ -4,7 +4,7 @@ B = build
# tests that use the game profile need GAME=<base game title ID> (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=<base game title ID>: $(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)
+54
View File
@@ -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 <assert.h>
#include <math.h>
#include <stdio.h>
#include <unistd.h>
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");
}
+76
View File
@@ -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 <assert.h>
@interface TestPlayer : NSObject
@property BOOL loopEnabled, isMuted;
@property int starts, stops;
@property NSMutableArray<NSNumber *> *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<CHHapticDynamicParameter *> *)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<CHHapticAdvancedPatternPlayer>)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");
} }
+39
View File
@@ -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;
}
+3 -1
View File
@@ -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
+2
View File
@@ -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];
+2
View File
@@ -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=<id> 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: