diff --git a/dm/games/0400E88025B1A000/README.md b/dm/games/0400E88025B1A000/README.md index 478a557eb0..2188156ecd 100644 --- a/dm/games/0400E88025B1A000/README.md +++ b/dm/games/0400E88025B1A000/README.md @@ -47,6 +47,49 @@ On an M5 Pro with 24 GB, macOS 27.0, handheld mode and Resolution 100% give 1920 `python3 scripts/build_ipa_from_nsp.py --platform mac` builds the Mac shaders and the host and prints the run command. Headless, through `DM_CTL`: the title is up about 40 s after boot and `A` starts the game. The prologue cinematic follows, about 4 minutes on every run (a save holds no progress yet, and skipping it was not tried), then the tutorial's first question, about 300 s after boot: `LDown`, `A` answers no. Shots of the swap chain hold the picture in their top-left quarter. A `shot` takes about 165 ms: a hitch every few seconds in a run that takes them is the shot's. +## Experimental Mac test modes + +The optional selector in `mac_test_launcher.m` has two independent dropdowns: +**Resolution: 1080p / 4K** and **Frame rate: 60 fps / 120 fps / Unlocked**. +All six combinations are available in one existing test app. All modes use +one RomFS, shader set and save folder. `scripts/configure_starfox_test.py` updates +only that app's executables and rewritten main, backing up changed files only. +It deliberately accepts the isolated `org.nxnative.starfox.120hz-test` bundle, +not the normal app. + +The build-gated output stubs use state +0xa4: -1 (unset) runs the original game +paths, 0 selects its zero desired-frame-time path and current-clock update, +and 60 or 120 selects its positive 1/60- or 1/120-second interval. The uncapped path bypasses +both output-stage waits; it does not remove a simulation-thread sleep or scale +the game clock. The two refresh fallbacks use 120 only during the experiment; +correct SDK VI refresh/capability service semantics remain open. + +Uncapped modes also set `DM_PRESENT_HZ=0`, `DM_PRESENT_LINK=0` and +`DM_PRESENT_VSYNC=0`. This removes software and Metal synchronization limits; +the number of frames the physical screen can display is still finite. 4K uses +docked mode, `DM_VI_BUFFER_MAX=3` and scene Resolution 100%, then scales the +3840x2160 output to the Mac's screen. The 1080p modes use handheld mode and +`DM_VI_BUFFER_MAX=1`. Render-pass dimensions, not screenshot size alone, establish +the scene resolution. + +The source is based on the verified Mac integration so the existing shader +libraries remain usable. Build the host with `make -C dm/host GAME=0400E88025B1A000`, +rewrite main with this profile, then run: + +```sh +python3 scripts/configure_starfox_test.py '/path/Star Fox 120 Hz Test.app' \ + --patched /path/to/rewritten-main --backup /path/to/new-backup-folder +``` + +On the M5 Pro 24 GB, macOS 27.0, the player reported roughly 300 fps in the +1080p uncapped flight and confirmed normal movement, aiming and audio. Pass +captures showed 1920x1080 scene color/depth targets in that mode and 3840x2160 +scene targets in 4K mode. This is opening-tutorial evidence, not a campaign or +4K frame-rate guarantee. + +The test modes remain experimental: frame throughput does not prove correct +movement speed, physics, audio/movie sync or later-level stability. + ## Found on this game - The avatar settings present the virtual camera's picture into an NVN window of their own; the host makes it an off-screen window: [GameChat queries from the title screen (SDK 21)](../../../docs/reference/services.md#gamechat-queries-from-the-title-screen-sdk-21). @@ -62,7 +105,7 @@ On an M5 Pro with 24 GB, macOS 27.0, handheld mode and Resolution 100% give 1920 ## Open -- Native 120 Hz: the host has no takeover for `nn::vi::QueryDisplayCurrentRefreshRate` or `SetDisplay120HzRefreshRateCapability`. SDK 21 rejects the host's display handle, and the game's refresh queries keep their prefilled 60 Hz answer. Exposing 120 Hz still needs correct display semantics and simulation, audio, movie and input checks. No frame-rate unlock is enabled in this fork. +- Native 120 Hz: the host has no takeover for `nn::vi::QueryDisplayCurrentRefreshRate` or `SetDisplay120HzRefreshRateCapability`. SDK 21 rejects the host's display handle, and the game's refresh queries keep their prefilled 60 Hz answer by default. The optional test selector uses build-gated refresh fallbacks and output timing stubs; correct SDK display semantics and later-level checks remain open. - [The picture](#the-picture): 768x432 docked until #1304 and #1305 are in; the Resolution setting raises the scene above the game's own scale. - `[RED] DLSS: the game's bias mask (a reactive mask for MetalFX) is not passed on`. diff --git a/dm/games/0400E88025B1A000/mac_test_launcher.m b/dm/games/0400E88025B1A000/mac_test_launcher.m new file mode 100644 index 0000000000..d286b7e764 --- /dev/null +++ b/dm/games/0400E88025B1A000/mac_test_launcher.m @@ -0,0 +1,60 @@ +// The optional Star Fox experiment selector. All modes share the app's data, shaders and save. +#import +#include + +int main(int argc, char **argv) { + @autoreleasepool { + NSArray *resolutions = @[@"1080p", @"4k"]; + NSArray *rates = @[@"60", @"120", @"uncapped"]; + NSUserDefaults *prefs = NSUserDefaults.standardUserDefaults; + NSString *mode = [prefs stringForKey:@"test.mode"] ?: @"1080p-uncapped"; + BOOL show = YES; + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--mode") && i + 1 < argc) { mode = @(argv[++i]); show = NO; } + else if (!strcmp(argv[i], "--no-launcher")) show = NO; + else { fprintf(stderr, "usage: nxnative [--mode <1080p|4k>-<60|120|uncapped>]\n"); return 2; } + } + NSArray *parts = [mode componentsSeparatedByString:@"-"]; + if (parts.count != 2 || ![resolutions containsObject:parts[0]] || ![rates containsObject:parts[1]]) { fprintf(stderr, "Unknown Star Fox test mode\n"); return 2; } + if (show) { + [NSApplication sharedApplication]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + [NSApp finishLaunching]; + NSAlert *alert = [NSAlert new]; + alert.messageText = @"Star Fox test settings"; + alert.informativeText = @"Choose the resolution and frame rate. 4K renders at 3840 × 2160 and scales to your screen."; + [alert addButtonWithTitle:@"Play"]; [alert addButtonWithTitle:@"Cancel"]; + NSView *controls = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 320, 84)]; + NSTextField *resLabel = [NSTextField labelWithString:@"Resolution"]; + resLabel.frame = NSMakeRect(0, 52, 120, 22); [controls addSubview:resLabel]; + NSPopUpButton *res = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(130, 48, 190, 32) pullsDown:NO]; + [res addItemsWithTitles:@[@"1080p", @"4K"]]; [res selectItemAtIndex:[resolutions indexOfObject:parts[0]]]; + res.accessibilityLabel = @"Resolution"; [controls addSubview:res]; + NSTextField *fpsLabel = [NSTextField labelWithString:@"Frame rate"]; + fpsLabel.frame = NSMakeRect(0, 12, 120, 22); [controls addSubview:fpsLabel]; + NSPopUpButton *fps = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(130, 8, 190, 32) pullsDown:NO]; + [fps addItemsWithTitles:@[@"60 fps", @"120 fps", @"Unlocked"]]; [fps selectItemAtIndex:[rates indexOfObject:parts[1]]]; + fps.accessibilityLabel = @"Frame rate"; [controls addSubview:fps]; + alert.accessoryView = controls; + [NSApp activateIgnoringOtherApps:YES]; + if ([alert runModal] != NSAlertFirstButtonReturn) return 0; + mode = [NSString stringWithFormat:@"%@-%@", resolutions[res.indexOfSelectedItem], rates[fps.indexOfSelectedItem]]; + [prefs setObject:mode forKey:@"test.mode"]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited]; // the host owns the game's windows, as in Launcher.swift + } + parts = [mode componentsSeparatedByString:@"-"]; + BOOL fourK = [parts[0] isEqualToString:@"4k"], uncapped = [parts[1] isEqualToString:@"uncapped"]; + setenv("DM_STARFOX_RENDER_HZ", uncapped ? "0" : parts[1].UTF8String, 1); + setenv("DM_PRESENT_VSYNC", uncapped ? "0" : "1", 1); + setenv("DM_VI_BUFFER_MAX", fourK ? "3" : "1", 1); + setenv("DM_OPMODE", fourK ? "1" : "0", 1); + setenv("DM_RESOLUTION_SCALE", "1", 1); + setenv("DM_PRESENT_HZ", "0", 1); setenv("DM_PRESENT_LINK", "0", 1); + setenv("DM_PRESENT_INTERVAL", "game", 1); + setenv("DM_LAUNCHER", "0", 1); setenv("DM_SAVED_CHOICES", "0", 1); + setenv("DM_METAL_HUD", "1", 1); + NSString *core = [NSBundle.mainBundle.executablePath.stringByDeletingLastPathComponent stringByAppendingPathComponent:@"nxnative-core"]; + execl(core.fileSystemRepresentation, core.fileSystemRepresentation, "--no-launcher", NULL); + perror("Starting Star Fox"); return 1; + } +} diff --git a/dm/games/0400E88025B1A000/patches.py b/dm/games/0400E88025B1A000/patches.py index 4e8459c50c..d0cbbe67a6 100644 --- a/dm/games/0400E88025B1A000/patches.py +++ b/dm/games/0400E88025B1A000/patches.py @@ -22,6 +22,50 @@ def frame_step(ctx): emit(B(here(), 0xb4498b0)) # done put(0xb4498ac, [B(0xb4498ac, start)], 'scene resolution scale from the host (DM_RESOLUTION_SCALE; 0 = the game\'s own)') + # Experimental output timing, state +0xa4: -1 = original, 0 = native unlimited path, 120 = desired frame time 1/120. + # The active sleeper was observed at main+0xb447270 on the output-stage thread. Leave the simulation thread alone. + site(0xb444adc, 'e12342bd2820201eed000054') + ctx.area(0xb444adc) + start = here() + emit(ADRP(9, here(), state)) + emit(0xb940a528) # ldr w8,[x9,#0xa4] + emit(0x37f80108) # tbnz w8,#31,original (+32) + emit(0x340000a8) # cbz w8,unlimited (+20) + emit(0x1e220101) # scvtf s1,w8 + emit(0x1e2e1000) # fmov s0,#1.0 + emit(0x1e211801) # fdiv s1,s0,s1 + emit(B(here(), 0xb444ae8)) + emit(0xf94033e0) # unlimited: ldr x0,[sp,#0x60], the engine object + emit(B(here(), 0xb444be4)) # native zero desired-time path + emit(0xbd4223e1) # original: ldr s1,[sp,#0x220] + emit(0x1e202028) # fcmp s1,#0.0 + emit(0x5400004c) # b.gt positive (+8) + emit(B(here(), 0xb444b00)) + emit(B(here(), 0xb444ae8)) + put(0xb444adc, [B(0xb444adc, start), 0xd503201f, 0xd503201f], 'experimental native output frame time (DM_STARFOX_RENDER_HZ)') + + site(0xb444c88, 'e81740b9') # ldr w8,[sp,#0x14] + start = here() + emit(ADRP(9, here(), state)) + emit(0xb940a528) # ldr w8,[x9,#0xa4] + emit(0x35000048) # cbnz w8,original (+8) + emit(B(here(), 0xb444d7c)) # uncapped: original current-clock path, skipping both output waits + emit(0xb94017e8) # original ldr w8,[sp,#0x14] + emit(B(here(), 0xb444c8c)) + put(0xb444c88, [B(0xb444c88, start)], 'uncapped output uses the original current-clock path; no simulation sleep removed') + + for off in (0xba66464, 0xba69750): + site(off, '88078052') + ctx.area(off) + start = here() + emit(0x52800788) # original 60 Hz fallback + emit(ADRP(9, here(), state)) + emit(0xb940a529) # ldr w9,[x9,#0xa4] + emit(0x37f80049) # tbnz w9,#31,done (+8) + emit(0x52800f08) # experiment's 120 Hz display fallback + emit(B(here(), off + 4)) + put(off, [B(off, start)], 'experiment display refresh fallback; SDK service semantics remain unimplemented') + FRAME_STEP = {'a7730d0f74d168d90422354c8317f76f': frame_step} # The launcher offers DM_RESOLUTION_SCALE (dm/games/settings.tsv) only for this build CAPABILITIES = {'a7730d0f74d168d90422354c8317f76f': ('RESOLUTION_SCALE',)} @@ -32,4 +76,7 @@ def frame_step(ctx): {'what': 'scene resolution scale', 'now': 'build', 'goal': 'game', 'purpose': 'rendering', 'why': "the game's own settings read in its renderer", 'blocked': 'frame-step stubs have no function-matched form yet'}, + {'what': 'experimental output timing', 'now': 'build', 'goal': 'game', 'purpose': 'frame rate', + 'why': 'the native desired-frame-time and current-clock paths in the output-stage loop', + 'blocked': 'addresses belong to this build; simulation and audio correctness above 60 fps remain unverified'}, ] diff --git a/dm/gfx/hle_vi.c b/dm/gfx/hle_vi.c index e2ae42302a..3dbe9a6515 100644 --- a/dm/gfx/hle_vi.c +++ b/dm/gfx/hle_vi.c @@ -119,6 +119,15 @@ HLE_FORCE("_ZN2nn2vi31QueryDisplayBufferMaxResolutionEPNS0_19BufferMaxResolution } static uint32_t buf_max(int *dw, int *dh) { // the index into k_buf_max, and the display's pixels (0 x 0 when not known) uint32_t r = 1; int w = 0, h = 0; + const char *override = getenv("DM_VI_BUFFER_MAX"); + if (override && *override) { + char *end; long v = strtol(override, &end, 10); + if (!*end && v >= 0 && v < 4) { + *dw = k_buf_max[v][0]; *dh = k_buf_max[v][1]; + static atomic_int once; if (!atomic_exchange(&once, 1)) LOG("vi: buffer maximum override %dx%d (DM_VI_BUFFER_MAX)", *dw, *dh); + return (uint32_t)v; + } + } if (dm_opmode() == 1 && front_display_pixels(&w, &h)) { if (w < h) { int t = w; w = h; h = t; } r = 0; for (uint32_t i = 0; i < 4; i++) if (w >= k_buf_max[i][0] && h >= k_buf_max[i][1]) r = i; diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index 55af55f7b6..8c6c4bccef 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -13401,7 +13401,10 @@ static int present_now(const tex_t *t, double period, uint64_t frame, uint64_t t if (!layer) return 0; if (g_present_layer_resident != layer) { [g_q4 addResidencySet:layer.residencySet]; g_present_layer_resident = layer; } #if TARGET_OS_OSX - if (!layer.displaySyncEnabled) layer.displaySyncEnabled = YES; // Windowed mode follows the display's vsync to prevent tearing + const char *vsync = getenv("DM_PRESENT_VSYNC"); + BOOL sync = !(vsync && !strcmp(vsync, "0")); + if (layer.displaySyncEnabled != sync) layer.displaySyncEnabled = sync; + static int sync_logged; if (!sync_logged) { sync_logged = 1; MLOG("displaySyncEnabled=%d (DM_PRESENT_VSYNC; default 1)", sync); } #endif present_pipeline(layer.pixelFormat); // Same as the original path: the first frame compiles the pipeline before taking the drawable if (g_grab_cb) grab_present(src, crop, layer.drawableSize); // Game picture needed by the whole-window screenshot (peek without the lock first; the real take locks inside the function) diff --git a/dm/host/loader.c b/dm/host/loader.c index 963bbcdd8d..5bf19ff3ac 100644 --- a/dm/host/loader.c +++ b/dm/host/loader.c @@ -295,10 +295,11 @@ static void read_layout(module_t *m, const char *dir) { // target, 0 = the game's own interval logic. The profile's fps_target, overridden by DM_FPS_TARGET (0, 30 or 60); the graphics layer decides the steps and paces presents // (hle_nvn.c fstep_tick) uint8_t *dm_fps_state; +static const char *g_fps_build; static const char *g_fps_mod; // the module holding the state page (for the log) static void install_fps_state(module_t *m) { if (m->fps_state_off + 0x40 > m->size || m->fps_state_off < m->key_page_off + m->key_page_size) die("%s: frame step state page out of range", m->name); - dm_fps_state = m->base + m->fps_state_off; g_fps_mod = m->name; + dm_fps_state = m->base + m->fps_state_off; g_fps_mod = m->name; g_fps_build = m->build_id; } // The settings in the state page, written when the game starts (main.c, after host loading): on iOS the loading runs at app launch, before the launcher has set DM_FPS_TARGET and // DM_RENDER_SCALE (Launcher.swift), and nothing reads the page before the game runs @@ -318,6 +319,16 @@ void dm_fps_state_apply(void) { if (qs && *qs) { char *end; q = strtof(qs, &end); if (end == qs || *end || !(q == 0 || (q >= 0.25f && q <= 1.0f))) { LOG("[RED] DM_RESOLUTION_SCALE=%s: must be 0 or 0.25 to 1.0; keeping the game's scale", qs); q = 0; } else if (q > 0) LOG("%s: scene rendered at %.2f of the target (DM_RESOLUTION_SCALE; only builds with that stub follow it)", g_fps_mod, q); } memcpy(dm_fps_state + 0xa0, &q, sizeof q); + // The Star Fox experiment's native output timer: -1 keeps the game, 0 is uncapped, 60/120 select its positive desired-frame-time path. + if (g_fps_build && !strcmp(g_fps_build, "a7730d0f74d168d90422354c8317f76f")) { + const char *hz = getenv("DM_STARFOX_RENDER_HZ"); int32_t rate = -1; + if (hz && !strcmp(hz, "0")) rate = 0; + else if (hz && !strcmp(hz, "60")) rate = 60; + else if (hz && !strcmp(hz, "120")) rate = 120; + else if (hz) LOG("[RED] DM_STARFOX_RENDER_HZ must be 0, 60 or 120; keeping the game's output timing"); + memcpy(dm_fps_state + 0xa4, &rate, sizeof rate); + LOG("main: Star Fox output timer %d Hz (-1 = game's own, 0 = uncapped)", rate); + } // Screen shape: DM_SCREEN_ASPECT, the screen's width / height between 1.3 and 1.77, makes the builds with screen-shape stubs // (Breath of the Wild 1.9.0's patches.py) render that shape: the output keeps its width (1920) and takes the height that gives the ratio, a multiple of 48 so the // 2/3-size layout space (1280 wide) stays whole: 1.43 → 1920x1344 and 1280x896. State +0x48 u32 output height, +0x4c u32 layout height, +0x50 its float; 0 = 16:9 diff --git a/dm/host/tests/t_vi_display.c b/dm/host/tests/t_vi_display.c index 19dbb45b03..60a5b10d36 100644 --- a/dm/host/tests/t_vi_display.c +++ b/dm/host/tests/t_vi_display.c @@ -25,5 +25,12 @@ int main(void) { uint32_t b = 99; uint64_t w = 0, h = 0; CHECK(h_vi_BufMaxRes(&b, NULL) == RES_OK && b == c[i].buf && layer(&w, &h) && w == c[i].lw && h == c[i].lh, c[i].what); } + setenv("DM_VI_BUFFER_MAX", "3", 1); g_mode = 1; g_ounce = 1; g_w = 1920; g_h = 1080; + uint32_t b = 99; uint64_t w = 0, h = 0; + CHECK(h_vi_BufMaxRes(&b, NULL) == RES_OK && b == 3 && layer(&w, &h) && w == 3840 && h == 2160, + "explicit 4K buffer and layer on a smaller display for supersampling"); + setenv("DM_VI_BUFFER_MAX", "invalid", 1); + CHECK(h_vi_BufMaxRes(&b, NULL) == RES_OK && b == 1, "invalid override keeps the display's own buffer maximum"); + unsetenv("DM_VI_BUFFER_MAX"); printf("== vi display %d checks, %d failed ==\n", checks, fails); return fails != 0; } diff --git a/dm/tools/docs_patches.py b/dm/tools/docs_patches.py index a3ae514bbc..d5ea49ac94 100644 --- a/dm/tools/docs_patches.py +++ b/dm/tools/docs_patches.py @@ -68,6 +68,9 @@ def payoff(gap): # the Gaps order: open gaps, then blocked, kept and unreviewe # The host's build-keyed rows: (table, the start of the row's label ('' = any), main build ('' = any), entry); the first that fits classifies a row HOST_BINDING = [ + ('', '', 'a7730d0f74d168d90422354c8317f76f', {'what': 'experimental output timing', 'now': 'build', 'goal': 'game', 'purpose': 'frame rate', + 'why': 'the host supplies the private state field read by this build\'s output-loop stubs', + 'blocked': 'addresses belong to this build; simulation and audio correctness above 60 fps remain unverified'}), ('k_locks', 'Unity', '', {'what': "Unity's Application.targetFrameRate at a fixed address", 'now': 'build', 'goal': 'engine', 'purpose': 'frame rate', 'why': 'an IL2CPP player registers the setter by its icall name, so the field can be found in any Unity game; whether to raise it stays the profile\'s'}), ('k_locks', 'RE Engine', '', {'what': "RE Engine's frame limit at a fixed pointer", 'now': 'build', 'goal': 'engine', 'purpose': 'frame rate', diff --git a/docs/agents/game-patches.md b/docs/agents/game-patches.md index d3774d1aa2..a2764ce591 100644 --- a/docs/agents/game-patches.md +++ b/docs/agents/game-patches.md @@ -54,6 +54,7 @@ Every patch bound tighter than it could be: the open gaps, then the blocked, kep | `build` → `game` | 60 fps frame step and render scale | frame rate | blocked: frame-step stubs have no function-matched form yet | judgement | 1 | [The Legend of Zelda: Tears of the Kingdom](../../dm/games/0100F2C0115B6000/README.md) | the game's own timing object | | `build` → `game` | 60 fps frame step and render scale (ksys::VFR) | frame rate | blocked: frame-step stubs have no function-matched form yet | judgement | 1 | [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md) | the game's own frame code; the frame-step state page is general | | `build` → `game` | 60 fps frame step: the KIDS frame task and frame-rate block | frame rate | blocked: frame-step stubs have no function-matched form yet | judgement | 1 | [Hyrule Warriors: Age of Calamity](../../dm/games/01002B00111A2000/README.md) | the game's own frame code; only the host's frame-step state page is shared. Fire Emblem: Three Houses and Taiko Risshiden V DX run on KTGL too, with other frame code | +| `build` → `game` | experimental output timing | frame rate | blocked: addresses belong to this build; simulation and audio correctness above 60 fps remain unverified | judgement | 1 | [Star Fox](../../dm/games/0400E88025B1A000/README.md) | the native desired-frame-time and current-clock paths in the output-stage loop | | `build` → `game` | frame step floor 2 → 1 vsync, and its frame step | frame rate | blocked: frame-step stubs have no function-matched form yet | judgement | 1 | [Paper Mario: The Thousand-Year Door](../../dm/games/0100ECD018EBE000/README.md) | the game's own clock floor and step | | `build` → `game` | scene resolution scale | rendering | blocked: frame-step stubs have no function-matched form yet | judgement | 1 | [Star Fox](../../dm/games/0400E88025B1A000/README.md) | the game's own settings read in its renderer | | `build` → `game` | screen shape: the sizes, the view, the layouts' projection and their edge widgets (DM_SCREEN_ASPECT) | rendering | blocked: the stubs have no function-matched form yet | judgement | 1 | [Animal Crossing: New Horizons](../../dm/games/01006F8002326000/README.md) | the output size is the host's, but where the game takes its sizes, its cameras and its layouts are this build's code | @@ -82,7 +83,7 @@ Every patch bound tighter than it could be: the open gaps, then the blocked, kep | [Paper Mario: The Thousand-Year Door](#paper-mario-the-thousand-year-door) | 1 | 0 | 1 | 0 | 0 | `build` → `game` | | [Pokémon Brilliant Diamond](#pokémon-brilliant-diamond) | 0 | 0 | 0 | 0 | 1 | `build` → `engine` | | [Pokémon Legends: Arceus](#pokémon-legends-arceus) | 0 | 0 | 0 | 1 | 0 | `game` | -| [Star Fox](#star-fox) | 0 | 0 | 1 | 0 | 0 | `build` → `game` | +| [Star Fox](#star-fox) | 0 | 0 | 5 | 0 | 1 | `build` → `game` ×2 | | [Subnautica](#subnautica) | 1 | 0 | 0 | 0 | 0 | `build` → `game` | | [Subnautica: Below Zero](#subnautica-below-zero) | 1 | 0 | 0 | 0 | 0 | `build` → `game` | | [Super Mario 3D All-Stars: Super Mario Sunshine](#super-mario-3d-all-stars-super-mario-sunshine) | 2 | 0 | 0 | 0 | 0 | `build` → `game` | @@ -463,10 +464,16 @@ Every patch bound tighter than it could be: the open gaps, then the blocked, kep [Star Fox](../../dm/games/0400E88025B1A000/README.md), `0400E88025B1A000`, [patches.py](../../dm/games/0400E88025B1A000/patches.py). - **`build` → `game`**, rendering, blocked: frame-step stubs have no function-matched form yet, judgement: scene resolution scale. The game's own settings read in its renderer +- **`build` → `game`**, frame rate, blocked: addresses belong to this build; simulation and audio correctness above 60 fps remain unverified, judgement: experimental output timing. The native desired-frame-time and current-clock paths in the output-stage loop -- **v131072 `main`** `a7730d0f…` ([patches.py:25](../../dm/games/0400E88025B1A000/patches.py#L25)) +- **v131072 `main`** `a7730d0f…` ([patches.py:69](../../dm/games/0400E88025B1A000/patches.py#L69)) + - frame step `+0xb444adc`: experimental native output frame time (DM_STARFOX_RENDER_HZ) + - frame step `+0xb444c88`: uncapped output uses the original current-clock path; no simulation sleep removed - frame step `+0xb4498ac`: scene resolution scale from the host (DM_RESOLUTION_SCALE; 0 = the game's own) + - frame step `+0xba66464`: experiment display refresh fallback; SDK service semantics remain unimplemented + - frame step `+0xba69750`: experiment display refresh fallback; SDK service semantics remain unimplemented - launcher capabilities: RESOLUTION_SCALE + - host [loader.c:323](../../dm/host/loader.c#L323): build check ### Subnautica diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index 788186627a..efe0189c1d 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -243,6 +243,7 @@ DM_PRESENT_LATENCY switch `=1` or `2`: overrides the display link's preferred fr DM_PRESENT_LINK switch Present pacing: `0` acquires the drawable itself, `1` (the default) presents on a 60 Hz display link, `2` uses a 120 Hz display link with a frame every other tick DM_PRESENT_SNAP player `=`: present snapshots, a ring of n host textures (2 to 4; `1` = 2) the presents are copied to so the game's swapchain texture is free at once; `0` off (default: the profile's `present_snap`) DM_PRESENT_UPSCALE player `metalfx` scales a picture smaller than its rectangle on the screen through MetalFX's spatial scaler; `off` (the default) scales it bilinearly +DM_PRESENT_VSYNC diagnostic Mac classic presentation: `0` disables CAMetalLayer display synchronization for uncapped profiling; default `1`. Combine with `DM_PRESENT_HZ=0` and `DM_PRESENT_LINK=0`; this does not remove a game's own frame limiter DM_PREV_PROGRAM switch What `oe::GetPreviousProgramIndex` answers (default -1, started from HOME); `oe::ExecuteProgram` sets it for the program it starts DM_PROBE diagnostic `=module:offset,…` (Mac only): puts a `brk` at each guest address and on every hit prints the thread, registers, strings they point to and a backtrace, then runs the original instruction DM_PROBE_ALL diagnostic Set: probe hits (`DM_PROBE`) also print x8–x18 @@ -284,6 +285,7 @@ DM_REC diagnostic `=`: writes player 1's physical input (keyboard, mouse t DM_REGIONS diagnostic `=` writes the table of the SDK's address regions every 2 s (alias offset, size, where the request returns to in the game, bytes mapped, the part opened, the SDK offset the request came through; `hle_svc.c` "Address regions"); the NSP script's address region check sets it DM_RENDER_SCALE player `0.5` to `1.0`: fixes the game's 3D views at that fraction of their size and turns its dynamic resolution off, for builds with a render scale stub; `1.0` (the default) keeps it DM_RESOLUTION_SCALE player `0.25` to `1.0`: the share of the target Star Fox renders its scene at, in place of its settings' scale (0.5 docked, 0.83 handheld); `0` or unset keeps the game's own; only on the builds whose patches.py names the RESOLUTION_SCALE capability. The launcher's Resolution +DM_STARFOX_RENDER_HZ diagnostic Star Fox main a7730d0f: `0` uses the native zero desired-frame-time and current-clock paths, skipping output pacing; `60` or `120` requests 1/60 or 1/120 second. Unset keeps original game behavior. Experimental; campaign correctness is not established DM_RESOURCE_AOC path The add-on content folder the iOS build copies into the app's `aoc/` (empty for none); written by `gen_resources.py`, read by `stage_resources.sh` DM_RESOURCE_BUILD switch Package version (for example `v327680`), written to `Gen/resources.env` by `gen_resources.py`; with `DM_COPY_ROMFS=NO` the copy phase puts it in the `build=` field of the app's `romfs.id` DM_RESOURCE_CHAIN_H path Path of `dm/gfx/nvn/dmsh_chain.h`, written to `Gen/resources.env` by `build_ipa.sh`; the Xcode copy phase (`stage_resources.sh`) stops when the prepared shaders are not of its chain version @@ -370,3 +372,4 @@ DM_XCODE_DERIVED_DATA path Xcode derived data folder for `build_ipa.sh` (default DM_XCODE_PROJECT_DIR path Folder of the per-game Xcode project that `build_ipa.sh --prepare-xcode` makes (default `$W/xcode`); the NSP script sets it DM_XV_FILE path File where the attachment validity ledger keeps the keys it disabled after a violation, read at the next launch DM_YIELD_BACKOFF switch `0` turns off the yield back-off; `=[:]` sets its sleep cap and the run of yields after which it starts (default 250:2000) +DM_VI_BUFFER_MAX diagnostic Override the VI buffer maximum and largest layer for supersampling: 0=720p, 1=1080p, 2=1440p, 3=4K; unset or invalid uses the display. Internal render resolution must be checked separately diff --git a/scripts/configure_starfox_test.py b/scripts/configure_starfox_test.py new file mode 100644 index 0000000000..53fe9931f2 --- /dev/null +++ b/scripts/configure_starfox_test.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Update an existing Star Fox 120 Hz Test.app with a shared-data mode selector. + +Build the host and rewrite main with this checkout first. Only the executables +and rewritten main are replaced; RomFS, shaders, saves and the icon stay in place. +Quit the test app before running this command. Backups contain changed files only. +""" +import argparse +from pathlib import Path +import plistlib +import re +import shutil +import subprocess +import tempfile + +REPO = Path(__file__).resolve().parent.parent + +def run(*cmd): + subprocess.run([str(x) for x in cmd], check=True) + +def copy_asset(src, dst): + # APFS shares the unchanged module pages; replace atomically without copying game data again. + dst.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=dst.parent) as temp: + staged = Path(temp)/dst.name + if subprocess.run(['cp', '-c', str(src), str(staged)], capture_output=True).returncode: + shutil.copy2(src, staged) + staged.replace(dst) + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('app', type=Path) + ap.add_argument('--patched', required=True, type=Path) + ap.add_argument('--backup', required=True, type=Path) + ap.add_argument('--host', type=Path, default=REPO/'dm/host/dmhost') + a = ap.parse_args() + app, backup = a.app.resolve(), a.backup.resolve() + info = plistlib.loads((app/'Contents/Info.plist').read_bytes()) + if info.get('CFBundleIdentifier') != 'org.nxnative.starfox.120hz-test': + ap.error('this installer only updates Star Fox 120 Hz Test.app') + if backup.exists() or backup.is_relative_to(app): + ap.error('backup must be a new folder outside the app') + if subprocess.run(['pgrep', '-f', re.escape(str(app/'Contents/MacOS'))], capture_output=True).returncode == 0: + ap.error('quit the test app before updating it') + fixups = (a.patched/'main.fixups.tsv').read_text() + if '# BUILD_ID\ta7730d0f74d168d90422354c8317f76f\n' not in fixups: + ap.error('unsupported Star Fox build') + if 'DM_STARFOX_RENDER_HZ' not in (a.patched/'main.cuts.tsv').read_text(): + ap.error('rewrite main with this checkout first; output-timing stubs are missing') + if not a.host.is_file() or a.patched.resolve().is_relative_to(app): + ap.error('use a built host and rewritten modules outside the app') + mac, dm = app/'Contents/MacOS', app/'Contents/Resources/dm' + files = [p for p in a.patched.iterdir() if p.name.startswith('main.') and p.suffix in ('.bin', '.tsv') and p.name != 'main.sites.tsv'] + with tempfile.TemporaryDirectory() as temp: + selector = Path(temp)/'nxnative' + run('clang', '-O2', '-fobjc-arc', '-Wall', '-Wextra', '-mmacosx-version-min=14.0', + REPO/'dm/games/0400E88025B1A000/mac_test_launcher.m', '-framework', 'Cocoa', '-o', selector) + core_existed = (mac/'nxnative-core').exists() + targets = [mac/'nxnative', mac/'dmhost', app/'Contents/Info.plist', app/'Contents/_CodeSignature/CodeResources'] + [dm/p.name for p in files] + if core_existed: targets.append(mac/'nxnative-core') + for src in targets: + dst = backup/src.relative_to(app); dst.parent.mkdir(parents=True, exist_ok=True) + copy_asset(src, dst) + try: + if not core_existed: copy_asset(mac/'nxnative', mac/'nxnative-core') + copy_asset(selector, mac/'nxnative'); copy_asset(a.host, mac/'dmhost') + for src in files: copy_asset(src, dm/src.name) + head = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=REPO, text=True).strip() + dirty = subprocess.check_output(['git', 'status', '--porcelain'], cwd=REPO, text=True).strip() + info['DMCommit'] = head + ('+dirty' if dirty else '') + (app/'Contents/Info.plist').write_bytes(plistlib.dumps(info, sort_keys=False)) + for target in (mac/'nxnative-core', mac/'dmhost', app): run('codesign', '--force', '--sign', '-', target) + run('codesign', '--verify', '--deep', '--strict', '--verbose=2', app) + except BaseException: + for dst in targets: copy_asset(backup/dst.relative_to(app), dst) + if not core_existed: (mac/'nxnative-core').unlink(missing_ok=True) + raise + print(f'Updated {app}; modes share one RomFS, shader set and save folder.') + +if __name__ == '__main__': + main()