diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index 6d26f8d..7d72888 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -8264,8 +8264,10 @@ static int g_link_owns_layer, g_classic_busy; // Under g_pq_mu: nextDrawable t static int link_on(void) { return g_dm_present_link && g_link != nil; } // Screens whose refresh rate is not a multiple of the link's (144 or 165 Hz against 60 or 30): asked for 60 Hz, the system fires the link on every third refresh of a 165 Hz screen, // 55 Hz, and a game paced by the link runs at 55 fps (27.5 at 30). There the link runs at the screen's rate and only the refreshes nearest a steady clock of the wanted rate count as -// ticks (g_link_vperiod, NvMetalDisplayLinkDelegate): 60 a second on average, each frame shown on the refreshes nearest its 1/60 s slot. DM_LINK_VTICK=0 keeps the system's choice +// ticks (g_link_vperiod, NvMetalDisplayLinkDelegate): 60 a second on average, each frame shown on the refreshes nearest its 1/60 s slot. DM_LINK_VTICK=0 keeps the system's choice. +// In fullscreen the Mac front end switches such a screen to a rate that is a multiple where it has one (front_screen_match), and the range is set again for it (link_sync) __attribute__((weak)) int front_screen_max_fps(void) { return 0; } // front_mac.m; graphics unit tests and iOS have none +__attribute__((weak)) void front_screen_match(int fps) { (void)fps; } // front_mac.m; graphics unit tests and iOS have none static CAFrameRateRange link_range(int mode) { static int on = -1; if (on < 0) { const char *e = getenv("DM_LINK_VTICK"); on = !(e && !strcmp(e, "0")); } int rate = mode == 3 ? 30 : mode == 2 ? 120 : 60, hz = on ? front_screen_max_fps() : 0; @@ -8299,6 +8301,11 @@ static void link_sync(void) { // Called every frame by the game's present thre static int applied; int want = g_dm_present_link; if (want < 0 || want > 2) { g_dm_present_link = applied == 3 ? 1 : applied; return; } if (want && mtl_fps_target() == 30) want = 3; // Power-saving mode: display link at 30 Hz + if (want) front_screen_match(want == 3 ? 30 : want == 2 ? 120 : 60); + // The screen's rate changed (switched by front_screen_match, or another screen): the range again, virtual ticks or none. Taken only once the link object exists, so a change + // while its thread starts (after link_main read the rate) is not lost + static int hz_was; int hz = front_screen_max_fps(); CAMetalDisplayLink *lk = g_link; + if (lk && hz != hz_was) { hz_was = hz; if (want && want == applied) { lk.preferredFrameRateRange = link_range(want); MLOG("display link: the screen runs at %d Hz now, range set again (frame %llu)", hz, (unsigned long long)atomic_load(&g_cur_frame)); } } if (want == applied) return; g_link_mode = want; if (!g_link_started) { diff --git a/dm/host/front.h b/dm/host/front.h index f6dd17a..5b7b49b 100644 --- a/dm/host/front.h +++ b/dm/host/front.h @@ -6,6 +6,9 @@ void front_run(void (*guest)(void)); void *front_metal_layer(void); // The highest refresh rate of the window's screen in Hz, 0 = not known (Mac front_mac.m; none on iOS, whose screens run at multiples of 60 Hz). Any thread int front_screen_max_fps(void); +// The frame rate the display link paces the game at (60, 30, 120; nvn_mtl.m link_sync): in fullscreen, a screen whose refresh rate is not a multiple of it is switched, while the game runs, +// to a mode of the same size whose rate is, and back when the window leaves fullscreen or that screen (front_mac.m; none on iOS). Called from the present thread +void front_screen_match(int fps); #include // Text input box (for the software keyboard swkbd): the main thread shows a native input box and the calling thread blocks until the user confirms / cancels. // All text is UTF-8; max_chars is the maximum character count (0 = unlimited, only a hint for the front end; truncation is done uniformly by the caller hle_swkbd.c in UTF-16 units). diff --git a/dm/host/front_mac.m b/dm/host/front_mac.m index 62a0f59..281e8f2 100644 --- a/dm/host/front_mac.m +++ b/dm/host/front_mac.m @@ -12,6 +12,7 @@ #include "front.h" #include "hle.h" #include +#include static CAMetalLayer *g_layer; static uint64_t g_btn; @@ -20,6 +21,62 @@ static int g_w, g_a, g_s, g_d; void *front_metal_layer(void) { return (__bridge void *)g_layer; } static _Atomic int g_screen_fps; // the window's screen's maximumFramesPerSecond, kept up to date when the window moves to another screen int front_screen_max_fps(void) { return g_screen_fps; } +// The screen's refresh rate matched to the game's frame rate in fullscreen (front_screen_match, from PR #79; DM_SCREEN_MATCH=0 turns it off). 60 fps on a 144 Hz screen cannot be paced +// evenly: the display link counts a tick on the refresh nearest each 1/60 s slot (nvn_mtl.m link_range), so frames stay up 2 and 3 refreshes in turn (13.9 / 20.8 ms) and Apple's HUD shows +// the frame interval jumping between them. In fullscreen, a screen whose rate is not a multiple of the game's switches to the mode of the same size with the highest rate that is (144 → 120 Hz +// for 60 and 30 fps), for this app only: macOS switches it back when the host quits, crashes included, and leaving fullscreen or that screen switches it back at once. A window keeps the +// screen's rate, because the switch changes the whole screen for every app and most external screens go black for a second or more. A screen with no such mode keeps the virtual ticks +static NSWindow *g_win; static _Atomic int g_match_want; // the game's window; the frame rate to match (0 = not known yet), written by the present thread only +static CGDirectDisplayID g_match_id; static CGDisplayModeRef g_match_orig; // main thread: the screen this host switched and its own mode (NULL = none switched) +static double mode_hz(CGDisplayModeRef m) { double hz = CGDisplayModeGetRefreshRate(m); return hz > 0 ? hz : 0; } // 0 = not known (older built-in screens report none) +static int hz_multiple(double hz, int fps) { double k = round(hz / fps); return k >= 1 && fabs(hz / fps - k) < 0.01; } // 59.94 Hz counts as 60 +static CGError screen_set_mode(CGDirectDisplayID id, CGDisplayModeRef m) { // for this app only (kCGConfigureForAppOnly) + CGDisplayConfigRef cfg; CGError r = CGBeginDisplayConfiguration(&cfg); + if (r != kCGErrorSuccess) return r; + r = CGConfigureDisplayWithDisplayMode(cfg, id, m, NULL); + if (r != kCGErrorSuccess) { CGCancelDisplayConfiguration(cfg); return r; } + return CGCompleteDisplayConfiguration(cfg, kCGConfigureForAppOnly); +} +static void screen_match_restore(int on_it) { // main thread: the screen this host switched gets its own mode again; on_it: the window is on that screen + double hz = mode_hz(g_match_orig); CGError r = screen_set_mode(g_match_id, g_match_orig); + LOG("front end: screen %s %.0f Hz", r == kCGErrorSuccess ? "back at" : "could not be switched back to", hz); + if (r == kCGErrorSuccess && on_it && hz > 0) g_screen_fps = (int)round(hz); + CGDisplayModeRelease(g_match_orig); g_match_orig = NULL; +} +static void screen_match_apply(void) { // main thread: on a new frame rate, on entering and leaving fullscreen and on a move to another screen + static int off = -1; if (off < 0) { const char *e = getenv("DM_SCREEN_MATCH"); off = e && !strcmp(e, "0"); if (off) LOG("front end: the screen keeps its refresh rate in fullscreen too (DM_SCREEN_MATCH=0)"); } + NSScreen *scr = g_win.screen; int want = g_match_want, full = (g_win.styleMask & NSWindowStyleMaskFullScreen) != 0; + CGDirectDisplayID id = scr ? [scr.deviceDescription[@"NSScreenNumber"] unsignedIntValue] : kCGNullDirectDisplay; + if (g_match_orig && (!full || id != g_match_id)) screen_match_restore(id == g_match_id); + if (off || !full || want <= 0 || !scr) return; + CGDisplayModeRef cur = CGDisplayCopyDisplayMode(id); + if (!cur) return; + double hz = mode_hz(cur); + if (hz <= 0 || hz_multiple(hz, want)) { // already a multiple, or not known + if (g_match_orig && hz > 0) g_screen_fps = (int)round(hz); // the screen this host switched: its rate from the mode, which maximumFramesPerSecond (the screen observer) may not show yet + CGDisplayModeRelease(cur); return; + } + size_t w = CGDisplayModeGetWidth(cur), h = CGDisplayModeGetHeight(cur), pw = CGDisplayModeGetPixelWidth(cur), ph = CGDisplayModeGetPixelHeight(cur); + CFArrayRef modes = CGDisplayCopyAllDisplayModes(id, (__bridge CFDictionaryRef)@{(__bridge NSString *)kCGDisplayShowDuplicateLowResolutionModes: @YES}); + CGDisplayModeRef pick = NULL; double pick_hz = 0; + for (CFIndex i = 0; modes && i < CFArrayGetCount(modes); i++) { + CGDisplayModeRef m = (CGDisplayModeRef)CFArrayGetValueAtIndex(modes, i); double r = mode_hz(m); + if (CGDisplayModeGetWidth(m) != w || CGDisplayModeGetHeight(m) != h || CGDisplayModeGetPixelWidth(m) != pw || CGDisplayModeGetPixelHeight(m) != ph || !CGDisplayModeIsUsableForDesktopGUI(m)) continue; + if (hz_multiple(r, want) && r > pick_hz) { pick = m; pick_hz = r; } + } + if (!pick) LOG("front end: screen at %.0f Hz has no mode of this size at a multiple of %d Hz: frames are paced on the nearest refreshes", hz, want); + else { + CGError r = screen_set_mode(id, pick); + LOG("front end: screen at %.0f Hz %s %.0f Hz for %d fps in fullscreen (DM_SCREEN_MATCH=0 keeps it)", hz, r == kCGErrorSuccess ? "switched to" : "could not be switched to", pick_hz, want); + if (r == kCGErrorSuccess) { if (!g_match_orig) { g_match_orig = CGDisplayModeRetain(cur); g_match_id = id; } g_screen_fps = (int)round(pick_hz); } + } + if (modes) CFRelease(modes); + CGDisplayModeRelease(cur); +} +void front_screen_match(int fps) { + if (!g_layer || g_match_want == fps) return; // no window (a windowless run's main thread runs the guest), or the rate as before + g_match_want = fps; dispatch_async(dispatch_get_main_queue(), ^{ screen_match_apply(); }); +} static void push(void) { int32_t lx = (g_d - g_a) * 32767, ly = (g_w - g_s) * 32767; dm_input_set(g_btn, lx, ly, 0, 0); @@ -127,6 +184,7 @@ void front_run(void (*guest)(void)) { [win center]; [win makeKeyAndOrderFront:nil]; g_screen_fps = (int)(win.screen ?: NSScreen.mainScreen).maximumFramesPerSecond; + g_win = win; [NSApp activateIgnoringOtherApps:YES]; // Main menu: AppKit runs ⌘ shortcuts only through menu items, so ⌘Q quits and ⌃⌘F toggles fullscreen from here (AppKit shows the item as Globe-F and keeps ⌃⌘F as a hidden twin) NSMenu *bar = [NSMenu new], *app = [NSMenu new], *wm = [[NSMenu alloc] initWithTitle:@"Window"]; @@ -152,11 +210,11 @@ void front_run(void (*guest)(void)) { usingBlock:^(NSNotification *n) { (void)n; exit(0); }]; // Lifecycle (hle.h / dm.h): focus lost → 2, focus regained → 1; minimized → 3 + audio paused + GPU suspended, restored → resume (followed by focus regained → 1) NSNotificationCenter *nc = NSNotificationCenter.defaultCenter; - [nc addObserverForName:NSWindowDidChangeScreenNotification object:win queue:nil usingBlock:^(NSNotification *n) { (void)n; if (win.screen) { g_screen_fps = (int)win.screen.maximumFramesPerSecond; g_layer.contentsScale = win.backingScaleFactor; } }]; + [nc addObserverForName:NSWindowDidChangeScreenNotification object:win queue:nil usingBlock:^(NSNotification *n) { (void)n; if (win.screen) { g_screen_fps = (int)win.screen.maximumFramesPerSecond; g_layer.contentsScale = win.backingScaleFactor; } screen_match_apply(); }]; // Entering and leaving fullscreen: the layer is layer-backed, so its frame (and with it the drawable) follows the - // content view by itself; only the scale factor has to be carried over to the screen the window ended up on. - [nc addObserverForName:NSWindowDidEnterFullScreenNotification object:win queue:nil usingBlock:^(NSNotification *n) { (void)n; g_layer.contentsScale = win.backingScaleFactor; }]; - [nc addObserverForName:NSWindowDidExitFullScreenNotification object:win queue:nil usingBlock:^(NSNotification *n) { (void)n; g_layer.contentsScale = win.backingScaleFactor; }]; + // content view by itself; only the scale factor has to be carried over to the screen the window ended up on. The screen's rate is matched in fullscreen only (screen_match_apply) + [nc addObserverForName:NSWindowDidEnterFullScreenNotification object:win queue:nil usingBlock:^(NSNotification *n) { (void)n; g_layer.contentsScale = win.backingScaleFactor; screen_match_apply(); }]; + [nc addObserverForName:NSWindowDidExitFullScreenNotification object:win queue:nil usingBlock:^(NSNotification *n) { (void)n; g_layer.contentsScale = win.backingScaleFactor; screen_match_apply(); }]; [nc addObserverForName:NSWindowDidResignKeyNotification object:win queue:nil usingBlock:^(NSNotification *n) { (void)n; if (!win.miniaturized) dm_focus_set(2); }]; [nc addObserverForName:NSWindowDidBecomeKeyNotification object:win queue:nil usingBlock:^(NSNotification *n) { (void)n; dm_focus_set(1); }]; [nc addObserverForName:NSWindowDidMiniaturizeNotification object:win queue:nil usingBlock:^(NSNotification *n) { (void)n; dm_focus_set(3); dm_audio_suspend(1); dm_gfx_suspend(1); }]; diff --git a/docs/agents/04-graphics.md b/docs/agents/04-graphics.md index b58a3dc..f1e5586 100644 --- a/docs/agents/04-graphics.md +++ b/docs/agents/04-graphics.md @@ -113,6 +113,7 @@ So the translator leaves z alone (`QueryGraphicsState` depth mode false in `Prog - **Dynamic present interval (Mario 1.2.1 on the iPhone)**: Mario times every frame (`main+0xcfd24`); after a frame that ran past one vertical sync it sets present interval 2 for the next frame *and* sets its own time step to the interval (a float at `main+0x36ceff0`), so that frame catches up 2/60 s. A user's 2-minute iPhone session switched 123 times, mostly for one frame. In `display` mode those frames were shown for 16.7 ms and the game visibly jumped ahead ("sometimes the speed increases when jumping"). In `game` mode (the default since 2026-10-15) the display-link callback keeps a frame whose period is over 25 ms on screen for its whole period before taking the next one (`g_link_hold_until` in `nvn_mtl.m`; the `display link up to tick` log line counts these ticks as `held for the present interval`), and the 30 fps target that drops the display link to 30 Hz (`nvn_game_fps` in `hle_nvn.c`) waits until interval 2 has held for a full second, so one-frame switches no longer flip the display-link mode. **Fixed: the game side of that hold (BotW on the iPhone, "when pressing B, Link's move pace is speeding up").** The acquire waited for one display-link tick per frame whatever the interval, so the game kept producing frames every 16.7 ms while the screen held each interval-2 frame for 33 ms and dropped the one in between (`old frames dropped by merging`). BotW sprinting flaps between interval 1 and 2 for runs of 6 or more frames, and its VFR time step at interval 2 is a whole 30 fps tick, so those runs went at double speed. `mtl_link_wait_tick` now waits for as many ticks as the game's present interval (times 2 on the 120 Hz link, half rounded up on the 30 Hz link), as the Switch blocks the next acquire for N vertical syncs. The windowless Mac host was never affected: there the acquire sleeps until the interval's slot. `DM_NVN_TSV` writes each frame's interval as `@itv`. `DM_TEST_PRESENT_LAG=90,30` (sleep 30 ms before every 90th present) makes Mario do this on the Mac; run it with `DM_WINDOW=1` to get the display link. - **Screens whose rate is not a multiple of 60 (144 or 165 Hz Mac displays; from PR #27)**: asked for 60 Hz, the system fires the display link on every third refresh of a 165 Hz screen, 55 Hz, so games paced by it ran at 55 fps (27.5 at 30). There `link_range` (`nvn_mtl.m`) runs the link at the screen's rate and counts as ticks only the refreshes nearest a steady 1/60 s clock: 60 a second on average, each frame shown 12.1 or 18.2 ms at 165 Hz. A tick lies up to half a refresh off its slot, so two ticks can be one refresh nearer each other than their slots: the hold of frames over 25 ms tolerates one refresh more (in a simulation of the callback without it, 30 fps frames held a third tick: 22.5 fps at 165 Hz, 24 at 144), and the frame step lets a frame on time show one refresh longer than its hold (`shown_slack`, `nvn_fstep.h`; a cheap 50 ms frame shows 54.5 ms at 165 Hz, and counted as late it kept Breath of the Wild at 20 fps for good). `DM_LINK_VTICK=0` keeps the system's choice. iOS screens run at multiples of 60 and are not affected. Not yet run on such a screen here; the PR's author measured at 165 Hz. +- **In fullscreen, the screen switched to a rate the frame rate divides (from PR #79)** (`front_mac.m` `front_screen_match`, called from `link_sync` with the link's rate: 60, 30, or 120 with `DM_PRESENT_LINK=2`): virtual ticks keep the average, but a frame still stays up two or three refreshes in turn (13.9 / 20.8 ms at 144 Hz), and Apple's HUD showed the frame interval jumping between them. When the window is fullscreen on a screen whose mode is not a multiple of the link's rate, the host switches that screen to the mode of the same size with the highest rate that is (an XB273U at 2560x1440: 144 → 120 Hz for 60 fps), with `kCGConfigureForAppOnly`, so macOS switches it back when the host quits, crashes included (the PR's author checked: 144 Hz again after quitting); leaving fullscreen, or the screen, switches it back at once. A window keeps the screen's rate and the virtual ticks: the switch changes the whole screen for every app, and most external screens go black for a second or more. The link sets its range again whenever the screen's rate changes (`link_sync`; before, a move to another screen kept the first screen's range), with no virtual ticks after a switch. A screen with no such mode keeps the virtual ticks; `DM_SCREEN_MATCH=0` keeps the screen's rate. The PR's author ran Hades at 144 Hz (switched in a window then): the HUD's frame interval line steady at 16.7 ms, where it jumped before. Here (a built-in 120 Hz screen, a multiple of every link rate) a harness drove `front_mac.m` asked for 50 fps: no switch in the window, 120 → 50 Hz in fullscreen, 120 Hz again on leaving it, and 120 Hz after quitting in fullscreen; at 60 fps and with `DM_SCREEN_MATCH=0` nothing switched. The link's new range after a switch has not run here (Hades windowed and fullscreen paced at 60 as before). ### Seen in Engage (Unity) diff --git a/docs/development.md b/docs/development.md index 9c3bc44..6e599c1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -10,7 +10,7 @@ The README covers packing and playing. This page is for working on nxnative itse | Host (`dm/host`) | Stands in for the Horizon kernel and the system services the games call: threads and synchronization, memory, file system with transactional saves, HID, audio, movies (VP9 through libvpx, Vorbis sound through stb_vorbis), software keyboard, focus and lifecycle | | Graphics (`dm/gfx`) | NVN on Metal. Shaders are extracted by `shader_split.py` from Nintendo's containers and from Unity's `Shader` objects, then translated offline. Mario: 16,266 of 16,268 shaders compile. Engage: 9,268 of 9,269. Games that render with Vulkan (Hades) or OpenGL (Digimon Story Cyber Sleuth) go through layers of their own on Metal (`dm/gfx/vk`, `dm/gfx/gl`) | | Audio | Games that carry the SDK's audio renderer (Mario) run it in-process; games that use AudioOut directly (Engage: Wwise) feed it straight. Output goes through Core Audio (default on iOS, `DM_AUDIO=1` on the Mac) | -| Input | iOS: the touch gamepad and physical controllers; shaking the phone shakes the controller (its six-axis sensor, `hle_hid.c`; the accelerometer in `DmGamepad.m`, threshold `DM_SHAKE_G`, 1.5 G by default). Mac: scripted input (`DM_KEYS=: