Skip to content

feat(vk): frame generation Auto: a frame for each of the display's refreshes - #1121

Merged
hckrman101 merged 4 commits into
NotRexed:mainfrom
t0masz407:feat/framegen-auto-pr
Nov 9, 2026
Merged

hckrman101 merged 4 commits into
NotRexed:mainfrom
t0masz407:feat/framegen-auto-pr

Conversation

@t0masz407

@t0masz407 t0masz407 commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

What

Frame generation gets Auto in the Insert menu (its value 1, the step the row skipped so far). Auto makes a frame for each of the display's refreshes and no more:

game display shown made
45 fps 60 Hz 60 / s 1 per 3 game frames, effectively 1.33x
45 fps 120 Hz 120 / s 2 or 3 per game frame, 2.67x
60 fps 60 Hz 60 / s none
faster than the display the display's rate none; some game frames are given no refresh

The fixed 2x to 8x are unchanged.

How

  • Each game frame is given the display's refreshes that fall between it and the game frame before. They sit on the game's own timeline, a refresh period apart (auto_slots).
  • Each of those refreshes gets a frame made at its place t between the two game frames. fg_synth already takes any t. A refresh at t = 1 gets the game's own frame.
  • The display thread shows each frame at its refresh's time, one game frame later (OutSet::due), instead of spreading the frames evenly over the game frame.
  • The sets' frames are allocated once for the most a game frame may be given: the refresh over 30 fps, 2 to 4. Changes in the game's rate cost no reallocation.
  • The overlay's "game N" counts each game frame once, because Auto may show none of the game's own frames.

The refresh rate comes from the display the window is on, and an unknown refresh counts as 60 Hz:

  • Linux: SDL's SDL_GetDisplayForWindow + SDL_GetCurrentDisplayMode (SDL2: SDL_GetWindowDisplayIndex + SDL_GetCurrentDisplayMode). Both are looked up optionally, are asked on the main thread once a second, and are handed to the renderer through a new refresh_mhz in the window bridge (MKWVK_WINDOW_VERSION 2; a version 1 bridge still starts).
  • Windows: EnumDisplaySettingsW for the window's monitor, cached for a second.

Why

With fixed multipliers, a game running between the display's rate and half of it either makes too many frames or too few:

  • At 45 fps on a 60 Hz TV, 2x makes 90 frames a second for a display that shows 60, so a third of them are dropped and the motion is uneven.
  • Off shows 45.
  • On a 120 Hz monitor the same game wants 2.67x.

Auto follows the display, also when the same install is played on a TV one day and a monitor the next.

Tested

RX 7900 XTX, RADV (Mesa 26.2.6), Linux (Hyprland, native Wayland), v2.0.7 plus this change, Kokiri Forest:

  • Auto shows the display's rate with the game at ~41-45 fps. In play the motion looked even, and no more artefacts than 2x were noticed.
  • Windows: the EnumDisplaySettingsW path is written but not built or run; I have no Windows machine. The Windows builder CI will compile it. Its rate is whole Hz, so one under a multiple of 6 (59, 119, 143, ...) is taken as that rate's /1.001 (59.94 Hz etc.), also untested.
  • tests/test_framegen_auto.py runs the real auto_cap, multiplier and auto_slots over refresh rates and game rates. It covers 45 fps on 60 Hz (60 shown, three of four made at 1/4 to 3/4), 60 on 60, 75 on 60, 45 on 120, 30 on 240 (capped) and a restarted timeline. It also checks that display_refresh_mhz is declared outside the unnamed namespace: an earlier draft declared it inside, and the shared library linked with the symbol undefined.
  • tests/test_framegen_gate.py now counts Auto as asking for interpolation.

Not covered

  • The display's vblank phase is not known. Frames are shown a refresh period apart on the game's timeline, with mailbox presents, not locked to vblank. That looked even here, but with FIFO or a VRR display it may beat slightly.
  • Making Auto the default (in the presets) is left to you. This PR only adds the option.

🤖 Generated with Claude Code

t0masz407 and others added 2 commits November 9, 2026 13:57
…freshes

The Insert menu's Frame generation gets Auto (its value 1, the step it skipped so far). Instead of
a whole multiplier, each game frame is given the display's refreshes that fall in its interval
(on the game's timeline, a refresh period apart), and each of them a frame made at its place
between the two game frames: 45 fps on a 60 Hz display makes one frame for every three of the
game's (60 shown a second, at 3/4, 1/2 and 1/4), on 120 Hz 120; at 60 fps on 60 Hz none are made.
The display thread shows each at its refresh's time, one game frame later. The sets' frames are
made once for the most a game frame may be given (the refresh over 30 fps, 2 to 4). The fixed
multipliers are unchanged.

The refresh is the window's display's: on Linux SDL's (SDL_GetDisplayForWindow and
SDL_GetCurrentDisplayMode; SDL2's SDL_GetWindowDisplayIndex), asked on the main thread once a
second and handed over in the window bridge's new refresh_mhz (MKWVK_WINDOW_VERSION 2; a version
1 bridge still starts); on Windows the window's monitor's (EnumDisplaySettingsW). An unknown
refresh counts as 60 Hz.

tests/test_framegen_auto.py runs the real functions over refresh rates and frame rates;
tests/test_framegen_gate.py now counts Auto as asking for interpolation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013mj4MdWTJo5ht4ShZX8Vpp
Conflicts: the display loop keeps wait_until_near, the bridge check keeps
prep_window = w (and takes versions 1 and 2), set_row keeps
step_output_choice / stops_at_ends (Auto's 1 is no longer skipped).

Review (hckrman101):
- A refresh within a quarter period of the game's frame shows that frame
  itself, and the timeline is pulled an eighth of the way to it. At 60 fps
  on 60 Hz with jitter (and 59.94/60.06 Hz drift) no frame is made and
  none dropped; other rates keep the display's count. The test now runs
  with jitter.
- The overlay's game rate counts each Auto game frame once, given a
  refresh or not (menu_note_frame(true, 0)).
- Windows: a whole-Hz rate one under a multiple of 6 (59, 119, 143...)
  is taken as its /1.001 rate.
- Frame pacing's help says Auto paces itself; framegen_wanted's comment
  names Auto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013mj4MdWTJo5ht4ShZX8Vpp
@t0masz407

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review, all points taken:

  1. Snapping: a refresh within a quarter period of the game's frame (before or just after it) now shows the game's own frame, and the timeline is pulled an eighth of the way to it, so a game at the display's rate stays locked while the clocks drift. I ran auto_slots for a minute of frames with ±0.2 / ±1 ms jitter: at 60 fps on 59.94, 60 and 60.06 Hz, 0 % synthesized and no game frame without a refresh. 45 and 30 fps on 60 Hz, and 45/60/75 fps on 120 and 144 Hz, still give the display its refresh count. The test now includes the jittered cases. Flow is skipped when every slot is the game's frame (that was already the case).
  2. Overlay: Auto counts each game frame once where the job is taken, independent of how many refreshes it got (menu_note_frame(true, 0)).
  3. Conflicts: merged main. The display loop keeps wait_until_near, the bridge check sits next to prep_window = w, and set_row keeps step_output_choice / stops_at_ends.

Smaller:

  • Windows: a whole-Hz rate one under a multiple of 6 (59, 119, 143, …) is taken as its /1.001 rate. I kept it at that rather than pulling in DWM. Still untested on Windows.
  • Frame pacing's help text says Auto paces by the refresh itself.
  • framegen_wanted's comment now names Auto.

Played on Linux (RX 7900 XTX, RADV, 120 Hz) on current main plus this PR: the game runs at 45 to 60 fps, and Auto holds a steady 120.

t0masz407 and others added 2 commits November 9, 2026 20:46
… Auto

The Frame generation row takes main's hint scheme: Auto's hint says
what it makes and that Frame pacing does not apply there (main skipped
the value 1). menu_note_frame keeps the shown count beside
menu_note_present_sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013mj4MdWTJo5ht4ShZX8Vpp
…windows.h defines it empty)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013mj4MdWTJo5ht4ShZX8Vpp
@t0masz407

Copy link
Copy Markdown
Contributor Author

Renamed it to snap (d156a6d), thanks for catching that. The description already says the Windows path is untested (only compiled by CI). It now also covers the 59 -> 59.94 rule.

@hckrman101
hckrman101 merged commit ea355a9 into NotRexed:main Nov 9, 2026
10 checks passed
notquiteog added a commit to notquiteog/Hookshot that referenced this pull request Nov 9, 2026
41 upstream commits, among them Frame generation's Auto (NotRexed#1121: a made frame
for each of the display's refreshes), our AppImage parts fix (NotRexed#1312), the
audio output row and mesh fallback fixes.

Frame generation: upstream's Auto (framegen = 1) keeps its own refresh-paced
built-in path; the fork's Frame generation method row (DLSS, FSR 4, FSR 3.1,
built-in) applies at 2x to 8x, and its help says so. hos_sdl.h keeps both
refresh queries (nn_vi.c's Hz, display_sdl.c's mHz). The audio output test's
fixture gains the fork's nr_backend, which ini_key names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant