Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dm/games/settings.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ DM_RUMBLE toggle - 1 always Controls Game rumble level=basic
DM_RUMBLE_GAIN range 0:3:0.1 1.0 always Controls Rumble strength needs=DM_RUMBLE=1
DM_RUMBLE_MIN range 0:0.9:0.05 0 always Controls Rumble cutoff
DM_PAD_PRESS_TO_JOIN toggle - 0 always Controls Press a button to join
DM_SW2 toggle - 0 platform:mac Controls Switch 2 controllers Plays Joy-Con 2 and Pro Controller 2 over Bluetooth: hold Sync on one during the game to pair it. macOS asks once for Bluetooth access.
DM_SW2 toggle - 0 always Controls Switch 2 controllers Plays Joy-Con 2 and Pro Controller 2 over Bluetooth: hold Sync on one during the game to pair it (on iPhone and iPad, after each start). The system asks once for Bluetooth access.
DM_SCREEN_ASPECT choice auto=Match screen?platform:ios,1.43=iPhone Duo screen (1.43:1),0=16:9 (Switch) auto|0 DM_GAME_SCREEN_ASPECT!=0 Game Picture shape Renders about a quarter more pixels: a little slower and warmer.
DM_PRESENT_SNAP toggle - DM_GAME_PRESENT_SNAP gfxapi:nvn Performance Frame buffering Up to one frame more display latency.
DM_PSO_MTL4 toggle - DM_GAME_PSO_MTL4|1 gfxapi:nvn Performance Metal 4 pipelines Keeps a pipeline archive; uses more memory.
Expand Down
60 changes: 58 additions & 2 deletions dm/ios/Sources/DmGamepad.m
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
#include "pad_apple.h"
#include "pad_slots.h"
#include "ctrl_prompt.h"
#include "pad_switch2_apple.h"
#include <stdatomic.h>
#import "DmLayout.h"
#import "DmGameMenu.h"
Expand Down Expand Up @@ -569,6 +570,8 @@ - (void)refresh { // pressed state → fill, sticks → knob position
static const char *pad_name(GCController *c);
static void npad_name(int p, char *out, size_t cap);
static int cp_poll(void);
static int g_merge_s2_rumble; // DM_PAD_MERGE: player 1's rumble goes to the Switch 2 pads (merge_rumble_to_s2)
static void merge_rumble_to_s2(int on);
static void read_pad(GCExtendedGamepad *g) {
int p = g_merge ? 0 : pad_slot_of(&g_slots, (__bridge const void *)g.controller); if (p < 0 && !g_press_to_join) return;
_Static_assert(DM_BTN_A == 1u << B_A && DM_BTN_B == 1u << B_B && DM_BTN_X == 1u << B_X && DM_BTN_Y == 1u << B_Y, "face button bits");
Expand All @@ -587,18 +590,24 @@ static void read_pad(GCExtendedGamepad *g) {
npad_name(p, nb, sizeof nb); LOG("controller '%s': a button pressed: player %d (npad %s)", pad_name(c), p + 1, nb);
}
if (cp_poll()) return; // the controller applet's prompt has the controllers (front_ctrl_support)
if (g_merge_s2_rumble && m) merge_rumble_to_s2(0); // DM_PAD_MERGE: a button on this controller takes player 1's rumble back from a Switch 2 pad
dm_haptics_apple_controller(p, g.controller);
dm_pad_set_player(p, m, axis(g.leftThumbstick.xAxis.value), axis(g.leftThumbstick.yAxis.value), axis(g.rightThumbstick.xAxis.value), axis(g.rightThumbstick.yAxis.value));
}
// Home and Capture: without this iOS takes them as system gestures (Home opens the Games overlay, Capture a screenshot); the app gives them no function
static void pad_keep_system_buttons(GCController *c) { for (NSString *b in @[GCInputButtonHome, GCInputButtonShare]) c.physicalInputProfile.buttons[b].preferredSystemGestureState = GCSystemGestureStateDisabled; }
static const char *pad_name(GCController *c) { return c.vendorName.UTF8String ?: "?"; }
static void npad_name(int p, char *out, size_t cap) { uint32_t id = dm_player_npad(p); if (id == 0x20) snprintf(out, cap, "handheld"); else snprintf(out, cap, "%u", id); }
// Switch 2 controllers over Bluetooth LE (pad_switch2_apple.h, shared with the Mac front end): a pad is a player's controller as a GameController one is, in the same slots.
// iOS cannot give the transport this device's Bluetooth address, so a controller is used without bonding: hold Sync on it after each start of the game
static const dm_switch2_front g_s2front;
static int g_s2_off; // the app is in the background or resigning: no Switch 2 input reaches the game
static void pads_start(void) {
const char *e = getenv("DM_PAD_MERGE"); g_merge = e && atoi(e);
e = getenv("DM_PAD_PRESS_TO_JOIN"); g_press_to_join = !g_merge && e && atoi(e);
LOG("front end: %s", g_merge ? "every controller is player 1 (DM_PAD_MERGE)" : "each controller is a player of its own, the touch gamepad plays with player 1");
if (g_press_to_join) LOG("front end: a controller becomes a player at its first button press, the lowest player free (DM_PAD_PRESS_TO_JOIN)");
if (dm_switch2_start(&g_s2front)) LOG("front end: Switch 2 controllers: hold Sync on one to use it (no bonding on iOS: again after each start)");
}
// Called at start and on every connect / disconnect (gone: the controller that was just removed, nil otherwise); returns whether a controller is in use
static BOOL pads_merged(GCController *gone) {
Expand Down Expand Up @@ -667,10 +676,12 @@ - (void)cancel { g_cp_cancel = 1; cp_poll(); }
static void cp_buttons(int *a, int *b) {
*a = *b = 0;
for (GCController *c in GCController.controllers) { GCExtendedGamepad *g = c.extendedGamepad; if (g) { *a |= g.buttonA.isPressed; *b |= g.buttonB.isPressed; } }
for (s2_pad *d = dm_switch2_pads(); d; d = d->next) s2_pad_ab(&d->st, a, b); // Switch 2 controllers (pad_switch2_apple.h)
}
static int cp_waiting(void) { // connected controllers that are no player yet
int n = 0;
if (!g_merge) for (GCController *c in GCController.controllers) if (c.extendedGamepad && pad_slot_of(&g_slots, (__bridge const void *)c) < 0) n++;
if (!g_merge) for (s2_pad *d = dm_switch2_pads(); d; d = d->next) if (pad_slot_of(&g_slots, d) < 0) n++;
return n;
}
static UILabel *cp_label(const char *text, CGFloat size, UIFontWeight w, CGFloat alpha) {
Expand Down Expand Up @@ -741,6 +752,50 @@ int front_ctrl_support(int min, int max) {
dispatch_semaphore_wait(done, DISPATCH_TIME_FOREVER);
return g_cp_ok;
}
static int s2pad_player(s2_pad *d) { return g_merge ? 0 : pad_slot_of(&g_slots, d); }
// DM_PAD_MERGE: player 1's rumble follows the controller last used, as read_pad hands it to a GameController one (as front_mac.m)
static void merge_rumble_to_s2(int on) {
if (!g_merge || g_merge_s2_rumble == on) return;
g_merge_s2_rumble = on; dm_haptics_apple_set_sink(0, on ? dm_switch2_rumble : NULL);
}
static int s2pad_blocked(void) { return g_s2_off || cp_blocks_input(g_cp.phase); } // no input to the game (in the background, or the controller prompt up)
static void s2pad_read(s2_pad *d) {
if (g_s2_off) return;
int p = s2pad_player(d);
uint64_t m; int32_t st[4]; if (!s2_pad_input(&d->st, &m, st)) return;
if (p < 0) { // DM_PAD_PRESS_TO_JOIN=1: it joins at its first button, as a GameController one does (read_pad)
char nb[16];
if (!g_press_to_join || !m) return;
if ((p = pad_slot_take_lowest(&g_slots, d, d->name)) < 0) { LOG("controller '%s' (Bluetooth): a button pressed, but all %d players are taken", d->name, DM_PLAYERS); return; }
dm_pad_players(pad_slot_mask(&g_slots)); dm_haptics_apple_set_sink(p, dm_switch2_rumble); // its rumble goes over Bluetooth, not to a Core Haptics engine
npad_name(p, nb, sizeof nb); LOG("controller '%s' (Bluetooth): a button pressed: player %d (npad %s)", d->name, p + 1, nb);
[g_view padsChanged:nil]; // the touch gamepad hides
}
if (cp_poll()) return; // the controller applet's prompt has the controllers (front_ctrl_support)
if (m) merge_rumble_to_s2(1);
if (g_merge) dm_pad_set(m, st[0], st[1], st[2], st[3]); else dm_pad_set_player(p, m, st[0], st[1], st[2], st[3]);
}
static int s2pad_attach(s2_pad *d) { // 1: kept (a player, or waiting for its first button press); 0: no free player (pad_switch2_apple.h)
if (g_press_to_join && !g_merge) { LOG("controller '%s' (Bluetooth) connected: press a button to play (DM_PAD_PRESS_TO_JOIN)", d->name); cp_poll(); return 1; }
int p = g_merge ? 0 : pad_slot_take(&g_slots, d, d->name); char nb[16];
if (p < 0) { LOG("controller '%s' (Bluetooth) connected: all %d players are taken, not used", d->name, DM_PLAYERS); return 0; }
npad_name(p, nb, sizeof nb); LOG("controller '%s' (Bluetooth) connected: player %d (npad %s)", d->name, p + 1, nb);
if (!g_merge) { dm_pad_players(pad_slot_mask(&g_slots)); dm_haptics_apple_set_sink(p, dm_switch2_rumble); } // its rumble goes over Bluetooth, not to a Core Haptics engine
[g_view padsChanged:nil]; // the touch gamepad hides
cp_poll(); // one controller fewer to wait for
return 1;
}
static void s2pad_detach(s2_pad *d) { // d is already off the transport's list
if (g_merge) { LOG("controller '%s' (Bluetooth) disconnected", d->name); if (!dm_switch2_pads()) merge_rumble_to_s2(0); dm_pad_set(0, 0, 0, 0, 0); [g_view padsChanged:nil]; return; }
int p = pad_slot_free(&g_slots, d); char nb[16]; if (p < 0) { cp_poll(); return; } // one that had not joined: one fewer waiting
dm_haptics_apple_set_sink(p, NULL);
npad_name(p, nb, sizeof nb); LOG("controller '%s' (Bluetooth) disconnected: player %d (npad %s) is free", d->name, p + 1, nb);
dm_pad_set_player(p, 0, 0, 0, 0, 0);
dm_pad_players(pad_slot_mask(&g_slots));
[g_view padsChanged:nil];
cp_poll(); // one player fewer
}
static const dm_switch2_front g_s2front = {s2pad_attach, s2pad_detach, s2pad_read, s2pad_player, s2pad_blocked};

UIView *dm_gamepad_view(CGRect frame) { DmGamepad *v = [[DmGamepad alloc] initWithFrame:frame]; g_view = v; return v; }
void dm_gamepad_arrange(UIView *view, DmLayout layout) { [(DmGamepad *)view arrange:layout]; }
Expand All @@ -749,9 +804,10 @@ int front_ctrl_support(int min, int max) {
int dm_pad_layout(void) { return g_view ? [g_view padLayout] : 0; }
bool dm_pad_layout_offered(int layout) { return layout == 0 || layout == 1 || (layout == 2 && dm_opmode() == 0); }
void dm_pad_layout_set(int layout) { [g_view setPadLayout:layout]; }
void dm_gamepad_active(void) { dispatch_async(dispatch_get_main_queue(), ^{ for (GCController *c in GCController.controllers) dm_motion_rearm(c); }); } // On become active: motion_apple.h
void dm_gamepad_active(void) { dispatch_async(dispatch_get_main_queue(), ^{ for (GCController *c in GCController.controllers) dm_motion_rearm(c); g_s2_off = 0; dm_switch2_active(1); for (s2_pad *d = dm_switch2_pads(); d; d = d->next) s2pad_read(d); }); } // On become active: motion_apple.h
void dm_gamepad_reset(void) { // On resign active: release everything (a key held while switching away and released in background gets no "release")
dispatch_async(dispatch_get_main_queue(), ^{ [g_view reset]; });
dispatch_async(dispatch_get_main_queue(), ^{ [g_view reset]; g_s2_off = 1; dm_switch2_active(0); });
for (int p = 0; p < DM_PLAYERS; p++) { int s2 = 0; for (s2_pad *d = dm_switch2_pads(); d; d = d->next) if (s2pad_player(d) == p) s2 = 1; if (s2) dm_pad_set_player(p, 0, 0, 0, 0, 0); }
pthread_mutex_lock(&g_mu); g_shake_btn = 0; pthread_mutex_unlock(&g_mu); publish();
if (g_merge) dm_pad_set(0, 0, 0, 0, 0);
else for (int p = 0; p < DM_PLAYERS; p++) if (g_held[p]) dm_pad_set_player(p, 0, 0, 0, 0, 0);
Expand Down
1 change: 1 addition & 0 deletions dm/ios/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ targets:
DMLanguage: ${DM_LANG} # the game language code (main.m sets DM_LANG from it; set by build_ipa.sh)
LSSupportsGameMode: true
GCSupportsControllerUserInteraction: true # Physical controllers (GameController)
NSBluetoothAlwaysUsageDescription: "Plays Nintendo Switch 2 controllers over Bluetooth while you hold Sync on one." # pad_switch2_apple.m (DM_SW2); without it the transport stays off
GCSupportedGameControllers:
- ProfileName: ExtendedGamepad
CADisableMinimumFrameDurationOnPhone: true
Expand Down
2 changes: 1 addition & 1 deletion dm/tools/tests/test_launcher_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def test_iphone_switches_only_on_the_iphone(self): # the frame-rate counter an
self.assertEqual(build(BOTW, 'nvn', 'ios').keys(), ios.keys()) # ios when unset
self.assertEqual(set(ios) - set(mac), {'DM_SHOW_FPS', 'DM_TOUCH_HAPTICS', 'DM_PICTURE_FIT'})
self.assertIn('DM_RUMBLE', mac); self.assertIn('DM_METAL_HUD', mac)
self.assertEqual(set(mac) - set(ios), {'DM_FULLSCREEN', 'DM_SW2'}) # the Mac window's Display mode (front_mac.m reads 1 or 0); Switch 2 controllers (pad_switch2_apple.m, wired on the Mac only)
self.assertEqual(set(mac) - set(ios), {'DM_FULLSCREEN'}) # the Mac window's Display mode (front_mac.m reads 1 or 0)
s = mac['DM_FULLSCREEN']
self.assertEqual((s['kind'], s['default'], s['options'], s['level']), ('choice', '0', [['0', 'Window'], ['1', 'Fullscreen']], 'basic'))

Expand Down
4 changes: 4 additions & 0 deletions docs/playing.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ Joy-Con 2 and the Pro Controller 2 do not appear in macOS's Bluetooth settings:

The mouse pointer hides when you press a key or a controller button, or move a stick, while it is over the game picture. Moving the mouse shows it again, as does switching to another window or app.

### Switch 2 controllers (iPhone and iPad)

The same setting, **Switch 2 controllers**, plays Joy-Con 2 and the Pro Controller 2 on iPhone and iPad. iOS does not give an app the device's own Bluetooth address, which a controller needs to wake toward it, so a controller is used without pairing: **hold its Sync button after each start of the game**, until its player lights sweep. A press of a button alone does not connect it. The rest (pairs and singles, rumble, motion) is the Mac's code, and the touch controls hide while a controller is connected.

## Shaders on first sight

Short stutters the first time a new scene appears are normal: the app compiles graphics for it once and remembers them for the next launch. What it remembered, and what the game's list names, is built before the game starts, behind "Preparing shaders" and a progress bar when that takes more than half a second.
8 changes: 4 additions & 4 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ A controller's vibration weaker than this stays off, and the rest is spread over

Off by default: each controller is a player as soon as it connects, in the order the system lists them. On: a controller becomes a player only when you press one of its buttons, as the lowest player free, so the players choose their order and how many play. That press also reaches the game. When a game asks for more players than have joined, a prompt over the game waits for the others to press a button; B closes it.

### Switch 2 controllers

**Off** by default. On, the game takes Joy-Con 2 and Pro Controller 2 over Bluetooth: hold the small Sync button on one during the game, until its player lights sweep, to pair it. On the Mac it pairs with this Mac, and after that a press of any of its buttons connects it ([Switch 2 controllers](playing.md#switch-2-controllers-mac)); macOS asks for Bluetooth access the first time, and again after the app is packed again. On iPhone and iPad it is used without pairing, so hold Sync again after each start of the game ([on iPhone and iPad](playing.md#switch-2-controllers-iphone-and-ipad)); iOS asks for Bluetooth access the first time. In an `env.txt`: `DM_SW2=1`.

## Performance

These settings change how the app hands frames to the screen and how it prepares the GPU's work. The game's tested defaults are a good start; change them when a game stutters, runs hot, or you want less latency.
Expand Down Expand Up @@ -231,10 +235,6 @@ Turn off **Show this window at launch** to start the game at once with your choi

**Window** or **Fullscreen**: how the game's window opens. ⌃⌘F or the green button changes it while you play. In an `env.txt`: `DM_FULLSCREEN=1` or `0`.

### Switch 2 controllers

**Off** by default. On, the game takes Joy-Con 2 and Pro Controller 2 over Bluetooth: hold the small Sync button on one during the game, until its player lights sweep, to pair it with this Mac; after that a press of any of its buttons connects it ([Switch 2 controllers](playing.md#switch-2-controllers-mac)). macOS asks for Bluetooth access the first time, and again after the app is packed again. In an `env.txt`: `DM_SW2=1`.

### Cloud saves folder

**Choose a folder…** picks the iCloud Drive folder the game's save is synced through, the one the iPhone app chose in its launcher's **Cloud saves**, or the game's own folder inside it, named after its title ID ([Cloud saves](#cloud-saves)); **Turn off** stops it. Off by default. macOS may ask once whether the app may open iCloud Drive; if the game log says `this app may not open iCloud Drive`, allow it in System Settings › Privacy & Security › Files and Folders. In an `env.txt`: `DM_SAVE_SYNC=<folder>`, or `DM_SAVE_SYNC=icloud` for iCloud Drive › nxnative Saves; the window then shows it fixed.
Expand Down
Loading