From 01a2d95aed1d401bfbc7e073b85c9d0e7ebfb43f Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Wed, 28 Oct 2026 21:27:09 -0400 Subject: [PATCH] Experiment with explicit iOS controller player assignment --- dm/host/pad_slots.h | 17 +- dm/host/tests/t_hid.c | 15 ++ dm/ios/Sources/DmGamepad.m | 205 +++++++++++++++++++-- dm/ios/tests/controller_assignment_check.m | 159 ++++++++++++++++ dm/ios/tools/build_controller_probe.py | 71 +++++++ docs/env_switches.tsv | 1 + docs/playing.md | 2 + docs/reference/services.md | 12 ++ 8 files changed, 467 insertions(+), 15 deletions(-) create mode 100644 dm/ios/tests/controller_assignment_check.m create mode 100644 dm/ios/tools/build_controller_probe.py diff --git a/dm/host/pad_slots.h b/dm/host/pad_slots.h index fa7058e553..7c20aeae39 100644 --- a/dm/host/pad_slots.h +++ b/dm/host/pad_slots.h @@ -1,6 +1,6 @@ // Player slots of the physical controllers (front_mac.m; pure, so tests/t_hid.c checks it without controllers): each controller holds one slot, slot p is player p + 1 -// (hle_hid.c dm_player_npad gives its npad). A connecting controller takes, in this order: a free slot whose last holder had its name (the same controller reconnected gets its -// player back; GameController gives a controller no identity across a reconnect, so two controllers of one model may trade places), player 1 when it is free, the lowest slot +// (hle_hid.c dm_player_npad gives its npad). A connecting controller takes, in this order: a free slot whose last holder had its model name (a reconnect hint, not identity: +// GameController gives no persistent identity, so identical models may trade places), player 1 when it is free, the lowest slot // no controller has held yet (so a slot stays kept for a controller that went away while others are in use), then the lowest free slot. With every slot held it gets none (-1). // A disconnected controller frees its slot and leaves its name on it. The owner is any pointer that stays the same while the controller is connected (the front end keeps the // GCController); a holder that never disconnects (the keyboard with DM_KBD_PLAYER=own) takes slot 0 first and keeps it. @@ -31,6 +31,19 @@ static int pad_slot_free(pad_slots *s, const void *owner) { int p = pad_slot_of(s, owner); if (p >= 0) s->owner[p] = NULL; return p; } +// Explicit session assignment: move to a free slot or swap its current owner. +// Reconnect hints follow the assigned controllers, not the old player numbers. +static int pad_slot_assign(pad_slots *s, const void *owner, int target) { + int from = pad_slot_of(s, owner); + if (from < 0 || target < 0 || target >= DM_PLAYERS) return -1; + if (from == target) return from; + char name[sizeof s->last[0]]; memcpy(name, s->last[from], sizeof name); + s->owner[from] = s->owner[target]; s->owner[target] = owner; + if (s->owner[from]) memcpy(s->last[from], s->last[target], sizeof name); + else memset(s->last[from], 0, sizeof name); + memcpy(s->last[target], name, sizeof name); + return from; +} static uint32_t pad_slot_mask(const pad_slots *s) { // bit p: slot p is held uint32_t m = 0; for (int i = 0; i < DM_PLAYERS; i++) if (s->owner[i]) m |= 1u << i; return m; diff --git a/dm/host/tests/t_hid.c b/dm/host/tests/t_hid.c index 58b9b3b804..f8f3868085 100644 --- a/dm/host/tests/t_hid.c +++ b/dm/host/tests/t_hid.c @@ -62,6 +62,21 @@ static void t_slots(void) { CHECK(pad_slot_mask(&s) == (1u << DM_PLAYERS) - 1, "every slot held"); CHECK(pad_slot_take(&s, &many[DM_PLAYERS - 3], "Pro Controller") == -1, "a ninth controller gets no player"); CHECK(pad_slot_take(&s, NULL, "x") == -1 && pad_slot_free(&s, NULL) == -1, "no owner, no slot"); + CHECK(pad_slot_assign(&s, &a, 1) == 0 && pad_slot_of(&s, &a) == 1 && pad_slot_of(&s, &b2) == 0, "explicit assignment swaps occupied players even when every slot is held"); + CHECK(!strcmp(s.last[1], "Xbox Wireless Controller") && !strcmp(s.last[0], "DualSense Wireless Controller"), "reconnect names follow both swapped controllers"); + CHECK(pad_slot_mask(&s) == (1u << DM_PLAYERS) - 1, "a full swap loses no connected player"); + pad_slot_free(&s, &a); + CHECK(pad_slot_take(&s, &a, "Xbox Wireless Controller") == 1, "a different-model reconnect follows its explicit assignment"); + pad_slots before = s; + CHECK(pad_slot_assign(&s, &a, -1) == -1 && pad_slot_assign(&s, &a, DM_PLAYERS) == -1 && pad_slot_assign(&s, NULL, 0) == -1 && !memcmp(&s, &before, sizeof s), "invalid assignments leave every slot and reconnect hint unchanged"); + CHECK(pad_slot_assign(&s, &a, 1) == 1 && !memcmp(&s, &before, sizeof s), "assigning the current player is a no-op"); + pad_slot_free(&s, &b2); + CHECK(pad_slot_assign(&s, &a, 0) == 1 && !s.owner[1] && !s.last[1][0] && pad_slot_of(&s, &a) == 0, "moving to a free player clears the old reservation"); + memset(&s, 0, sizeof s); + pad_slot_take(&s, &a, "Same model"); pad_slot_take(&s, &b, "Same model"); + CHECK(pad_slot_assign(&s, &a, 1) == 0 && pad_slot_of(&s, &a) == 1 && pad_slot_of(&s, &b) == 0, "identical model names can be explicitly swapped by live connection"); + pad_slot_free(&s, &a); pad_slot_free(&s, &b); + CHECK(pad_slot_take(&s, &b2, "Same model") == 0, "duplicate-name reconnect is a free-name hint, not persistent identity"); memset(&s, 0, sizeof s); // DM_KBD_PLAYER=own: the keyboard holds player 1 for good CHECK(pad_slot_take(&s, &kbd, "keyboard") == 0 && pad_slot_take(&s, &a, "Xbox Wireless Controller") == 1, "with the keyboard on its own the first controller is player 2"); } diff --git a/dm/ios/Sources/DmGamepad.m b/dm/ios/Sources/DmGamepad.m index c41491e5df..1f989be91f 100644 --- a/dm/ios/Sources/DmGamepad.m +++ b/dm/ios/Sources/DmGamepad.m @@ -122,9 +122,18 @@ static void phone_motion_start(void) { int dm_opmode(void); // hle_oe.c: 0 = handheld @interface DmGamepad : UIView +- (void)showPlayers; +- (void)controllerMoved:(GCController *)controller player:(int)player; +- (void)syncPlayers:(BOOL)any; @end static __weak DmGamepad *g_view; static void pads_start(void); // Physical controllers, below +static int g_assign; +static BOOL g_assigning; +static BOOL player_one_held(void); +static void show_players(DmGamepad *view); +static void controller_identified(GCController *controller); +static void controllers_refreshed(void); @implementation DmGamepad { NSMapTable *_owner; // touch → control (with the key for C_BTN or the stick for C_STICK, << 8) @@ -141,6 +150,7 @@ @implementation DmGamepad { BOOL _full, _tap; // the 3D layout / the Tap layout is current CALayer *_controls; // holds every control layer: hidden while a physical controller is connected (the view itself stays, for game touches) BOOL _padOff; // a physical controller is connected: every touch goes to the game + UIButton *_players; // explicit opt-in assignment, separate from the hidden touch-control layer UITouch *_game[16]; // touches on the game's touch screen by finger slot } @@ -193,6 +203,13 @@ - (instancetype)initWithFrame:(CGRect)f { [nc addObserver:self selector:@selector(padsChanged:) name:GCControllerDidConnectNotification object:nil]; [nc addObserver:self selector:@selector(padsChanged:) name:GCControllerDidDisconnectNotification object:nil]; pads_start(); + if (g_assign) { + _players = [UIButton buttonWithType:UIButtonTypeSystem]; + _players.configuration = UIButtonConfiguration.tintedButtonConfiguration; + [_players setTitle:@"Players" forState:UIControlStateNormal]; + [_players addTarget:self action:@selector(showPlayers) forControlEvents:UIControlEventTouchUpInside]; + [self addSubview:_players]; + } [self padsChanged:nil]; motion_start(); phone_motion_start(); @@ -407,6 +424,8 @@ static void placeLabel(CATextLayer *t, CGPoint c, CGFloat fs) { - (void)layoutSubviews { [super layoutSubviews]; + CGRect safe = self.safe; + _players.frame = CGRectMake(CGRectGetMidX(safe) - 64, CGRectGetMinY(safe) + 44, 128, 36); UIWindowScene *ws = self.window.windowScene; if (ws) atomic_store(&g_iface_orient, (int)ws.effectiveGeometry.interfaceOrientation); CGFloat u = self.u; [CATransaction begin]; [CATransaction setDisableActions:YES]; @@ -447,16 +466,19 @@ - (void)refresh { // pressed state → fill, sticks → knob position } // ---- Physical controllers ---- -// Each controller with an extended gamepad profile is a player of its own, as on the Mac (front_mac.m): it holds a slot (pad_slots.h: player 1 first, a reconnected controller -// gets its player back) and goes to dm_pad_set_player, and hle_hid.c reports player n on its own npad while the slot is held (dm_pad_players). Player 1's controller is ORed +// Each controller with an extended gamepad profile is a player of its own, as on the Mac (front_mac.m): it holds a slot (pad_slots.h: player 1 first, model-name reconnect +// hints) and goes to dm_pad_set_player, and hle_hid.c reports player n on its own npad while the slot is held (dm_pad_players). Player 1's controller is ORed // with the touch gamepad; a one-player game that supports only No1 / Handheld still sees every controller as player 1 (hle_hid.c). Each player has its controller's rumble and // gyro; the phone's gyro is player 1's. With DM_PAD_MERGE=1 every controller is player 1, as before (the last one moved wins) static pad_slots g_slots; static GCController *g_held[DM_PLAYERS]; // the controller of each slot, kept alive while it holds it (the slot table keys on its pointer) +static uint64_t g_route_generation[DM_PLAYERS]; static int g_merge; static int32_t axis(float v) { return (int32_t)(fmaxf(-1.f, fminf(1.f, v)) * 32767); } static void read_pad(GCExtendedGamepad *g) { + if (g_assign && !NSThread.isMainThread) { dispatch_async(dispatch_get_main_queue(), ^{ read_pad(g); }); return; } int p = g_merge ? 0 : pad_slot_of(&g_slots, (__bridge const void *)g.controller); if (p < 0) return; + if (g_assigning) return; dm_haptics_apple_controller(p, g.controller); uint64_t m = 0; #define K(btn, bit) if (btn.isPressed) m |= 1ull << bit @@ -474,8 +496,58 @@ static void read_pad(GCExtendedGamepad *g) { 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); } static void pads_start(void) { const char *e = getenv("DM_PAD_MERGE"); g_merge = e && atoi(e); + e = getenv("DM_PAD_ASSIGN"); g_assign = !g_merge && e && !strcmp(e, "1"); 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"); } +static BOOL player_one_held(void) { return g_held[0] != nil; } +static void pad_changed(GCExtendedGamepad *g) { + if (g_assign) { + if (!NSThread.isMainThread) { dispatch_async(dispatch_get_main_queue(), ^{ pad_changed(g); }); return; } + int p = pad_slot_of(&g_slots, (__bridge const void *)g.controller); + if (p >= 0) controller_identified(g.controller); + } + read_pad(g); +} +static void pad_release(int p) { + GCController *c = g_held[p]; + c.extendedGamepad.valueChangedHandler = nil; + g_route_generation[p]++; + dm_motion_detach(c, p); dm_haptics_apple_controller(p, nil); + dm_pad_set_player(p, 0, 0, 0, 0, 0); +} +static void pad_attach(GCController *c, int p) { + pad_keep_system_buttons(c); + if (g_assign) { c.handlerQueue = dispatch_get_main_queue(); c.playerIndex = p < 4 ? (GCControllerPlayerIndex)p : GCControllerPlayerIndexUnset; } + c.extendedGamepad.valueChangedHandler = ^(GCExtendedGamepad *g, GCControllerElement *el) { (void)el; pad_changed(g); }; + if (!g_assigning) { + dm_motion_attach(c, p); + if (g_assign && c.motion.valueChangedHandler) { + GCMotionValueChangedHandler handler = c.motion.valueChangedHandler; + uint64_t generation = g_route_generation[p]; + c.motion.valueChangedHandler = ^(GCMotion *m) { + if (NSThread.isMainThread && g_held[p] == c && g_route_generation[p] == generation) handler(m); + }; + } + } +} +// Main-queue transaction: publish releases at both old players before changing +// ownership, then reroute motion/haptics and publish each controller at its new player. +static BOOL assign_controller(GCController *c, int target) { + if (!g_assign || !NSThread.isMainThread || target < 0 || target >= DM_PLAYERS) return NO; + int from = pad_slot_of(&g_slots, (__bridge const void *)c); + if (from < 0) return NO; + if (from == target) return YES; + GCController *other = g_held[target]; + pad_release(from); if (other) pad_release(target); + pad_slot_assign(&g_slots, (__bridge const void *)c, target); + g_held[from] = other; g_held[target] = c; + for (int p = 0; p < DM_PLAYERS; p++) if (p == from || p == target) if (g_held[p]) { + pad_attach(g_held[p], p); read_pad(g_held[p].extendedGamepad); + } + dm_pad_players(pad_slot_mask(&g_slots)); [g_view syncPlayers:YES]; controllers_refreshed(); + LOG("controller '%s' assigned to player %d%s", pad_name(c), target + 1, other ? " (swapped)" : ""); + return YES; +} // 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) { if (gone) { gone.extendedGamepad.valueChangedHandler = nil; dm_motion_detach(gone, 0); } @@ -490,38 +562,145 @@ static BOOL pads_merged(GCController *gone) { if (!any) { dm_haptics_apple_controller(0, nil); dm_pad_set(0, 0, 0, 0, 0); } return any; } -static BOOL pads_slotted(GCController *gone) { - NSArray *now = GCController.controllers; char nb[16]; +static BOOL pads_slotted_now(NSArray *now, GCController *gone) { + 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_motion_detach(g_held[p], p); // player 1's: the phone's gyro (if on) takes over from its next sample - dm_haptics_apple_controller(p, nil); + pad_release(p); // player 1's phone gyro can take over from its next sample + if (g_assign) g_held[p].playerIndex = GCControllerPlayerIndexUnset; 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 } for (GCController *c in now) if (c.extendedGamepad && c != gone && pad_slot_of(&g_slots, (__bridge const void *)c) < 0) { int p = pad_slot_take(&g_slots, (__bridge const void *)c, pad_name(c)); if (p < 0) { LOG("controller '%s' connected: all %d players are taken, not used", pad_name(c), DM_PLAYERS); continue; } - g_held[p] = c; pad_keep_system_buttons(c); - c.extendedGamepad.valueChangedHandler = ^(GCExtendedGamepad *g, GCControllerElement *el) { (void)el; read_pad(g); }; - dm_motion_attach(c, p); + g_held[p] = c; pad_attach(c, p); npad_name(p, nb, sizeof nb); LOG("controller '%s' connected: player %d (npad %s)%s", pad_name(c), p + 1, nb, c.motion.hasRotationRate ? ", with motion (gyro)" : ""); read_pad(c.extendedGamepad); } dm_pad_players(pad_slot_mask(&g_slots)); return pad_slot_mask(&g_slots) != 0; } +- (void)showPlayers { show_players(self); } +- (void)syncPlayers:(BOOL)any { + _padOff = g_assign ? player_one_held() : any; + _controls.hidden = _padOff; _players.hidden = !any; [self reset]; +} +- (void)controllerMoved:(GCController *)controller player:(int)player { + if (player < 0) { [_players setTitle:@"Players" forState:UIControlStateNormal]; _players.accessibilityLabel = nil; return; } + [_players setTitle:[NSString stringWithFormat:@"Players · %d", player + 1] forState:UIControlStateNormal]; + _players.accessibilityLabel = [NSString stringWithFormat:@"Players. Last input: %@, player %d", controller.vendorName ?: @"Controller", player + 1]; +} - (void)padsChanged:(NSNotification *)note { + if (g_assign && !NSThread.isMainThread) { dispatch_async(dispatch_get_main_queue(), ^{ [self padsChanged:note]; }); return; } GCController *c = note.object, *gone = [note.name isEqual:GCControllerDidDisconnectNotification] ? c : nil; if (c && (!c.extendedGamepad || g_merge)) LOG("controller %s: %s%s", gone ? "disconnected" : "connected", pad_name(c), c.extendedGamepad ? "" : " (no extended gamepad profile: not used)"); - BOOL any = g_merge ? pads_merged(gone) : pads_slotted(gone); - dispatch_async(dispatch_get_main_queue(), ^{ self->_padOff = any; self->_controls.hidden = any; [self reset]; }); // the touch gamepad hides while any controller is in use + BOOL any = g_merge ? pads_merged(gone) : pads_slotted_now(GCController.controllers, gone); + if (g_assign) { [self syncPlayers:any]; controllers_refreshed(); } + else dispatch_async(dispatch_get_main_queue(), ^{ [self syncPlayers:any]; }); +} +@end + +// The sheet is intentionally session-only. Model names are display labels, not +// stable identities; moving a control highlights the live connection to choose. +@interface DmControllerSheet : UITableViewController +@property(nonatomic, strong) GCController *selectedController; +@end +static __weak DmControllerSheet *g_controller_sheet; +static __weak GCController *g_last_controller; +static int g_last_player = -1; +static NSArray *assigned_controllers(void) { + NSMutableArray *controllers = [NSMutableArray array]; + for (int p = 0; p < DM_PLAYERS; p++) if (g_held[p]) [controllers addObject:g_held[p]]; + return controllers; +} +static void assignment_started(void) { + g_assigning = YES; [g_view reset]; + for (int p = 0; p < DM_PLAYERS; p++) if (g_held[p]) { + g_route_generation[p]++; dm_motion_detach(g_held[p], p); + dm_haptics_apple_controller(p, nil); dm_pad_set_player(p, 0, 0, 0, 0, 0); + } +} +static void assignment_finished(void) { + if (!g_assigning) return; + g_assigning = NO; + for (int p = 0; p < DM_PLAYERS; p++) if (g_held[p]) { pad_attach(g_held[p], p); read_pad(g_held[p].extendedGamepad); } + [g_view reset]; +} +static void controller_identified(GCController *controller) { + int player = pad_slot_of(&g_slots, (__bridge const void *)controller); + if (g_last_controller == controller && g_last_player == player) return; + g_last_controller = controller; g_last_player = player; + [g_view controllerMoved:controller player:player]; [g_controller_sheet.tableView reloadData]; +} +static void controllers_refreshed(void) { + g_last_player = pad_slot_of(&g_slots, (__bridge const void *)g_last_controller); + [g_view controllerMoved:g_last_controller player:g_last_player]; + [g_controller_sheet.tableView reloadData]; + DmControllerSheet *top = (DmControllerSheet *)g_controller_sheet.navigationController.topViewController; + [top.tableView reloadData]; +} +@implementation DmControllerSheet +- (void)viewDidLoad { + [super viewDidLoad]; self.title = self.selectedController ? @"Assign player" : @"Controllers"; + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(done)]; +} +- (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [self.tableView reloadData]; } +- (void)done { [self.navigationController dismissViewControllerAnimated:YES completion:^{ assignment_finished(); }]; } +- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController { (void)presentationController; assignment_finished(); } +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + (void)tableView; (void)section; return self.selectedController ? DM_PLAYERS : assigned_controllers().count; +} +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section { + (void)tableView; (void)section; + return self.selectedController ? @"Choosing an occupied player swaps both controllers. Touch controls always belong to Player 1." : + @"Move a control to identify its row. Choose a controller, then its player. Identical models can change order after reconnecting. Controller input is released while this sheet is open."; +} +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + (void)tableView; + UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:nil]; + GCController *c; int p; + if (self.selectedController) { p = (int)indexPath.row; c = g_held[p]; } + else { NSArray *controllers = assigned_controllers(); if (indexPath.row >= (NSInteger)controllers.count) return cell; + c = controllers[indexPath.row]; p = pad_slot_of(&g_slots, (__bridge const void *)c); } + cell.textLabel.text = [NSString stringWithFormat:@"Player %d", p + 1]; + NSString *name = c.vendorName ?: @"Controller"; + if (self.selectedController) { + cell.detailTextLabel.text = c == self.selectedController ? @"Currently assigned" : c ? [@"Swap with " stringByAppendingString:name] : @"Available"; + cell.accessoryType = c == self.selectedController ? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone; + } else { + cell.detailTextLabel.text = c == g_last_controller ? [name stringByAppendingString:@" · last input"] : name; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + } + return cell; +} +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (self.selectedController) { + assign_controller(self.selectedController, (int)indexPath.row); + [self.navigationController popToRootViewControllerAnimated:YES]; + } else { + NSArray *controllers = assigned_controllers(); if (indexPath.row >= (NSInteger)controllers.count) return; + DmControllerSheet *next = [[DmControllerSheet alloc] initWithStyle:UITableViewStyleInsetGrouped]; + next.selectedController = controllers[indexPath.row]; [self.navigationController pushViewController:next animated:YES]; + } } @end +static void show_players(DmGamepad *view) { + if (!g_assign || g_assigning) return; + UIViewController *parent = view.window.rootViewController; if (!parent) return; + while (parent.presentedViewController) parent = parent.presentedViewController; + assignment_started(); + DmControllerSheet *sheet = [[DmControllerSheet alloc] initWithStyle:UITableViewStyleInsetGrouped]; + g_controller_sheet = sheet; + UINavigationController *navigation = [[UINavigationController alloc] initWithRootViewController:sheet]; + navigation.modalPresentationStyle = UIModalPresentationPageSheet; + navigation.presentationController.delegate = sheet; + [parent presentViewController:navigation animated:YES completion:nil]; +} UIView *dm_gamepad_view(CGRect frame) { DmGamepad *v = [[DmGamepad alloc] initWithFrame:frame]; g_view = v; return v; } void dm_gamepad_reset(void) { // On resign active: release everything (a key held while switching away and released in background gets no "release") + if (g_assign && !NSThread.isMainThread) { dispatch_async(dispatch_get_main_queue(), ^{ dm_gamepad_reset(); }); return; } dispatch_async(dispatch_get_main_queue(), ^{ [g_view reset]; }); 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); diff --git a/dm/ios/tests/controller_assignment_check.m b/dm/ios/tests/controller_assignment_check.m new file mode 100644 index 0000000000..323cc82ee0 --- /dev/null +++ b/dm/ios/tests/controller_assignment_check.m @@ -0,0 +1,159 @@ +// Actual production slot/adapter code with public writable snapshot controllers. +// Host endpoints record routing only; no game, discovery, sensors or rumble run. +#include "../Sources/DmGamepad.m" +#include + +uint64_t g_t0_ns; +uint64_t now_ns(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return (uint64_t)t.tv_sec * 1000000000ull + t.tv_nsec; } +static uint64_t buttons[DM_PLAYERS]; +static GCController *rumble[DM_PLAYERS]; +static unsigned cleared[DM_PLAYERS], motion_samples; +static int motion_player; +static uint32_t player_mask; +static NSMutableArray *writes; +void dm_input_set(uint64_t b, int32_t x, int32_t y, int32_t r, int32_t s) { (void)b; (void)x; (void)y; (void)r; (void)s; } +void dm_pad_set_player(int p, uint64_t b, int32_t x, int32_t y, int32_t r, int32_t s) { + (void)x; (void)y; (void)r; (void)s; buttons[p] = b; [writes addObject:@[@(p),@(b)]]; +} +void dm_pad_set(uint64_t b, int32_t x, int32_t y, int32_t r, int32_t s) { dm_pad_set_player(0,b,x,y,r,s); } +void dm_pad_players(uint32_t m) { player_mask = m; } +void dm_motion_set(int p, const float a[3], const float r[3]) { (void)a; (void)r; motion_samples++; motion_player = p; } +void dm_motion_clear(int p) { cleared[p]++; } +void dm_motion_set_switch(int p, const float a[3], const float r[3]) { (void)p; (void)a; (void)r; } +void dm_haptics_apple_controller(int p, GCController *c) { rumble[p] = c; } +void dm_touch_set(const dm_touch *touches, int n) { (void)touches; (void)n; } +uint32_t dm_player_npad(int p) { return (uint32_t)p; } +int dm_opmode(void) { return 0; } + +@interface AssignmentMotion : NSObject +@property BOOL hasRotationRate, hasGravityAndUserAcceleration, sensorsRequireManualActivation, sensorsActive; +@property GCAcceleration acceleration, gravity, userAcceleration; +@property GCRotationRate rotationRate; +@property(copy) GCMotionValueChangedHandler valueChangedHandler; +@end +@implementation AssignmentMotion +@end +@interface AssignmentController : NSObject +@property AssignmentMotion *motion; +@property GCExtendedGamepad *extendedGamepad; +@property GCPhysicalInputProfile *physicalInputProfile; +@property dispatch_queue_t handlerQueue; +@property GCControllerPlayerIndex playerIndex; +@property NSString *vendorName; +@end +@implementation AssignmentController +@end + +static NSMutableArray *checks; +static void check(NSString *name, BOOL passed) { + [checks addObject:@{@"name":name,@"passed":@(passed)}]; + fprintf(stderr,"[assignment] %s: %s\n",name.UTF8String,passed ? "PASS" : "FAIL"); +} +static void verify_assignment(void) { + checks = [NSMutableArray array]; writes = [NSMutableArray array]; + unsetenv("DM_PAD_ASSIGN"); unsetenv("DM_PAD_MERGE"); pads_start(); + check(@"assignment is absent by default", !g_assign); + GCController *a = GCController.controllerWithExtendedGamepad, *b = GCController.controllerWithExtendedGamepad; + [a.extendedGamepad.buttonA setValue:1]; [b.extendedGamepad.buttonB setValue:1]; + GCControllerPlayerIndex original = a.playerIndex; + pads_slotted_now(@[a,b],nil); + check(@"default connection order still assigns players 1 and 2", g_held[0] == a && g_held[1] == b && player_mask == 3); + check(@"default positional button mapping is unchanged", buttons[0] == DM_BTN_B && buttons[1] == DM_BTN_A); + check(@"default mode leaves controller player indicators alone", a.playerIndex == original); + check(@"default mode rejects explicit assignment", !assign_controller(a,1) && g_held[0] == a); + pads_slotted_now(@[],nil); memset(&g_slots,0,sizeof g_slots); + setenv("DM_PAD_ASSIGN","1",1); setenv("DM_PAD_MERGE","1",1); pads_start(); + check(@"merge mode keeps assignment disabled", g_merge && !g_assign); + unsetenv("DM_PAD_MERGE"); pads_start(); check(@"exact opt-in enables assignment", g_assign); + pads_slotted_now(@[a,b],nil); + check(@"opt-in input and motion callbacks use the main queue", a.handlerQueue == dispatch_get_main_queue() && b.handlerQueue == dispatch_get_main_queue()); + check(@"supported player indicators follow initial slots", a.playerIndex == GCControllerPlayerIndex1 && b.playerIndex == GCControllerPlayerIndex2); + [writes removeAllObjects]; + check(@"occupied assignment succeeds as a swap", assign_controller(a,1)); + check(@"both old held states are released before either new state", writes.count >= 4 && [writes[0][1] unsignedLongLongValue] == 0 && [writes[1][1] unsignedLongLongValue] == 0 && [writes[0][0] intValue] != [writes[1][0] intValue]); + check(@"both connected owners move without a lost player", g_held[0] == b && g_held[1] == a && pad_slot_of(&g_slots,(__bridge const void *)a) == 1 && player_mask == 3); + check(@"held buttons are republished only at their new players", buttons[0] == DM_BTN_A && buttons[1] == DM_BTN_B); + check(@"haptic routes move with both controllers", rumble[0] == b && rumble[1] == a); + check(@"both old motion sources are cleared", cleared[0] && cleared[1]); + check(@"player indicators follow the swap", a.playerIndex == GCControllerPlayerIndex2 && b.playerIndex == GCControllerPlayerIndex1); + NSUInteger count = writes.count; + check(@"same-player assignment emits no releases", assign_controller(a,1) && writes.count == count); + check(@"invalid targets emit no releases", !assign_controller(a,-1) && !assign_controller(a,DM_PLAYERS) && writes.count == count); + __block BOOL worker_result = YES; + dispatch_semaphore_t worker_done = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED,0), ^{ worker_result = assign_controller(a,2); dispatch_semaphore_signal(worker_done); }); + long waited = dispatch_semaphore_wait(worker_done,dispatch_time(DISPATCH_TIME_NOW,5*NSEC_PER_SEC)); + check(@"a worker cannot mutate the slot transaction", waited == 0 && !worker_result && g_held[1] == a && writes.count == count); + check(@"moving to a free player succeeds", assign_controller(a,7)); + check(@"the vacated player releases buttons and haptics", buttons[1] == 0 && rumble[1] == nil && !g_held[1]); + check(@"player 8 input and connection mask are published", buttons[7] == DM_BTN_B && rumble[7] == a && player_mask == 0x81); + check(@"players beyond the four Apple indicators stay explicitly unlit", a.playerIndex == GCControllerPlayerIndexUnset); + assign_controller(b,3); + check(@"touch player 1 can remain available with controllers on later players", !player_one_held() && player_mask == 0x88); + pads_slotted_now(@[b],a); + check(@"disconnect releases the assigned player", !g_held[7] && !buttons[7] && !rumble[7] && player_mask == 8); + GCController *reconnected = GCController.controllerWithExtendedGamepad; + pads_slotted_now(@[b,reconnected],nil); + check(@"reconnect uses the explicit assignment name hint", g_held[7] == reconnected && g_held[3] == b); + pad_changed(b.extendedGamepad); + check(@"moving a control identifies its live connection", g_last_controller == b); + DmControllerSheet *sheet = [[DmControllerSheet alloc] initWithStyle:UITableViewStyleInsetGrouped]; + check(@"the sheet lists only assigned controllers", [sheet tableView:sheet.tableView numberOfRowsInSection:0] == 2); + UITableViewCell *row = [sheet tableView:sheet.tableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]]; + check(@"the sheet names the player and last input without a persistent ID", [row.textLabel.text isEqual:@"Player 4"] && [row.detailTextLabel.text containsString:@"last input"]); + sheet.selectedController = b; + check(@"every guest player is an explicit assignment choice", [sheet tableView:sheet.tableView numberOfRowsInSection:0] == DM_PLAYERS); + row = [sheet tableView:sheet.tableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:7 inSection:0]]; + check(@"an occupied choice explicitly says swap", [row.detailTextLabel.text hasPrefix:@"Swap with "]); + assignment_started(); + check(@"opening assignment releases live game input and haptics", g_assigning && !buttons[3] && !buttons[7] && !rumble[3] && !rumble[7]); + read_pad(b.extendedGamepad); + check(@"input stays released while the sheet owns controls", !buttons[3]); + assign_controller(b,2); assignment_finished(); + check(@"closing restores held input at the newly assigned player", !g_assigning && buttons[2] == DM_BTN_A && buttons[3] == 0 && rumble[2] == b); + pads_slotted_now(@[],nil); memset(&g_slots,0,sizeof g_slots); + + AssignmentController *sensor = [AssignmentController new]; sensor.vendorName = @"Synthetic motion controller"; + sensor.motion = [AssignmentMotion new]; sensor.motion.hasRotationRate = YES; + sensor.motion.sensorsRequireManualActivation = YES; sensor.motion.acceleration = (GCAcceleration){0,0,-1}; + GCController *c = (id)sensor; pad_slot_take(&g_slots,(__bridge const void *)c,"Synthetic motion controller"); g_held[0] = c; + pad_attach(c,0); GCMotionValueChangedHandler stale = sensor.motion.valueChangedHandler; stale((id)sensor.motion); + check(@"synthetic motion starts at its assigned player", motion_samples == 1 && motion_player == 0 && sensor.motion.sensorsActive); + assign_controller(c,2); unsigned samples = motion_samples; + stale((id)sensor.motion); + check(@"a queued old gyro callback cannot write the vacated player", motion_samples == samples); + sensor.motion.valueChangedHandler((id)sensor.motion); + check(@"fresh gyro data follows reassignment", motion_samples == samples + 1 && motion_player == 2); + assign_controller(c,0); samples = motion_samples; stale((id)sensor.motion); + check(@"an old gyro generation stays invalid after moving back", motion_samples == samples); + pads_slotted_now(@[],nil); + check(@"disconnect stops and clears synthetic motion", !sensor.motion.sensorsActive && !sensor.motion.valueChangedHandler && !g_held[0]); +} + +@interface AssignmentScene : UIResponder +@property(nonatomic, strong) UIWindow *window; +@end +@implementation AssignmentScene +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options { + (void)session; (void)options; self.window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene]; + self.window.rootViewController = [UIViewController new]; [self.window makeKeyAndVisible]; + static BOOL started; if (started) return; started = YES; + verify_assignment(); + NSInteger failed = 0; for (NSDictionary *item in checks) if (![item[@"passed"] boolValue]) failed++; + NSDictionary *report = @{@"checks":checks,@"failed":@(failed),@"physicalControllersTested":@NO,@"UIAutomationPerformed":@NO, + @"systemVersion":UIDevice.currentDevice.systemVersion}; + NSURL *documents = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]; + [[NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:NULL] + writeToURL:[documents URLByAppendingPathComponent:@"results.json"] options:NSDataWritingAtomic error:NULL]; + if (failed) { fflush(stderr); exit(1); } +} +@end +@interface AssignmentApp : UIResponder +@end +@implementation AssignmentApp +@end +int main(int argc, char **argv) { @autoreleasepool { + NSURL *documents = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]; + freopen([[documents URLByAppendingPathComponent:@"assignment.log"] fileSystemRepresentation],"w",stderr); setvbuf(stderr,NULL,_IONBF,0); + return UIApplicationMain(argc,argv,nil,NSStringFromClass(AssignmentApp.class)); +} } diff --git a/dm/ios/tools/build_controller_probe.py b/dm/ios/tools/build_controller_probe.py new file mode 100644 index 0000000000..6e3b2ddf3b --- /dev/null +++ b/dm/ios/tools/build_controller_probe.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Build an unsigned controller-assignment fixture with synthetic controllers. + +GAME selects only source constants. No games, keys, signing, discovery, motion +sensors or device actions. Run separately on an authorized test device. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +REPO = Path(__file__).resolve().parents[3] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', required=True, type=Path, help='new scratch directory') + args = parser.parse_args() + game = os.environ.get('GAME') or os.environ.get('DM_GAME') + if not game: + parser.error('GAME or DM_GAME must select a source profile') + output = args.output.resolve() + output.mkdir(parents=True) + include = output / 'Include' + include.mkdir() + header = include / 'game_profile.h' + subprocess.run([sys.executable, str(REPO / 'dm/games/game.py'), 'header', game, str(header)], check=True) + project = { + 'name': 'ControllerVerification', 'options': {'deploymentTarget': {'iOS': '26.0'}}, + 'targets': {'ControllerVerification': { + 'type': 'application', 'platform': 'iOS', + 'sources': [{'path': str(REPO / 'dm/ios/tests/controller_assignment_check.m')}], + 'dependencies': [{'sdk': name + '.framework'} for name in + ('UIKit', 'Foundation', 'QuartzCore', 'GameController', 'CoreMotion', 'CoreText')], + 'settings': {'base': { + 'PRODUCT_BUNDLE_IDENTIFIER': 'dev.anygame.controller-verification', + 'ENABLE_DEBUG_DYLIB': 'NO', 'CODE_SIGN_STYLE': 'Automatic', + 'TARGETED_DEVICE_FAMILY': '1', 'CLANG_ENABLE_OBJC_ARC': 'YES', + 'HEADER_SEARCH_PATHS': [str(REPO / 'dm/host'), str(include)], + }}, + 'info': {'path': 'Info.plist', 'properties': { + 'CFBundleDisplayName': 'Controller Verification', 'UILaunchScreen': {}, + 'UIFileSharingEnabled': True, + 'UIApplicationSceneManifest': {'UIApplicationSupportsMultipleScenes': False, + 'UISceneConfigurations': {'UIWindowSceneSessionRoleApplication': [{ + 'UISceneConfigurationName': 'Default', 'UISceneDelegateClassName': 'AssignmentScene'}]}}, + }}, + }}, + } + (output / 'project.yml').write_text(json.dumps(project, indent=2) + '\n') + files = [REPO / p for p in ('dm/ios/tests/controller_assignment_check.m', 'dm/ios/Sources/DmGamepad.m', + 'dm/host/pad_slots.h', 'dm/host/dm_input.h', 'dm/host/motion_apple.h', 'dm/host/motion_fusion.h', + 'dm/host/front.h', 'dm/host/dm.h', 'dm/host/haptics_apple.h', 'dm/host/hle_haptics.h')] + report = {'gitHead': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=REPO, text=True).strip(), + 'gameProfile': game, 'physicalControllersTested': False, + 'generatedProfileSHA256': hashlib.sha256(header.read_bytes()).hexdigest(), + 'sha256': {str(p.relative_to(REPO)): hashlib.sha256(p.read_bytes()).hexdigest() for p in files}} + (output / 'source-provenance.json').write_text(json.dumps(report, indent=2) + '\n') + subprocess.run(['xcodegen', 'generate', '--spec', str(output / 'project.yml')], check=True) + subprocess.run(['xcodebuild', '-project', str(output / 'ControllerVerification.xcodeproj'), + '-scheme', 'ControllerVerification', '-configuration', 'Debug', + '-destination', 'generic/platform=iOS', '-derivedDataPath', str(output / 'DerivedData'), + '-jobs', '3', 'CODE_SIGNING_ALLOWED=NO', 'build'], check=True) + print(output / 'DerivedData/Build/Products/Debug-iphoneos/ControllerVerification.app') + + +if __name__ == '__main__': + main() diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index c7695d9902..b223156caa 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -182,6 +182,7 @@ DM_OODLE_JOB path `=`: the host runs the game's own Oodle decoder on e DM_OODLE_MODULES path The rewritten modules the Mac host's Oodle tool mode loads when `shader_split.py` decodes Trinity packs; set by the NSP script DM_OODLE_SERVE switch `=1`: the host answers decode requests on stdin / stdout with the game's own Oodle (or LZ5) decoder from the guest main thread before the SDK starts, until stdin ends DM_OPMODE player The operation mode the game sees: `0` handheld, `1` console (default: the profile's `op_mode`, console unless set); read once at startup +DM_PAD_ASSIGN switch `1` opts iOS into explicit session controller/player assignment and live-input identification; occupied players swap after releasing old input, gyro and rumble follow the assignment, and touch controls remain Player 1. Default off; ignored with DM_PAD_MERGE. Model names are not persistent identity. DM_PAD_MERGE switch `=1` merges every controller into player 1, as before, instead of giving each controller a player of its own (Mac and iOS) DM_PATCH_FRONT switch `=`: the size of the patch area right before the module base for a long text (`BIG_TEXT`); set only by the rewriter's own rerun with the size it needs DM_PHONE_MOTION switch On iOS, whether the phone's own device motion drives player 1's six-axis sensor: `auto` (default) while no controller supplies fresh motion, `1` phone only, `0` never; each source keeps its own orientation diff --git a/docs/playing.md b/docs/playing.md index bdf4378322..5cac9abb17 100644 --- a/docs/playing.md +++ b/docs/playing.md @@ -14,6 +14,8 @@ In 3D, the right stick (the camera in most games) is not drawn: swipe on the rig A connected game controller hides the touch controls and keeps every button. +The optional iOS experiment `DM_PAD_ASSIGN=1` adds a **Players** button for choosing each controller's player. Move a control to identify its row, then choose a player; occupied players swap controllers. Touch controls stay with Player 1 and remain available when only later players have physical controllers. Identical controller models may change order after reconnecting. This option is off by default and is ignored with `DM_PAD_MERGE=1`. + ## 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 settings screen's **Game rumble** and **Rumble strength** control it independently of **Touch feedback**. Phone rumble approximates the Switch's effects; physical feel and controller compatibility still need device testing. diff --git a/docs/reference/services.md b/docs/reference/services.md index b42cdbf539..eb8c9f2e5a 100644 --- a/docs/reference/services.md +++ b/docs/reference/services.md @@ -222,6 +222,18 @@ Most service calls can be found before they crash, before the first boot or the ## Input +### Opt-in player assignment on iOS + +`DM_PAD_ASSIGN=1`, set before launch, adds a **Players** button while a physical controller is assigned. Move a control to identify its current connection, choose that controller, then choose a player. An occupied player is explicitly labeled as a swap. Model names are labels, not persistent identities; identical controllers can reconnect in a different order. The existing automatic assignment remains the default, and `DM_PAD_MERGE=1` takes precedence over this experiment. + +The main-queue transaction releases both old button states, clears old motion, stops old haptic routes, then moves the owners and reconnect hints together. It publishes the new connection mask and current button state at the destination. Motion and rumble follow the controller. Generation checks prevent a queued old gyro callback from writing its former player after a swap or disconnect. Controllers use GameController's main `handlerQueue` in the opt-in mode; connect, reset and assignment operations use that same queue. Player indicators follow slots 1–4 where the controller supports them; later players use the on-screen labels. + +Touch controls and the phone's motion remain Player 1. In this opt-in mode, a physical controller on a later player leaves Player 1's touch controls available. While the assignment sheet is open, physical controllers' input, gyro and rumble routes are released; moving a control still identifies its row. Closing the sheet restores each controller's current input at its assigned player. The game continues running, and the phone's motion remains independent. The game's existing supported-player rules still apply: a one-player title may combine later players into Player 1. + +`t_hid` checks swaps, moves, masks, reconnect hints and duplicate-name ambiguity without controllers. `dm/ios/tools/build_controller_probe.py --output ` builds a native fixture using the actual iOS adapter, Apple's writable snapshot controllers and inert host endpoints. It checks release order, rerouting, stale gyro callbacks and the sheet's row data. It starts no discovery, phone sensors or haptic engines. Physical wired/Bluetooth operation, multiple real controllers, LED/rumble feel and interactive sheet use remain unverified. + +Apple's SDK documents the controller's [callback queue](https://developer.apple.com/documentation/gamecontroller/gcdevice/handlerqueue), [player indicator](https://developer.apple.com/documentation/gamecontroller/gccontroller/playerindex) and [snapshot controllers](https://developer.apple.com/documentation/gamecontroller/gccontroller/controllerwithextendedgamepad()). The SDK says vendor names are not unique and player indices do not persist. This experiment stores no controller identity or assignment preferences. + ### Controller disconnect between style and player lookup - **Symptom:** clang reports a negative shift in `fill` or `vib_targets` (`hle_hid.c`).