diff --git a/dm/games/01007EF00011E000/README.md b/dm/games/01007EF00011E000/README.md index db11791..db6ab83 100644 --- a/dm/games/01007EF00011E000/README.md +++ b/dm/games/01007EF00011E000/README.md @@ -29,6 +29,7 @@ No `tex_flush_upload` (a minute's boot saw it flush 32 CPU-filled textures with - Mac (M4 Pro), from the title into the save's scene: 1.000 of real time at 60; with 12 ms added to every frame (`DM_TEST_PRESENT_LAG=1,12`) it drops to 30 and stays at 1.000. `DM_FPS_TARGET=30` holds 30 (saves power, also from `Documents/env.txt`); `0` runs the game's own interval controller. - **Frame-step probes (iPhone).** The GPU clock follows the hold: on an iPhone the game went from 14.4 to about 25 ms of GPU time a frame at 30 fps with the same 4,900 draws, its CPU still on the performance cores, and never tried 60 again; a GPU time at the longer hold of at least 1.3 times its time at 60 that alone does not fit 60 (`nvn_fstep_gpu_slowed`) now allows probes. Stepped up by pipeline compiles after a cutscene, it took 12.8–15.2 ms of GPU time a frame at 30 fps (p50) where 60 had held for minutes, and stayed at 30 for 36 and 46 s until the player switched out and back (the same 4,900 draws took 5.8 ms right after a trip to the background and 13.5 ms half a minute later); a step up after 8 s or more at 60 now keeps that run's cost, and a cost at the longer hold of at most 1.1 times it allows a probe (`nvn_fstep_same`). Log lines: `frame step: hold 2 → 1 … (probe, …)`, `the probe held / failed`, `hold 1 held at 0.86`. Details: [development.md, Power](../../../docs/development.md#power). - **Screens whose rate is not a multiple of 60 (from PR #27).** A cheap 50 ms frame shows 54.5 ms at 165 Hz; counted as late it kept the game at 20 fps for good, so the frame step lets a frame on time show one refresh longer than its hold (`shown_slack`, `nvn_fstep.h`). +- **Resolved: menu repeat twice as fast at 60 fps (2026-10-26).** `sead::ControllerBase::updateDerivativeParams_` (`main+0xeb0ef0`) adds a constant 1.0 (`main+0xeb0f78`..`88`) to each held button's count and repeats when `fmod(count - delay, pulse)` falls below that step, so delays and pulses were counted in frames: holding Down in the System menu for 0.3 s went 5 entries at 60 and 2 at 30 (found by MacNativeGames, `866beb9`). A `FRAME_STEP` stub at `main+0xeb0f88` takes the VFR's ratio (`+0x3c`, interval / 2) instead, keeping 1.0 when there is no VFR or the ratio is not positive. Mac, 60 fps: 0.2 / 0.3 / 0.4 s now move 1 / 2 / 3 entries, as the game does at 30. The pause menu's fast-repeat countdown (`main+0xe67d00`) already reads the VFR's delta. - `DM_NVN_CUT` growing segments (64, 128, 256 … draws) were tried for fewer commits: the game fell from 60 to 30 fps in the same scene (GPU idle while the long last segments were encoded). ### The game's own interval controller diff --git a/dm/games/01007EF00011E000/patches.py b/dm/games/01007EF00011E000/patches.py index f2f3363..e46c889 100644 --- a/dm/games/01007EF00011E000/patches.py +++ b/dm/games/01007EF00011E000/patches.py @@ -66,6 +66,21 @@ def frame_step(ctx): emit(w) emit(B(here(), 0x151d3d4)) fs_put(0x151d3d0, [B(0x151d3d0, start)], 'VFR::updateInterval: its own override published for the host (state page target fps; 0 = original)') + # Held buttons: sead::ControllerBase::updateDerivativeParams_ (main+0xeb0ef0) adds s8 to each held button's count, a constant 1.0 (main+0xeb0f78..88), and raises the repeat bit + # when fmod(count - delay, pulse) < s8: delays and pulses are counted in frames, so at 60 fps menus repeated twice as fast as at 30 (MacNativeGames 866beb9). s8 is now the VFR's + # ratio (+0x3c: interval / base interval 2), 0.5 at a one-vsync step, 1.0 at two as shipped; the constant stays when there is no VFR yet or the ratio is not positive. + # x8 is loaded again after the site, s0 before its first read + fs_site(0xeb0f80, '08f144f9'); fs_site(0xeb0f88, '080140bd') + start = here(); emit(0xbd400108) # ldr s8,[x8] the constant 1.0 + emit(ADRP(8, here(), 0x1e93890)); emit(LDRX(8, 8, 0x890 // 8)) # the VFR + for w in (0xb40000a8, # cbz x8,back (+5) + 0xbd403d00, # ldr s0,[x8,#0x3c] its ratio + 0x1e202008, # fcmp s0,#0.0 + 0x5400004d, # b.le back (+2) + 0x1e204008): # fmov s8,s0 + emit(w) + emit(B(here(), 0xeb0f8c)) # back + fs_put(0xeb0f88, [B(0xeb0f88, start)], 'held-button counts step by the VFR ratio (menu repeat in game time at any frame step)') # Render scale (state +0x10, float written by the loader: DM_RENDER_SCALE, 1.0 by default; independent of the frame step). The dynamic-resolution method (main+0x12666e4) keeps a # scale in 0.8–1.0 rounded to 0.05 steps (+0x1218, main+0x1266818), or the game's forced 0.75 / 0.5 (+0x284 & 0x1800), in s8; from main+0x1266880 it applies s8: each view's # scale (+0x28a0 / +0x28a4 = s8 × base width (+0x1214: 1600 docked, 1280 handheld) / output width), from which the scene buffers are sized each frame (main+0x106340c), the diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index 815c846..9137908 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -1713,8 +1713,8 @@ static MTLTextureDescriptor *tex_desc(const tex_t *t, int packaged) { } // Fields NVN takes that Metal refuses: Metal aborts the process on them (-[MTLTextureDescriptorInternal validateWithDevice:]). An array or 3D texture of no layers gets one; one of // more than 2048 is not made. More levels than the size holds (on NVN the extra ones are 1x1; a mod's 1x1 texture declaring 9, MacNativeGames f864eb0): made with the levels the - // size holds, and copies to the others are skipped (tex_level_ok). A sample count the GPU lacks (Apple GPUs have 2 and 4; NVN also 8 and 16): the largest it has below, which - // the pipelines follow (their sample count is the attachments'); a multisample texture of one sample has no Metal form and is not made + // size holds, and copies to the others are skipped (tex_level_ok). A multisample texture of a sample count the GPU lacks (Apple GPUs have 2 and 4; NVN also 8 and 16), or of + // one sample, has no Metal form and is not made, as a texture past Metal's size limits (fewer samples would leave shaders that read sample 4 or higher reading past the texture) if (d.depth < 1) d.depth = 1; if (d.arrayLength < 1) d.arrayLength = 1; if (d.arrayLength > 2048) { RED_ONCE(113, "texture of %lu layers exceeds Metal's 2048: not made (format %#x target %d, first case ser=%llu)", (unsigned long)d.arrayLength, t->d.format, t->d.target, (unsigned long long)t->serial); return nil; } @@ -1727,12 +1727,10 @@ static MTLTextureDescriptor *tex_desc(const tex_t *t, int packaged) { d.mipmapLevelCount = fit; } if (d.textureType == MTLTextureType2DMultisample || d.textureType == MTLTextureType2DMultisampleArray) { - NSUInteger want = d.sampleCount, s = want; - while (s > 1 && ![g_dev supportsTextureSampleCount:s]) s--; - if (s != want) RED_ONCE(115, "multisample texture of %lu samples: this GPU has %lu at most below it%s (format %#x, first case ser=%llu)", (unsigned long)want, (unsigned long)s, - s < 2 ? ", not made" : ", made with them", t->d.format, (unsigned long long)t->serial); - if (s < 2) return nil; - d.sampleCount = s; + if (d.sampleCount < 2 || ![g_dev supportsTextureSampleCount:d.sampleCount]) { + RED_ONCE(115, "multisample texture of %lu samples: this GPU has no such texture, not made (format %#x, first case ser=%llu)", (unsigned long)d.sampleCount, t->d.format, (unsigned long long)t->serial); + return nil; + } } d.storageMode = MTLStorageModePrivate; // Render targets and assets both go in GPU private storage (lossless compression available) d.usage = MTLTextureUsageShaderRead | (fmt_compressed(t->d.format) || packaged ? 0 : MTLTextureUsageRenderTarget) @@ -4891,7 +4889,8 @@ static NSSet *ga_list(void) { } // A compile the serializer captures, with g_arch_rw held shared: the first one after a flush notes the folder it made static id ga_capture(MTL4RenderPipelineDescriptor *d, MTL4CompilerTaskOptions *o, NSError **e) { - if (o.lookupArchives.count) atomic_store(&g_ga_recap, 1); + // A recapture through lookup archives makes no folder at its compile (the flush makes it, ga_flushed): it takes none, or it could take another program's made meanwhile + if (o.lookupArchives.count) { atomic_store(&g_ga_recap, 1); return [g_comp4 newRenderPipelineStateWithDescriptor:d compilerTaskOptions:o error:e]; } // other compiles do not wait for the one that looks: theirs land in the same folder (at worst one made before its first listing, so none is taken and the folder stays) if (!g_ser4 || atomic_load(&g_ga_seen) || pthread_mutex_trylock(&g_ga_mu)) return [g_comp4 newRenderPipelineStateWithDescriptor:d compilerTaskOptions:o error:e]; if (atomic_load(&g_ga_seen)) { pthread_mutex_unlock(&g_ga_mu); return [g_comp4 newRenderPipelineStateWithDescriptor:d compilerTaskOptions:o error:e]; } @@ -4899,7 +4898,7 @@ static id ga_capture(MTL4RenderPipelineDescriptor *d, MT id ps = [g_comp4 newRenderPipelineStateWithDescriptor:d compilerTaskOptions:o error:e]; if (ps) { // a failed compile may have captured nothing: the next capture looks again NSMutableSet *made = [ga_list() mutableCopy]; [made minusSet:before]; - if (made.count == 1) g_ga_mine = made.anyObject; // none: a recapture through lookup archives, whose folder the flush makes (ga_flushed) + if (made.count == 1) g_ga_mine = made.anyObject; else if (made.count > 1 && atomic_fetch_add(&g_ga_said, 1) < 4) MLOG("pipeline archive: %lu new Metal build folders during the first capture since the last flush: none is removed", (unsigned long)made.count); atomic_store(&g_ga_seen, 1); } diff --git a/dm/gfx/tests/t_view_levels.m b/dm/gfx/tests/t_view_levels.m index b4b0461..115b23d 100644 --- a/dm/gfx/tests/t_view_levels.m +++ b/dm/gfx/tests/t_view_levels.m @@ -51,8 +51,9 @@ static void desc_caps(void) { CHECK(d && d.arrayLength == 1 && [g_dev newTextureWithDescriptor:d], "array of no layers: one"); CHECK(desc_of(NVN_TEXTURE_TARGET_1D_ARRAY, 8, 4096, 1, 1, 1) == nil, "1D array of 4096 layers: not made"); d = desc_of(NVN_TEXTURE_TARGET_2D_MULTISAMPLE, 16, 16, 1, 1, 8); - NSUInteger s8 = 8; while (s8 > 1 && ![g_dev supportsTextureSampleCount:s8]) s8--; - CHECK(d && d.sampleCount == s8 && d.mipmapLevelCount == 1 && [g_dev newTextureWithDescriptor:d], "8 samples: the most this GPU has"); + CHECK([g_dev supportsTextureSampleCount:8] ? d && d.sampleCount == 8 && [g_dev newTextureWithDescriptor:d] : d == nil, "8 samples: made where the GPU has them, else not made"); + d = desc_of(NVN_TEXTURE_TARGET_2D_MULTISAMPLE, 16, 16, 1, 1, 4); + CHECK(d && d.sampleCount == 4 && d.mipmapLevelCount == 1 && [g_dev newTextureWithDescriptor:d], "4 samples: made"); CHECK(desc_of(NVN_TEXTURE_TARGET_2D_MULTISAMPLE, 16, 16, 1, 1, 1) == nil, "multisample of one sample: not made"); } int main(void) { @autoreleasepool { diff --git a/docs/reference/rendering.md b/docs/reference/rendering.md index 3777940..b6185ee 100644 --- a/docs/reference/rendering.md +++ b/docs/reference/rendering.md @@ -149,7 +149,7 @@ The replay on Metal (`dm/gfx/nvn/nvn_mtl.m`) and the Vulkan and OpenGL layers. E - **Symptom:** the process ends at once (the window vanishes), with `-[MTLTextureDescriptorInternal validateWithDevice:]` failing an assertion such as `requests 9 mipmap levels, but the dimensions (1, 1, 1) can only support 1`, or `sampleCount must be > 1 for multisample textures`. - **Cause:** NVN takes texture fields Metal refuses, and Metal aborts instead of returning nil: more mip levels than the size holds (on NVN the extra levels are 1×1), an array or 3D texture of no layers, more than 2048 layers (a 1D array's layer count is its height, which the size check let through), and sample counts the GPU lacks (an M4 Pro has 1, 2 and 4; NVN also has 8 and 16). A blit at a level the Metal texture lacks is not checked either. -- **Fix:** `tex_desc` makes such textures with the levels their size holds, one layer for none, and the largest sample count the GPU has below the one asked for (the pipelines follow, since their sample count is the attachments'). A red line names each case once. Textures of more than 2048 layers, and multisample textures of one sample, are not made. Copies and `ReadTexels` at a level the host texture lacks are skipped (`tex_level_ok`). Retail data is within every bound except, possibly, 8- or 16-sample targets. +- **Fix:** `tex_desc` makes such textures with the levels their size holds and one layer for none. Textures of more than 2048 layers, and multisample textures of one sample or of a sample count the GPU lacks, are not made, as textures past Metal's size limits are (fewer samples would leave shaders reading sample indices past the texture). A red line names each case once. Copies and `ReadTexels` at a level the host texture lacks are skipped (`tex_level_ok`). Retail data is within every bound except, possibly, 8- or 16-sample targets. - **Found on:** reported by MacNativeGames (`f864eb0`): a mod's 1×1 texture in Tears of the Kingdom declaring 9 levels. No game here is known to reach it; the sample counts (an M4 Pro has 1, 2 and 4, and a multisample texture of one sample aborts) were checked on that Mac while porting the fix. - **Check:** `t_view_levels` makes each case with Metal's validation layer on: a 1×1 texture of 9 levels, a 3D texture whose depth holds the levels, an array of no layers, a 1D array of 4,096 layers, an 8-sample and a 1-sample multisample texture, and a copy at a missing level.