diff --git a/dm/ios/Sources/DmSceneRouting.h b/dm/ios/Sources/DmSceneRouting.h new file mode 100644 index 0000000000..ffa60854a1 --- /dev/null +++ b/dm/ios/Sources/DmSceneRouting.h @@ -0,0 +1,10 @@ +// iOS-only scene ownership. Events keep the existing front end's 0..3 values; +// connect/disconnect additionally maintain the opt-in shared-runtime registry. +#pragma once +#import + +int front_ios_external_enabled(void); +UIViewController *front_ios_external_root(UIWindowScene *scene); +void front_ios_external_disconnect(UIViewController *controller); +void front_ios_scene_event(UIWindowScene *scene, int event); +void front_ios_scene_changed(UIWindowScene *scene); diff --git a/dm/ios/Sources/front_ios.m b/dm/ios/Sources/front_ios.m index 72867c194d..580ad4e4e7 100644 --- a/dm/ios/Sources/front_ios.m +++ b/dm/ios/Sources/front_ios.m @@ -9,6 +9,7 @@ #include "hle.h" #include "dm.h" #include "haptics_apple.h" +#import "DmSceneRouting.h" UIView *dm_gamepad_view(CGRect frame); void dm_gamepad_reset(void); @@ -19,6 +20,22 @@ bool dm_show_launcher(void *view_controller, void (*done)(void)); // Launcher.swift: false = no launcher (no dm/launcher.json, or DM_LAUNCHER=0) static CAMetalLayer *g_layer; +static NSMapTable *g_scene_focus; +static NSHashTable *g_external_roots; +static __weak UIViewController *g_external_output; +static int g_route_focus = 3; +static BOOL g_route_runtime_started, g_route_suspended; +static void external_refresh(void); +static void external_register(UIViewController *controller); +void front_ios_focus_event(int event); +static void front_ios_active_recover(void); +void mtl_link_pause(int on); +int front_ios_external_enabled(void) { return [[NSBundle.mainBundle objectForInfoDictionaryKey:@"DMExternalDisplayEnabled"] boolValue]; } +static CGRect fit_game(CGRect bounds) { + CGFloat w = bounds.size.width, h = bounds.size.height; + if (w / h > 16.0 / 9.0) w = h * 16.0 / 9.0; else h = w * 9.0 / 16.0; + return CGRectMake(floor((bounds.size.width - w) / 2), floor((bounds.size.height - h) / 2), w, h); +} static void front_ios_power_watch(void); static void front_ios_audio_watch(void); static UILabel *front_ios_fps_label(void); @@ -94,6 +111,8 @@ @interface DmRootVC : UIViewController @property (nonatomic, strong) UIView *pad; @property (nonatomic, strong) UILabel *fps; @property (nonatomic) BOOL launched; +@property (nonatomic, strong) id externalAccessory; +@property (nonatomic, strong) UILabel *externalNote; - (void)startGame; @end static DmRootVC *g_root; @@ -129,14 +148,16 @@ - (void)startGame { if (self.fps) [self.view addSubview:self.fps]; [self.view setNeedsLayout]; [self.view layoutIfNeeded]; UIApplication.sharedApplication.idleTimerDisabled = YES; + if (front_ios_external_enabled()) { external_register(self); external_refresh(); } front_ios_boot(); + g_route_runtime_started = YES; + if (front_ios_external_enabled()) external_refresh(); } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; CGRect b = self.view.bounds; if (!self.game) return; - CGFloat w = b.size.width, h = b.size.height; // Fit 16:9 into the whole screen - if (w / h > 16.0 / 9.0) w = h * 16.0 / 9.0; else h = w * 9.0 / 16.0; - self.game.frame = CGRectMake(floor((b.size.width - w) / 2), floor((b.size.height - h) / 2), w, h); + if (self.game.superview == self.view) self.game.frame = fit_game(b); + self.externalNote.frame = b; if (self.fps) { // the top row's free stretch: right of DmGamepad.m's L3 key (102 + 17 of its units from the safe edge), level with − / + (22 units down); u is its unit CGRect s = UIEdgeInsetsInsetRect(b, self.view.safeAreaInsets); CGFloat u = MIN(b.size.height / 430.0, 1.3); self.fps.frame = CGRectMake(floor(CGRectGetMinX(s) + 130 * u), floor(CGRectGetMinY(s) + 22 * u - 11), 64, 22); @@ -158,7 +179,113 @@ - (UIRectEdge)preferredScreenEdgesDeferringSystemGestures { return UIRectEdgeAll - (BOOL)prefersStatusBarHidden { return YES; } - (UIInterfaceOrientationMask)supportedInterfaceOrientations { return UIInterfaceOrientationMaskLandscape; } @end -UIViewController *front_ios_root(void) { return [DmRootVC new]; } +UIViewController *front_ios_root(void) { + if (!front_ios_external_enabled()) return [DmRootVC new]; + // Reconnecting the primary scene reuses its controls, Metal layer and guest. + if (!g_root) g_root = [DmRootVC new]; + return g_root; +} + +@interface DmExternalRootVC : UIViewController +@property (nonatomic, weak) UIWindowScene *scene; +@property (nonatomic, strong) UILabel *hint; +@end +@implementation DmExternalRootVC +- (void)viewDidLoad { + [super viewDidLoad]; self.view.backgroundColor = UIColor.blackColor; + self.hint = [[UILabel alloc] initWithFrame:self.view.bounds]; + self.hint.text = @"Start the game on your iPhone"; self.hint.textAlignment = NSTextAlignmentCenter; + self.hint.textColor = UIColor.whiteColor; self.hint.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [self.view addSubview:self.hint]; +} +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + if (g_root.game.superview == self.view) g_root.game.frame = fit_game(self.view.bounds); + self.hint.hidden = g_root.game.superview == self.view; +} +- (BOOL)prefersStatusBarHidden { return YES; } +@end + +static int scene_focus(UIWindowScene *scene) { return scene ? ([g_scene_focus objectForKey:scene] ?: @3).intValue : 3; } +static void external_register(UIViewController *controller) { + if (@available(iOS 27.0, *)) { + DmRootVC *root = (DmRootVC *)controller; if (root.externalAccessory) return; + UISceneConfiguration *configuration = [[UISceneConfiguration alloc] initWithName:@"External game display" sessionRole:UIWindowSceneSessionRoleExternalDisplayNonInteractive]; + configuration.delegateClass = NSClassFromString(@"DmExternalSceneDelegate"); + root.externalAccessory = [root registerSceneAccessory:[UISceneAccessory externalNonInteractiveSceneAccessoryWithConfiguration:configuration]]; + } +} +UIViewController *front_ios_external_root(UIWindowScene *scene) { + DmExternalRootVC *root = [DmExternalRootVC new]; root.scene = scene; + if (!g_external_roots) g_external_roots = [NSHashTable weakObjectsHashTable]; + [g_external_roots addObject:root]; + return root; +} +void front_ios_external_disconnect(UIViewController *controller) { + [g_external_roots removeObject:controller]; + if (g_external_output == controller) g_external_output = nil; + external_refresh(); +} +static void external_refresh(void) { + if (!front_ios_external_enabled() || !g_root.game) return; + int desired = scene_focus(g_root.view.window.windowScene); + DmExternalRootVC *target = (DmExternalRootVC *)g_external_output; + if (target && (![g_external_roots containsObject:target] || scene_focus(target.scene) == 3)) target = nil; + for (DmExternalRootVC *root in g_external_roots) { + int focus = scene_focus(root.scene); if (focus < desired) desired = focus; + if (!target && focus != 3) target = root; + } + UIView *host = target ? target.view : g_root.view; + BOOL moving = g_root.game.superview != host; + // The phone retains the pad and FPS label. The very same Metal view/layer + // changes parent, so the renderer never observes a replacement layer or a + // second guest. Stop submissions and drain their output before that move. + if (desired == 3 && g_route_runtime_started && !g_route_suspended) { + front_ios_focus_event(1); g_route_suspended = YES; + } + BOOL fenced = moving && g_route_runtime_started && !g_route_suspended; + if (fenced) dm_gfx_suspend(1); + if (moving && g_route_runtime_started) mtl_link_pause(1); + if (moving) { + [g_root.game removeFromSuperview]; [host insertSubview:g_root.game atIndex:0]; + [host setNeedsLayout]; [host layoutIfNeeded]; + if (target) [target viewDidLayoutSubviews]; else [g_root viewDidLayoutSubviews]; + [g_root.game setNeedsLayout]; [g_root.game layoutIfNeeded]; + LOG("external display: render layer moved to %s; phone controls retained", target ? "external scene" : "primary scene"); + } + g_external_output = target; + if (target && !g_root.externalNote) { + g_root.externalNote = [[UILabel alloc] initWithFrame:g_root.view.bounds]; + g_root.externalNote.text = @"Playing on your external display"; g_root.externalNote.textAlignment = NSTextAlignmentCenter; + g_root.externalNote.textColor = UIColor.secondaryLabelColor; + g_root.externalNote.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [g_root.view insertSubview:g_root.externalNote atIndex:0]; + } + g_root.externalNote.hidden = !target; + if (desired != 3) { + if (g_route_runtime_started && (moving || g_route_suspended)) mtl_link_pause(0); + if (g_route_suspended || g_route_focus == 3) { front_ios_focus_event(2); g_route_suspended = NO; } + else if (fenced) dm_gfx_suspend(0); + if (desired != g_route_focus) front_ios_focus_event(desired == 1 ? 3 : 0); + } + g_route_focus = desired; +} +void front_ios_scene_event(UIWindowScene *scene, int event) { + if (!front_ios_external_enabled()) { if (event < 4) front_ios_focus_event(event); return; } + int already_active = g_route_focus == 1; + if (!g_scene_focus) g_scene_focus = [NSMapTable weakToStrongObjectsMapTable]; + if (event == 5) [g_scene_focus removeObjectForKey:scene]; + else { + int focus = event == 1 ? 3 : event == 3 ? 1 : 2; + if (event == 4) focus = scene.activationState == UISceneActivationStateForegroundActive ? 1 : scene.activationState == UISceneActivationStateForegroundInactive ? 2 : 3; + [g_scene_focus setObject:@(focus) forKey:scene]; + } + external_refresh(); + // An active external scene can keep aggregate focus unchanged while the + // phone returns. Preserve audio/controller recovery without resetting guest + // focus or its activation time. A focus transition already recovered below. + if (event == 3 && already_active && scene == g_root.view.window.windowScene) front_ios_active_recover(); +} void front_ios_scene_changed(UIWindowScene *scene) { if (g_root.game.window.windowScene != scene) return; [g_root.game setNeedsLayout]; [g_root.game layoutIfNeeded]; @@ -311,6 +438,11 @@ static void front_ios_audio_watch(void) { // Scene lifecycle (called by SceneDelegate in main.m): 0 resign active, 1 enter background, 2 return to foreground, 3 become active. On entering background, freeze the guest clock and suspend after in-flight GPU work completes (submitting GPU work in background gets the process killed). // The screen stays on while the game is active: only touches reset the idle timer, so with a controller (or during a cutscene) the screen dimmed and locked mid-game +static void front_ios_active_recover(void) { + if (g_audio_intr) { LOG("audio session: active again with no interruption-ended notice; resuming"); g_audio_intr = 0; g_session_off = 1; } + if (g_session_off) front_ios_audio_session(); + front_ios_audio_apply(); dm_gamepad_active(); +} void front_ios_focus_event(int ev) { if (!g_layer) return; switch (ev) { @@ -319,8 +451,6 @@ void front_ios_focus_event(int ev) { case 2: dm_gfx_suspend(0); guest_clock_freeze_for(1, 0); front_ios_audio_session(); g_audio_bg = 0; front_ios_audio_apply(); dm_focus_set(2); break; case 3: // Becoming active again also ends an interruption whose "ended" never came (not every one sends it), and retries a session that would not activate on the way back UIApplication.sharedApplication.idleTimerDisabled = YES; - if (g_audio_intr) { LOG("audio session: active again with no interruption-ended notice; resuming"); g_audio_intr = 0; g_session_off = 1; } - if (g_session_off) front_ios_audio_session(); - front_ios_audio_apply(); dm_gamepad_active(); dm_focus_set(1); break; + front_ios_active_recover(); dm_focus_set(1); break; } } diff --git a/dm/ios/Sources/main.m b/dm/ios/Sources/main.m index 0fef37252c..3a1fedb1f8 100644 --- a/dm/ios/Sources/main.m +++ b/dm/ios/Sources/main.m @@ -12,6 +12,7 @@ #include "romfs_image.h" #include "romfs_identity.h" #include "player_env.h" +#import "DmSceneRouting.h" #ifndef DM_PLAYER_BUILD #define DM_PLAYER_BUILD 0 #endif @@ -168,6 +169,10 @@ @interface App : UIResponder @end @implementation App - (UISceneConfiguration *)application:(UIApplication *)a configurationForConnectingSceneSession:(UISceneSession *)s options:(UISceneConnectionOptions *)o { + if (front_ios_external_enabled() && [s.role isEqualToString:UIWindowSceneSessionRoleExternalDisplayNonInteractive]) { + UISceneConfiguration *configuration = [[UISceneConfiguration alloc] initWithName:@"External game display" sessionRole:s.role]; + configuration.delegateClass = NSClassFromString(@"DmExternalSceneDelegate"); return configuration; + } return [[UISceneConfiguration alloc] initWithName:@"Default" sessionRole:s.role]; } @end @@ -180,15 +185,39 @@ @implementation SceneDelegate - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)o { self.window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene]; self.window.rootViewController = front_ios_root(); - [self.window makeKeyAndVisible]; + [self.window makeKeyAndVisible]; front_ios_scene_event((UIWindowScene *)scene, 4); } -- (void)sceneWillResignActive:(UIScene *)s { front_ios_focus_event(0); } +- (void)sceneDidDisconnect:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 5); } +- (void)sceneWillResignActive:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 0); } - (void)windowScene:(UIWindowScene *)scene didUpdateEffectiveGeometry:(UIWindowSceneGeometry *)previous { (void)previous; front_ios_scene_changed(scene); } -- (void)sceneDidEnterBackground:(UIScene *)s { front_ios_focus_event(1); } -- (void)sceneWillEnterForeground:(UIScene *)s { front_ios_focus_event(2); } -- (void)sceneDidBecomeActive:(UIScene *)s { front_ios_scene_changed((UIWindowScene *)s); front_ios_focus_event(3); } +- (void)sceneDidEnterBackground:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 1); } +- (void)sceneWillEnterForeground:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 2); } +- (void)sceneDidBecomeActive:(UIScene *)s { front_ios_scene_changed((UIWindowScene *)s); front_ios_scene_event((UIWindowScene *)s, 3); } +@end + +// Only this content scene is added for opted-in builds. It never starts the +// host and never becomes key: text input and game controls stay on the phone. +@interface DmExternalSceneDelegate : UIResponder +@property (strong, nonatomic) UIWindow *window; +@end +@implementation DmExternalSceneDelegate +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options { + (void)session; (void)options; + self.window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene]; + self.window.rootViewController = front_ios_external_root((UIWindowScene *)scene); + self.window.hidden = NO; front_ios_scene_event((UIWindowScene *)scene, 4); +} +- (void)sceneWillResignActive:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 0); } +- (void)sceneDidEnterBackground:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 1); } +- (void)sceneWillEnterForeground:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 2); } +- (void)sceneDidBecomeActive:(UIScene *)s { front_ios_scene_changed((UIWindowScene *)s); front_ios_scene_event((UIWindowScene *)s, 3); } +- (void)windowScene:(UIWindowScene *)scene didUpdateEffectiveGeometry:(UIWindowSceneGeometry *)previous { (void)previous; front_ios_scene_changed(scene); } +- (void)sceneDidDisconnect:(UIScene *)s { + front_ios_external_disconnect(self.window.rootViewController); + front_ios_scene_event((UIWindowScene *)s, 5); self.window = nil; +} @end int main(int argc, char **argv) { diff --git a/dm/ios/tests/external_scene_check.m b/dm/ios/tests/external_scene_check.m new file mode 100644 index 0000000000..ac87261ab4 --- /dev/null +++ b/dm/ios/tests/external_scene_check.m @@ -0,0 +1,190 @@ +// Programmatic routing/policy probe of the complete production iOS front end. +// Output windows share one real phone scene. Distinct identities below model +// external scene events; this is not a physical external-display test. +#import +#import +#import +#include +#include +static int audio_activations, audio_last_suspend, pad_activations; +static BOOL audio_activation_fails; +// Deterministic session responses exercise the real production notification and +// activation code without claiming a physical audio-route or controller test. +@interface ExternalProbeAudioSession : NSObject ++ (instancetype)sharedInstance; +- (BOOL)setCategory:(NSString *)category error:(NSError **)error; +- (BOOL)setPreferredSampleRate:(double)value error:(NSError **)error; +- (BOOL)setPreferredIOBufferDuration:(double)value error:(NSError **)error; +- (BOOL)setActive:(BOOL)value error:(NSError **)error; +@property(readonly) double sampleRate; +@property(readonly) double IOBufferDuration; +@end +@implementation ExternalProbeAudioSession ++ (instancetype)sharedInstance { static ExternalProbeAudioSession *s; static dispatch_once_t once; dispatch_once(&once, ^{ s = [self new]; }); return s; } +- (BOOL)setCategory:(NSString *)category error:(NSError **)error { (void)category; (void)error; return YES; } +- (BOOL)setPreferredSampleRate:(double)value error:(NSError **)error { (void)value; (void)error; return YES; } +- (BOOL)setPreferredIOBufferDuration:(double)value error:(NSError **)error { (void)value; (void)error; return YES; } +- (BOOL)setActive:(BOOL)value error:(NSError **)error { (void)value; audio_activations++; if (audio_activation_fails && error) *error = [NSError errorWithDomain:@"verification" code:1 userInfo:nil]; return !audio_activation_fails; } +- (double)sampleRate { return 48000; } +- (double)IOBufferDuration { return .005; } +@end +#define AVAudioSession ExternalProbeAudioSession +#include "../Sources/front_ios.m" +#undef AVAudioSession +#define main unused_production_main +#include "../Sources/main.m" +#undef main + +static _Atomic int guest_starts; +static NSMutableArray *trace; +static int last_focus, focus_calls, frozen, link_paused; +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; } +int dm_main(int argc, char **argv) { (void)argc; (void)argv; atomic_fetch_add(&guest_starts, 1); return 0; } +void dm_host_preload_start(int argc, char **argv) { (void)argc; (void)argv; } +void dm_haptics_apple_init(void) {} +UIView *dm_gamepad_view(CGRect frame) { return [[UIView alloc] initWithFrame:frame]; } +void dm_gamepad_reset(void) {} +void dm_gamepad_active(void) { pad_activations++; } +bool dm_show_launcher(void *view, void (*done)(void)) { (void)view; (void)done; return false; } +uint64_t mtl_present_count(void) { return 0; } +void dm_audio_suspend(int on) { audio_last_suspend = on; } +void dm_audio_reset(void) {} +void dm_focus_set(int state) { last_focus = state; focus_calls++; } +void guest_clock_freeze_for(int reason, int on) { (void)reason; frozen = on; } +void dm_gfx_suspend(int on) { [trace addObject:on ? @"gate close" : @"gate open"]; } +void mtl_link_pause(int on) { link_paused = on; [trace addObject:on ? @"link pause" : @"link resume"]; } + +static NSMutableArray *checks; +static void check(NSString *name, BOOL passed) { + [checks addObject:@{@"name":name, @"passed":@(passed)}]; + fprintf(stderr, "[external] %s: %s\n", name.UTF8String, passed ? "PASS" : "FAIL"); +} +static void checkMove(NSString *name) { + check(name, [trace isEqualToArray:@[@"gate close", @"link pause", @"link resume", @"gate open"]]); + if (trace.count) fprintf(stderr, "[external] handover trace: %s\n", trace.description.UTF8String); + [trace removeAllObjects]; +} +@interface ExternalPolicyScene : SceneDelegate +@end +@implementation ExternalPolicyScene +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options { + g_boot_ok = 0; + [super scene:scene willConnectToSession:session options:options]; + DmRootVC *root = (DmRootVC *)self.window.rootViewController; root.launched = YES; + // The production no-game-files branch prevents guest startup for this probe. + // Replace its placeholder before the first frame so this app cannot look like + // a broken game installation. The production missing-files message is intact. + for (UIView *view in root.view.subviews.copy) [view removeFromSuperview]; + UILabel *status = [[UILabel alloc] initWithFrame:root.view.bounds]; + status.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + status.numberOfLines = 0; status.textAlignment = NSTextAlignmentCenter; + status.font = [UIFont systemFontOfSize:20 weight:UIFontWeightMedium]; + status.textColor = UIColor.whiteColor; status.backgroundColor = UIColor.blackColor; + status.text = @"External display test\nRunning programmatic checks.\nThis verification app does not use game files."; + [root.view addSubview:status]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + checks = [NSMutableArray array]; trace = [NSMutableArray array]; + UIWindowScene *phone = (UIWindowScene *)scene; + check(@"this app explicitly opts into external routing", front_ios_external_enabled()); + front_ios_scene_event(phone, 3); + g_boot_ok = 1; [root startGame]; + front_ios_audio_watch(); // viewDidLoad intentionally took the no-game-files branch above + void *layer = front_metal_layer(); DmGameView *game = root.game; + [root startGame]; + check(@"starting again retains the single game view and layer", root.game == game && front_metal_layer() == layer); + if (@available(iOS 27.0, *)) check(@"iOS 27 registers an accessory on the owning controller", root.externalAccessory != nil); + check(@"reconnecting the phone reuses its runtime owner", front_ios_root() == root); + [trace removeAllObjects]; + + // NSObject supplies only weak identity/hash semantics; no fake UIKit + // lifecycle or screen API is invoked on it. All event processing and + // view moves run through the production functions. + UIWindowScene *identity = (UIWindowScene *)[NSObject new]; + DmExternalRootVC *external = (DmExternalRootVC *)front_ios_external_root(identity); + UIWindow *output = [[UIWindow alloc] initWithWindowScene:phone]; + output.rootViewController = external; output.hidden = NO; + front_ios_scene_event(identity, 2); + checkMove(@"external connection drains and pauses before moving, then resumes"); + check(@"external connection transfers the same render view", game.superview == external.view && front_metal_layer() == layer); + check(@"phone retains its controls and FPS label", root.pad.superview == root.view && (!root.fps || root.fps.superview == root.view)); + check(@"the noninteractive output does not take the key window", self.window.isKeyWindow && !output.isKeyWindow); + check(@"an external scene never creates a second guest view", external.view.subviews.count >= 1 && root.game == game); + front_ios_scene_event(identity, 0); + check(@"external resign-active cannot suspend an active phone", trace.count == 0 && last_focus == 1 && !frozen); + front_ios_scene_event(identity, 1); + checkMove(@"background output falls back to phone with a fenced handover"); + check(@"fallback retains the layer and phone render target", game.superview == root.view && front_metal_layer() == layer); + + front_ios_scene_event(identity, 3); [trace removeAllObjects]; + front_ios_scene_event(phone, 1); + check(@"active external scene keeps the shared runtime alive when phone backgrounds", trace.count == 0 && last_focus == 1 && !frozen); + + // The phone's own activation must still recover audio/input even though + // the external scene keeps aggregate guest focus active throughout. + front_ios_scene_event(phone, 0); + [NSNotificationCenter.defaultCenter postNotificationName:AVAudioSessionInterruptionNotification object:nil + userInfo:@{AVAudioSessionInterruptionTypeKey:@(AVAudioSessionInterruptionTypeBegan)}]; + check(@"the real interruption observer suspends audio", g_audio_intr && audio_last_suspend == 1); + int activations_before = audio_activations, pads_before = pad_activations, focus_calls_before = focus_calls; + front_ios_scene_event(phone, 3); + check(@"phone activation clears a missed interruption-end and retries audio", !g_audio_intr && !g_session_off && audio_last_suspend == 0 && audio_activations == activations_before + 1); + check(@"phone activation refreshes manual controllers with external focus unchanged", pad_activations == pads_before + 1); + check(@"phone recovery leaves active guest focus and the graphics gate unchanged", last_focus == 1 && g_route_focus == 1 && focus_calls == focus_calls_before && !frozen && !g_route_suspended && trace.count == 0); + audio_activation_fails = YES; front_ios_audio_session(); + check(@"a refused session activation is retained for retry", g_session_off); + front_ios_scene_event(phone, 0); audio_activation_fails = NO; activations_before = audio_activations; + front_ios_scene_event(phone, 3); + check(@"phone activation retries a failed session while external focus stays active", !g_session_off && audio_activations == activations_before + 1 && audio_last_suspend == 0); + front_ios_scene_event(identity, 0); front_ios_scene_event(phone, 0); + [NSNotificationCenter.defaultCenter postNotificationName:AVAudioSessionInterruptionNotification object:nil + userInfo:@{AVAudioSessionInterruptionTypeKey:@(AVAudioSessionInterruptionTypeBegan)}]; + activations_before = audio_activations; pads_before = pad_activations; + front_ios_scene_event(phone, 3); + check(@"aggregate inactive-to-active recovery runs once", !g_audio_intr && !g_session_off && audio_activations == activations_before + 1 && pad_activations == pads_before + 1); + front_ios_scene_event(identity, 3); front_ios_scene_event(phone, 1); + front_ios_scene_event(identity, 0); + check(@"visible inactive scenes lose input focus without suspending graphics", trace.count == 0 && last_focus == 2 && !frozen); + front_ios_scene_event(identity, 1); + check(@"all-background closes graphics and freezes the guest", [trace isEqualToArray:@[@"gate close", @"link pause"]] && frozen && g_route_suspended); + check(@"all-background fallback keeps the same retained layer", game.superview == root.view && front_metal_layer() == layer); + [trace removeAllObjects]; front_ios_external_disconnect(external); front_ios_scene_event(identity, 5); + check(@"disconnect while backgrounded does not reopen graphics", trace.count == 0 && g_route_suspended && frozen); + front_ios_scene_event(phone, 2); + check(@"foreground return resumes a link paused during fallback", !link_paused && !g_route_suspended && !frozen && last_focus == 2); + check(@"foreground return resumes the link before reopening the graphics gate", [trace isEqualToArray:@[@"link resume", @"gate open"]]); + front_ios_scene_event(phone, 3); [trace removeAllObjects]; + + external = (DmExternalRootVC *)front_ios_external_root(identity); output.rootViewController = external; + front_ios_scene_event(identity, 3); [trace removeAllObjects]; + front_ios_external_disconnect(external); front_ios_scene_event(identity, 5); output.hidden = YES; + checkMove(@"active unplug fences the move back to the phone"); + check(@"unplug retains all phone controls and the one Metal layer", game.superview == root.view && root.pad.superview == root.view && front_metal_layer() == layer); + check(@"unplug leaves the phone active and the guest clock running", last_focus == 1 && !frozen && !g_route_suspended); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 100 * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{ + check(@"all scene changes started only one guest runtime", atomic_load(&guest_starts) == 1); + NSInteger failed = 0; for (NSDictionary *item in checks) if (![item[@"passed"] boolValue]) failed++; + status.text = [NSString stringWithFormat:@"External display test\n%@: %lu checks, %ld failed.\nThis verification app does not use game files.\nPhysical external-display testing is still pending.", + failed ? @"FAILED" : @"PASS", (unsigned long)checks.count, (long)failed]; + [root.view bringSubviewToFront:status]; + NSURL *provenanceURL = [NSBundle.mainBundle URLForResource:@"SourceProvenance" withExtension:@"json"]; + NSDictionary *provenance = [NSJSONSerialization JSONObjectWithData:[NSData dataWithContentsOfURL:provenanceURL] options:0 error:NULL]; + NSDictionary *report = @{@"displayMessage":status.text, @"sourceProvenance":provenance, @"checks":checks, @"failed":@(failed), @"guestStarts":@(atomic_load(&guest_starts)), + @"physicalExternalDisplayTested":@NO, @"outputWindowsSharePhoneScene":@YES, + @"externalSceneIdentitiesInjected":@YES, @"graphicsGateAndDisplayLinkStubbed":@YES, + @"productionAudioObserverUsed":@YES, @"nativeAudioActivationMocked":@YES, @"controllerActivationStubbed":@YES, + @"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]; + }); + }); +} +@end +int main(int argc, char **argv) { + @autoreleasepool { + NSURL *documents = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]; + freopen([[documents URLByAppendingPathComponent:@"external.log"] fileSystemRepresentation], "w", stderr); setvbuf(stderr, NULL, _IOLBF, 0); + return UIApplicationMain(argc, argv, nil, NSStringFromClass(App.class)); + } +} diff --git a/dm/ios/tests/scene_metrics_check.m b/dm/ios/tests/scene_metrics_check.m index 1b29675b46..05a13ddabf 100644 --- a/dm/ios/tests/scene_metrics_check.m +++ b/dm/ios/tests/scene_metrics_check.m @@ -16,6 +16,7 @@ void dm_haptics_apple_init(void) {} UIView *dm_gamepad_view(CGRect frame) { return [[UIView alloc] initWithFrame:frame]; } void dm_gamepad_reset(void) {} +void dm_gamepad_active(void) {} bool dm_show_launcher(void *view, void (*done)(void)) { (void)view; (void)done; return false; } uint64_t mtl_present_count(void) { return 0; } void dm_audio_suspend(int on) { (void)on; } @@ -23,6 +24,7 @@ void dm_audio_reset(void) {} void dm_focus_set(int state) { (void)state; } void guest_clock_freeze_for(int reason, int on) { (void)reason; (void)on; } void dm_gfx_suspend(int on) { (void)on; } +void mtl_link_pause(int on) { (void)on; } static NSMutableArray *checks; static void check(NSString *name, BOOL passed) { diff --git a/dm/ios/tests/test_external_scene_config.py b/dm/ios/tests/test_external_scene_config.py new file mode 100644 index 0000000000..e6f7294c99 --- /dev/null +++ b/dm/ios/tests/test_external_scene_config.py @@ -0,0 +1,69 @@ +"""Check the actual IPA-builder scene opt-in against a generated-style plist.""" +import os +from pathlib import Path +import plistlib +import subprocess +import tempfile +import unittest + +REPO = Path(__file__).resolve().parents[3] + + +class ExternalSceneConfigurationTests(unittest.TestCase): + def configure(self, enabled, include_stamp=False): + script = (REPO / 'dm/ios/tools/build_ipa.sh').read_text() + if include_stamp: + block = script.split('xcodegen generate -q > /dev/null || die "xcodegen"\n', 1)[1].split('# DM_METAL_CAPTURE=1:', 1)[0] + else: + block = script.split('# Explicit build opt-in:', 1)[1].split('# DM_METAL_CAPTURE=1:', 1)[0] + block = block[block.index('\n') + 1:] + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / 'Gen').mkdir() + path = root / 'Gen/Info.plist' + original = plistlib.dumps({'UIApplicationSceneManifest': { + 'UIApplicationSupportsMultipleScenes': False, + 'UISceneConfigurations': {'UIWindowSceneSessionRoleApplication': [{ + 'UISceneConfigurationName': 'Default', 'UISceneDelegateClassName': 'SceneDelegate'}]}}}) + path.write_bytes(original) + environment = {'PATH': os.defpath, 'DM': str(REPO / 'dm'), 'MSLDIR': 'b-fixture-chain'} + if enabled is not None: + environment['DM_EXTERNAL_DISPLAY'] = enabled + result = subprocess.run(['bash', '-c', 'die() { exit 1; }; t() { :; };\n' + block], + cwd=root, env=environment, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return original, path.read_bytes() + + def test_default_and_explicit_zero_preserve_scene_manifest(self): + for value in (None, '0'): + with self.subTest(value=value): + original, configured = self.configure(value) + self.assertEqual(configured, original) + + def test_opt_in_advertises_only_the_noninteractive_external_role(self): + _, configured = self.configure('1') + info = plistlib.loads(configured) + self.assertTrue(info['DMExternalDisplayEnabled']) + manifest = info['UIApplicationSceneManifest'] + self.assertFalse(manifest['UIApplicationSupportsMultipleScenes']) + configurations = manifest['UISceneConfigurations'] + self.assertEqual(set(configurations), {'UIWindowSceneSessionRoleApplication', + 'UIWindowSceneSessionRoleExternalDisplayNonInteractive'}) + self.assertEqual(configurations['UIWindowSceneSessionRoleApplication'][0]['UISceneDelegateClassName'], 'SceneDelegate') + self.assertEqual(configurations['UIWindowSceneSessionRoleExternalDisplayNonInteractive'], [{ + 'UISceneConfigurationName': 'External game display', 'UISceneDelegateClassName': 'DmExternalSceneDelegate'}]) + + + def test_source_stamp_and_external_scene_survive_together(self): + _, configured = self.configure('1', include_stamp=True) + info = plistlib.loads(configured) + head = subprocess.check_output(['git', '-C', str(REPO), 'rev-parse', '--short=12', 'HEAD'], text=True).strip() + self.assertIn(info['DMCommit'], (head, head + '+dirty')) + self.assertEqual(info['DMChain'], 'fixture-chain') + self.assertTrue(info['DMExternalDisplayEnabled']) + external = info['UIApplicationSceneManifest']['UISceneConfigurations']['UIWindowSceneSessionRoleExternalDisplayNonInteractive'] + self.assertEqual(external[0]['UISceneDelegateClassName'], 'DmExternalSceneDelegate') + + +if __name__ == '__main__': + unittest.main() diff --git a/dm/ios/tools/build_ipa.sh b/dm/ios/tools/build_ipa.sh index 1bb65f0885..166a069677 100755 --- a/dm/ios/tools/build_ipa.sh +++ b/dm/ios/tools/build_ipa.sh @@ -136,6 +136,17 @@ xcodegen generate -q > /dev/null || die "xcodegen" COMMIT=$(git -C "$DM" rev-parse --short=12 HEAD 2> /dev/null) && { [ -z "$(git -C "$DM" status --porcelain --untracked-files=no 2> /dev/null)" ] || COMMIT=$COMMIT+dirty /usr/libexec/PlistBuddy -c "Add :DMCommit string $COMMIT" Gen/Info.plist > /dev/null || die "DMCommit"; t "source: $COMMIT"; } case "$MSLDIR" in b-*) /usr/libexec/PlistBuddy -c "Add :DMChain string ${MSLDIR#b-}" Gen/Info.plist > /dev/null || die "DMChain";; esac +# Explicit build opt-in: default builds advertise no external content role and keep system mirroring unchanged. +if [ "${DM_EXTERNAL_DISPLAY:-0}" = 1 ]; then + for command in \ + "Add :DMExternalDisplayEnabled bool true" \ + "Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleExternalDisplayNonInteractive array" \ + "Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleExternalDisplayNonInteractive:0 dict" \ + "Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleExternalDisplayNonInteractive:0:UISceneConfigurationName string External game display" \ + "Add :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleExternalDisplayNonInteractive:0:UISceneDelegateClassName string DmExternalSceneDelegate"; do + /usr/libexec/PlistBuddy -c "$command" Gen/Info.plist > /dev/null || die "external display scene" + done +fi # DM_METAL_CAPTURE=1: a development build that may write GPU frame captures (MetalCaptureEnabled; the host's DM_GPU_CAPTURE switch writes them to Documents/gpu_captures) if [ "${DM_METAL_CAPTURE:-0}" = 1 ]; then /usr/libexec/PlistBuddy -c "Add :MetalCaptureEnabled bool true" Gen/Info.plist > /dev/null || die "MetalCaptureEnabled"; t "Metal frame capture: enabled (DM_METAL_CAPTURE=1)"; fi DM_RESOURCE_BUILD=${PKG:-} GAME=$GAME python3 tools/gen_resources.py "$IOS" "$ROMFS" "$MARKER" "$MSL_IOS/$MSLDIR" "$MSLDIR" "$AOC" "$XCODE_DD" "$DM/gfx/nvn/dmsh_chain.h" "$PSO" || die "generate Xcode resource paths" diff --git a/dm/ios/tools/build_scene_metrics_probe.py b/dm/ios/tools/build_scene_metrics_probe.py index d5ddfc7c3f..1694b98ab0 100644 --- a/dm/ios/tools/build_scene_metrics_probe.py +++ b/dm/ios/tools/build_scene_metrics_probe.py @@ -5,6 +5,7 @@ used. Install/run the resulting app separately on an authorized test device. """ import argparse +import hashlib import json import os from pathlib import Path @@ -18,7 +19,12 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', required=True, type=Path, help='new scratch directory') + parser.add_argument('--external-scenes', action='store_true', help='build the programmatic external-routing probe instead of the default metrics probe') + parser.add_argument('--destination', default='generic/platform=iOS', help='Xcode build destination; default is an unsigned device build') args = parser.parse_args() + name = 'ExternalSceneVerification' if args.external_scenes else 'DisplayVerification' + source = 'external_scene_check.m' if args.external_scenes else 'scene_metrics_check.m' + delegate = 'ExternalPolicyScene' if args.external_scenes else 'MetricsScene' 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') @@ -26,40 +32,56 @@ def main(): output.mkdir(parents=True) include = output / 'Include' include.mkdir() - subprocess.run([sys.executable, str(REPO / 'dm/games/game.py'), 'header', game, + subprocess.run([sys.executable, '-B', str(REPO / 'dm/games/game.py'), 'header', game, str(include / 'game_profile.h')], check=True) project = { - 'name': 'DisplayVerification', + 'name': name, 'options': {'deploymentTarget': {'iOS': '26.0'}}, - 'targets': {'DisplayVerification': { + 'targets': {name: { 'type': 'application', 'platform': 'iOS', - 'sources': [{'path': str(REPO / 'dm/ios/tests/scene_metrics_check.m')}], + 'sources': [{'path': str(REPO / 'dm/ios/tests' / source)}], 'dependencies': [{'sdk': name + '.framework'} for name in ('UIKit', 'Foundation', 'QuartzCore', 'Metal', 'AVFAudio', 'GameController')], 'settings': {'base': { - 'PRODUCT_BUNDLE_IDENTIFIER': 'dev.anygame.display-verification', + 'PRODUCT_BUNDLE_IDENTIFIER': 'dev.anygame.external-scene-verification' if args.external_scenes else 'dev.anygame.display-verification', 'ENABLE_DEBUG_DYLIB': 'NO', 'CODE_SIGN_STYLE': 'Automatic', 'TARGETED_DEVICE_FAMILY': '1', - 'HEADER_SEARCH_PATHS': [str(REPO / 'dm/host'), str(include)], + 'HEADER_SEARCH_PATHS': [str(REPO / 'dm/host'), str(REPO / 'dm/ios/Sources'), str(include)], }}, 'info': {'path': 'Info.plist', 'properties': { - 'CFBundleDisplayName': 'Display Verification', 'UILaunchScreen': {}, + 'CFBundleDisplayName': 'External Display Test' if args.external_scenes else 'Display Verification', 'UILaunchScreen': {}, 'UIFileSharingEnabled': True, 'UISupportedInterfaceOrientations': ['UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], 'UIApplicationSceneManifest': {'UIApplicationSupportsMultipleScenes': False, 'UISceneConfigurations': {'UIWindowSceneSessionRoleApplication': [{ - 'UISceneConfigurationName': 'Default', 'UISceneDelegateClassName': 'MetricsScene'}]}}, + 'UISceneConfigurationName': 'Default', 'UISceneDelegateClassName': delegate}]}}, }}, }}, } + if args.external_scenes: + properties = project['targets'][name]['info']['properties'] + properties['DMExternalDisplayEnabled'] = True + properties['UIApplicationSceneManifest']['UISceneConfigurations']['UIWindowSceneSessionRoleExternalDisplayNonInteractive'] = [{ + 'UISceneConfigurationName': 'External game display', 'UISceneDelegateClassName': 'DmExternalSceneDelegate'}] + paths = ['dm/ios/tests/' + source, 'dm/ios/Sources/front_ios.m', 'dm/ios/Sources/DmSceneRouting.h'] + if args.external_scenes: + paths.append('dm/ios/Sources/main.m') + provenance = { + 'commit': subprocess.check_output(['git', '-C', str(REPO), 'rev-parse', 'HEAD'], text=True).strip(), + 'sourceSHA256': {path: hashlib.sha256((REPO / path).read_bytes()).hexdigest() for path in paths}, + 'gameProfile': game, + } + provenance_file = output / 'SourceProvenance.json' + provenance_file.write_text(json.dumps(provenance, indent=2) + '\n') + project['targets'][name]['sources'].append({'path': str(provenance_file), 'buildPhase': 'resources'}) spec = output / 'project.yml' spec.write_text(json.dumps(project, indent=2) + '\n') subprocess.run(['xcodegen', 'generate', '--spec', str(spec)], check=True) - subprocess.run(['xcodebuild', '-project', str(output / 'DisplayVerification.xcodeproj'), - '-scheme', 'DisplayVerification', '-configuration', 'Debug', - '-destination', 'generic/platform=iOS', '-derivedDataPath', str(output / 'DerivedData'), + subprocess.run(['xcodebuild', '-project', str(output / f'{name}.xcodeproj'), + '-scheme', name, '-configuration', 'Debug', + '-destination', args.destination, '-derivedDataPath', str(output / 'DerivedData'), '-jobs', '3', 'CODE_SIGNING_ALLOWED=NO', 'build'], check=True) - print(output / 'DerivedData/Build/Products/Debug-iphoneos/DisplayVerification.app') + print(output / 'DerivedData/Build/Products' / ('Debug-iphonesimulator' if 'Simulator' in args.destination else 'Debug-iphoneos') / f'{name}.app') if __name__ == '__main__': diff --git a/docs/development.md b/docs/development.md index 04421a906c..0af9b76734 100644 --- a/docs/development.md +++ b/docs/development.md @@ -398,6 +398,14 @@ The unsigned IPA build still uses the repository's `dm/ios/DmIOS.xcodeproj`, ski The iPhone settings list can require a verified rewrite capability (`fixups:` in `dm/games/settings.tsv`). A game's `patches.py` declares `CAPABILITIES = {build ID: (name, ...)}`; the rewriter writes those flags only for that main build. This keeps controls that require particular rewritten instructions out of older or unsupported builds. For example, NieR's resolution control requires its native scale/UV path and configurable preset bounds. +### Experimental external-display routing + +Set `DM_EXTERNAL_DISPLAY=1` when running `dm/ios/tools/build_ipa.sh` to advertise a noninteractive external scene. This is a build-time opt-in. Default builds keep their existing scene manifest. iOS 27 registers a `UISceneAccessory` on the phone's game controller; iOS 26 uses the external scene role in the manifest. + +The external scene takes the existing game view and `CAMetalLayer`, and the phone keeps the touch controls, launcher, frame-rate label and text-input window. A handover drains graphics submissions and pauses the display link before moving the view, updates the scene metrics, then resumes. Disconnecting returns the same view to the phone. Scene focus is aggregated across the phone and external outputs, so a background notification from one scene cannot suspend another visible scene. Only the phone starts the guest runtime. + +The isolated native probe builds with `GAME= python3 dm/ios/tools/build_scene_metrics_probe.py --external-scenes --output `. It includes the complete production frontend and scene delegates, moves real UIKit views between two windows on one phone scene, and injects external scene identities. Graphics-gate and display-link calls are recorded by stubs. `Documents/results.json` records each assertion and the source hashes. This checks the routing policy and ownership; a physical external scene, cross-display drawable delivery, refresh-rate handover, controller input, and unplug during real GPU rendering still need hardware testing. The output remains SDR and preserves the existing 16:9 fit. + ## Port another game Each game has a profile in `dm/games//`: diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index 862d058d90..02c853889a 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -25,6 +25,7 @@ DM_ENTITLEMENTS path `=` gives a prepared Xcode project another entitleme DM_ENV_FILE path `=` makes `build_ipa.sh` bake its `KEY=VALUE` lines into the app as `dm/env.txt`, applied at boot; `Documents/env.txt` overrides them DM_ENV_FILE_KEYS switch Set by the iOS app and the Mac launcher at launch to the space-separated names an env file set; the settings screen keeps those values and shows them greyed out DM_ENV_LAUNCH_KEYS switch Set at launch by the Mac launcher (the launcher settings already in the environment) and the iOS app (every DM_* variable already in it) to the space-separated names; the settings screen keeps those values and shows them greyed out +DM_EXTERNAL_DISPLAY switch `=1` at iOS build time opts into external-display content scenes; one render layer moves to the visible external scene while controls remain on the phone. iOS 27 registers a scene accessory; iOS 26 uses the legacy scene role. Off by default; experimental, physical display testing pending DM_FD_LOG diagnostic `=` logs, every that many seconds, the number and kinds of file descriptors the process has open DM_FIXED_TIME switch `=`: the user clock always returns that time and the host time zone is pinned to UTC, for deterministic comparison DM_FONT_DIR path Folder of the shared-font stand-ins (`standard.ttf`, `nintendo_ext.ttf` with the button icons); the app, the Mac launcher and the NSP script set it