Skip to content

Experimental opt-in fractional timing for Ocarina high-FPS play - #1890

Open
bmjask wants to merge 1 commit into
seedds:mainfrom
bmjask:codex/oot-fractional-timing
Open

bmjask wants to merge 1 commit into
seedds:mainfrom
bmjask:codex/oot-fractional-timing

Conversation

@bmjask

@bmjask bmjask commented Nov 9, 2026 •

Copy link
Copy Markdown

Ocarina's frame-ratio division cannot express arbitrary high-rate simulation through the existing whole-vsync contract. This extracts the experimental fractional path from #1663 and adapts it to current main without overwriting the newer dynamic-resolution control.

  • Rewrite opt-in DM_EXPERIMENTAL_FRACTIONAL=1, restricted to NSO build e0f3ccfaf50065ae4ea01eb5aeb13b1a. Normal rewrites retain upstream's whole-vsync timing. Function matching still supplies the existing dynamic-resolution patch.
  • Contract FPS_FRACTIONAL=2 relocates the override to +0x44, preserving DRS at +0x40. Legacy contract 1 is rejected with a rewrite instruction.
  • Runtime DM_FPS_TARGET=120 requests the high-rate path; existing screen, movie and thermal caps remain. Deterministic clocks and disabled elapsed-time correction reject high-rate mode. This does not unlock 85 Hz displays or establish compatibility with other titles.

Evidence and validation

Historical campaign chat, 2026-11-08 02:22 UTC: a short forest run averaged 85.7 FPS and measured simulation speed within 0.06% of the 60 FPS reference. A later combined timing/compute run at 02:49 UTC averaged 93.7 FPS, median GPU 9.84 ms, with three presentation discards and GPU p95 above 12.5 ms. These used the older integration branch; they are not fresh A/B results for this adaptation and do not establish sustained 120 FPS.

Current extraction: 265 NVN checks, 84 frame-step checks, seven ARM64 execution/build-gating tests and 11 documentation tests pass. The execution test verifies preserved registers/NZCV and that +0x40 is untouched; the gating test checks DRS remains called. Loader syntax and modified Python compilation pass.

Outstanding validation: renewed full gameplay, audio/movie/hitch checks, full graphics suite, CI lint/tidy and multi-game regression remain pending. Impact analysis reaches module loading/rewriting and NVN presentation. Selected regression coverage is Ocarina (new contract), Breath of the Wild and Tears of the Kingdom (whole-vsync stubs), Pokémon Scarlet (ordinary NVN presentation), and Hades (non-Ocarina host loading). Only Ocarina assets are available locally. No full shader rebuild or game restart was performed for this extraction.

Front-ends: this is an experimental rewrite opt-in requiring the matching host and rewritten module; do not reuse contract-1 modules. Keep the ordinary frame-rate path as the default. A 120 FPS request is not a guaranteed rate.

Regression attempt

The selected five-game quick/no-smoke check stopped during asset discovery, before builds or game runs:

[RED] regress.py: no Mac build of 01007EF00011E000, 0100F2C0115B6000, 0100A3D008C5C000, 0100535012974000 under /Users/Shared/Games (found: 0400486024DEC000)

This is incomplete validation, not a regression pass.

Reproducible evidence

Tested extraction: 721a69655b6ac31feda2e0fa403e56dfdac6e90f. Test source at that commit; game note at that commit. Run on an Apple-silicon Mac with the repository's build dependencies configured:

python3 -m unittest discover -s dm/tools/tests -p test_oot_fractional_step.py
make -C dm/gfx/tests GAME=0400486024DEC000 check_t_nvn check_t_fstep

Saved local test-output excerpt (not a new test run or a CI result):

== 265 checks, 0 failed ==

Historical A/B figures above describe the older combined branch. They are not fresh performance validation of this extracted commit. The remaining gameplay/regression limitations above still apply.

Rebase validation — 2026-11-09

Current head: c53497d1a3a2751b2c7fe1a80ae2a5ac9e616b57, one commit above upstream 9983d659e6101867215e4afe5dd6287ad8e0d4a3.

Full graphics check list: 125 test targets plus the two Python check targets passed. The fractional timing, matcher, patch registry, and FPS Python tests passed 63 tests; the C frame-step fixture passed 84 checks. Rebase resolution retained upstream function-address matching and the opt-in/build gate. The internal build constant was renamed to satisfy the patch-table registry.

Mac fixtures were tested before the final upstream iOS-linker-only commit; verified that dm/ios/tools/gen_build.py is the sole subsequent file difference. Impact analysis was also run. No new gameplay A/B performance claim: historical figures above still describe the combined branch. Full current-chain game rebuild and gameplay regression remain pending; only Ocarina assets are locally available, and other titles/iOS are untested. The installed app and codex/ocarina-playability were preserved.

@github-actions github-actions Bot added host The Horizon host: SVCs, services, threads, memory (dm/host) nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) rewriter The NSO rewriter and per-build patches tools Tools and scripts (dm/tools, scripts) docs Documentation game: 0400486024DEC000 A game labels Nov 9, 2026
@bmjask
bmjask marked this pull request as ready for review November 9, 2026 01:14
@roandegraaf

Copy link
Copy Markdown
Contributor

#2259 changes the same stub and host path from the other side. When frames run a little longer than a vsync (55–59 fps), the game's step is taken from the measured frame time. It's on by default, uses state page +0x38 (float seconds) and +0x3c (the stub's mark), and leaves main's whole-vsync step unchanged otherwise. This PR covers rates above 60 as an opt-in at +0x44. The offsets don't collide, but the two conflict in nvn_fstep.h, loader.c and patch_nso.py. If both land, one measured value in the stub covering both directions is probably cleaner than two mechanisms. Happy to settle which goes first and rebase the other.

@dean0396

Copy link
Copy Markdown
Contributor

I've had a port of this in my fork since Nov 9 (M3 Max, 120 Hz screen). Porting it and going over it, I found four things that may be worth fixing before it lands. Line numbers are on main 084f5aa5.

1. The present thread declares a one-tick period. fstep_on() is false in fractional mode, so rt_period_ns (hle_nvn.c:2275-2279) returns the present period, 8.33 ms at 120. The computation is capped at 0.9 × period (hle_nvn.c:2069, 2090), so the game's main thread is declared at 7.5 ms at most. The comment at hle_nvn.c:2061-2062 puts that thread at about 10 ms a frame when hot, and says a real-time thread that runs past its declared computation gets demoted. I declare 1e9 / min(cap, 60) instead: 16.7 ms, or 33.3 ms at a cap of 30. That only lifts the cap to 15 ms, the same as at 60, and in my unlocked runs the auto declaration still reached it now and then, so it doesn't settle the declaration itself (#1985).

2. The vsync event stays at 60 Hz. nvn_vsync_hz() answers 120 only while g_fs_k == 1. Only fstep_tick sets g_fs_k, and this PR skips fstep_tick in fractional mode, so vi's vsync event keeps firing at 60 Hz. From the disassembly, the game's frame end waits for one vsync event a frame (main+0x346c920) while its own rate is 60, so I'd expect this head to stay at 60 fps. I haven't run this head itself. My port answers 120 while the cap is 120.

With the event at 120, the game's half-rate mode needs a look too. When bit 0 of the framework's +0x24f is set (read at main+0x6e6cc0), the base in s1 is 30 and calc runs twice per drawn frame (#2071). On the passes with both bits set, the frame end (main+0x40cd20 → main+0x40ced0) sleeps 1/60 s from its last mark instead of waiting for the event, so at 120 that mode no longer paces as it does at 60. I haven't checked how that mode looks at 120 in play. In my port the stub also stores s1 in a spare state word, and the host caps at 60 while that word is 30, which puts the event back at 60 Hz for that mode.

3. DM_DETCLOCK. fps_high caches its answer on its first call, before the first present, but dm_detclock_enabled() only turns on at the first present (hle_os.c:67-74, called from hle_nvn.c:1884). So with DM_DETCLOCK=1 the high rate stays on. Reading the setting itself, as detclock_cfg does, fixes it.

4. The floor makes the game run slightly fast. The step is max(base / cap, mean of the last 8 intervals × base). Where the game holds 120, frames follow the link's ticks, so the true mean is one tick. The present call's jitter moves the 8-interval mean above and below that, and the floor lifts the low windows while keeping the high ones. Here is game time against real time, simulated with this PR's rule (frames locked to 120 Hz ticks, Gaussian work jitter, 36,000 frames per run):

Present jitter (SD) Cap of 120 Cap of 30
0.2 ms 1.0017 1.0004
0.5 ms 1.0042 1.0011
1.25 ms 1.0072 1.0027

The test's mode 1 alternates 7.0 and 9.67 ms. Every window of 8 then has a mean of exactly 8.33 ms, so the floor never binds and the test can't see this. My fix keeps an owe term. What the floor hands out above the mean is paid back by later steps above the floor, never going below the floor. owe stops at 0.1 s, so after a long stretch faster than the cap, at most 0.1 s is paid back, rather than a long slow-motion stretch. With it, the same runs give 1.0000. #2259 meets the same bias at 55-59 fps and gates it with the frame step's debt, so one measured value for both, as roandegraaf suggests, would need one rule for where the floor's extra time goes.

I have the owe change and that jitter test as a plain diff against this PR's head c53497d1, and can post it here if it's useful. With it, t_fstep passes 90 of 90 checks, your 84 included. One of those, the target-transition check, now compares max(1/120, step), the step the stub takes, because the function now returns 0 at the rate. Without the change, the new cases fail 5 of 6.

With these changes, five scripted unlocked runs on my fork (about 30 minutes in all, from five saves) ran at 58-85 fps in the 30 s windows after each area loaded. Game time was 0.996-1.001 of real time in every window except the five with the area load (0.98-0.99 there, where stalls past the 0.1 s bound are dropped), and nothing crashed. My fork has many other changes, so take that as a sanity check, not an A/B of this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 0400486024DEC000 A game host The Horizon host: SVCs, services, threads, memory (dm/host) nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) rewriter The NSO rewriter and per-build patches tools Tools and scripts (dm/tools, scripts)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants