Aim dynamic resolution at the frame limit's frame time by default and hold each step for a time, not a frame count.

Dynamic resolution's target was 16.7 ms unless set, whatever the frame limit, so under a 120 or 144 fps limit the GPU
could take up to 18 ms a frame at full size before a step down, and the game drew at about 55 fps instead of the
limit. Each step was held for 45 presented frames, 0.75 s at 60 fps but 0.19 s at 240, so the size changed four times
as often there. The target now defaults to 1000 / the rate the game draws at under the limit (frame_rate.c
frame_step_game_hz: the monitor's rate for display, the drawn frames with frame generation), read every frame so a
limit changed in the menu moves it, and 16.7 ms while that rate is unknown or unlimited; dyn_res_target set to 1 or
more still wins (its row's default is now 0). dyn_res_step keeps its key, range and default, now counted in 60ths of
a second of the clock, so 45 is 0.75 s at any frame rate and the same as before at 60 fps. The switch stays off by
default.

Not reproduced before the change (read from the code). The new tests/dynres_check.py builds dynres.c with stand-ins
and passes (24 failures on the old code); cfg_rows_check reports 0 problems; the runtime compile check and the -Wall
comparison of dynres.c show no new warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-13 19:04:35 -05:00
parent 5c9e480f90
commit 1c26907abe
5 changed files with 224 additions and 22 deletions

View File

@@ -792,6 +792,13 @@ A change to which entry of the upscaler stand-in's scratch images serves a new s
and checks that a match is a hit, a ninth size takes a free entry or the one used longest ago instead of being
refused, and two images of one frame never evict each other. No GPU, no game files.
A change to dynamic resolution's target or step hold (`runtime/dynres.c`) passes `python tests\dynres_check.py`, which
builds that file with stand-ins for the settings, the GPU's busy time, the clock and the game's rate under the limit:
the default target is the frame time of 60, 144 and 240 fps and 16.7 ms for an unknown or unlimited rate, a target
that is set wins, a limit changed while it runs moves the target with one log line, and each step is held for
`dyn_res_step` 60ths of a second at 60, 144 and 240 fps, down while the GPU is over the target and back up once it is
under three quarters of it. No GPU, no game files.
A change to the ASTC decoder's scratch trim (`runtime/vk_astc.c` `vk_astc_scratch_trim`, with the upload lock it only
tries, `runtime/vk_memory.c` `vk_defer_buffer_try`) passes `python tests\astc_trim_check.py`, which builds `vk_astc.c`
with the platform layer's recursive lock and the rest of the runtime stubbed: the trim gives way while another thread

View File

@@ -177,10 +177,11 @@
# present, as before).
# gate_wait_ms = 10
# Dynamic resolution, 0 or 1, applied at once: with a DLSS upscaling mode, the scene resolution follows the GPU time
# per frame toward dyn_res_target milliseconds (1 to 100), one DLSS step at a time, each held for dyn_res_step frames
# (8 to 600), from the DLSS mode set down to performance. Default: off.
# per frame toward dyn_res_target milliseconds (1 to 100; 0 is the time of a frame the game draws under fps_limit,
# 1000 / the limit, and 16.7 with fps_limit = unlimited), one DLSS step at a time, each held for dyn_res_step 60ths of
# a second (8 to 600; 45 is 0.75 s at any frame rate), from the DLSS mode set down to performance. Default: off.
# dyn_res = 0
# dyn_res_target = 16.7
# dyn_res_target = 0
# dyn_res_step = 45
# Processors. On a processor whose dies have L3 caches of different sizes (AMD's two-die X3D processors, with 3D
# V-Cache on one die) the game runs on the die with the larger cache when that die has 8 cores or more: 1 = on,

View File

@@ -331,8 +331,9 @@ static const CfgMeta metas[] = {
{"TAIL_EARLY", "0", "0|1", RESTART},
{"REPLAY_SMALL", "0", "0..63", RESTART}, // vk_exec.c group_start
{"FREE_CORES", "0", "0|1", RESTART}, {"TIMER_1MS", "1", "0|1", RESTART},
// dynamic resolution (dynres.c): the scene follows the GPU time toward DYN_RES_TARGET milliseconds
{"DYN_RES", "0", "0|1", LIVE}, {"DYN_RES_TARGET", "16.7", "1..100", LIVE}, {"DYN_RES_STEP", "45", "8..600", LIVE},
// dynamic resolution (dynres.c): the scene follows the GPU time toward DYN_RES_TARGET milliseconds (under 1: the
// frame limit's frame time), holding each step DYN_RES_STEP 60 Hz frames of the clock
{"DYN_RES", "0", "0|1", LIVE}, {"DYN_RES_TARGET", "0", "0..100", LIVE}, {"DYN_RES_STEP", "45", "8..600", LIVE},
// the recording thread: a core of its own (auto: with 12 or more logical processors) and the highest priority
// (vk_exec.c rec_claim)
{"RECORDER_CORE", "0", "auto|0|1", RESTART}, {"RECORDER_PRIO", "1", "0|1", RESTART}, {"REPLAY_TIME", "0", "0|1", RESTART}, {"LOCK_TIME", "0", "0|1", RESTART}, {"POOL_CREATE_BUDGET", "16", "1..4096", RESTART}, {"POOL_CREATE_MS", "6", "0..100", RESTART}, {"POOL_FINI_IDLE", "0", "0|1", RESTART}, {"FREEZE_BUDGET_US", "1000", "0..100000", RESTART}, {"POOL_LOAD_BACKLOG", "512", "0..65536", RESTART},

View File

@@ -7,11 +7,16 @@
// NX2NAVI_DLSS is one of those upscaling modes, which is then the sharpest step it uses; the lowest is performance,
// or ultra performance when that is the player's own mode. While it runs it asks for GPU timestamps
// (perf_gpu_timing_want; vk_sync.c times every submission part) and reads their running total (vk_gpu_busy_all) once
// per presented frame. It averages the busy time and moves one step at a time: down when the average is over
// NX2NAVI_DYN_RES_TARGET milliseconds (default 16.7, a 60 Hz frame) by 8 %, up when it is under 75 % of it, and holds
// each step for NX2NAVI_DYN_RES_STEP frames (default 45), because a size that changes every few frames is worse to look
// at than a steady one. The step in use goes to vk_dlaa.c dlss_mode as an override (dynres_override), which applies it
// as it applies the menu's choice; the player's setting itself never changes.
// per presented frame. It averages the busy time and moves one step at a time: down when the average is over the
// target by 8 %, up when it is under 75 % of it, and holds each step for NX2NAVI_DYN_RES_STEP 60 Hz frames of the clock
// (default 45, 750 ms), because a size that changes every few frames is worse to look at than a steady one. The target
// is NX2NAVI_DYN_RES_TARGET milliseconds when that is set (1 to 100), else the frame time of the limit the game draws
// at (frame_rate.c frame_step_game_hz: 1000 / the limit, the monitor's rate for display, the drawn frames with frame
// generation), and 16.7 ms, a 60 Hz frame, while that rate is unknown or unlimited. A fixed 16.7 ms target let the GPU
// take up to 18 ms a frame at full size under a 120 or 144 fps limit, and a hold of 45 presented frames lasted 0.19 s
// at 240 fps. The step in use goes to vk_dlaa.c dlss_mode as an override (dynres_override), which applies it as it
// applies the menu's choice; the player's setting itself never changes.
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
@@ -20,18 +25,21 @@
#include "cfg.h"
#include "host.h"
#include "vk.h"
#include "platform/platform.h"
#define DYN_LEVELS 4
#define DYN_TARGET_MS 16.7 // the target while the game's rate is unknown or unlimited: a 60 Hz frame
static const char *const DYN_MODE[DYN_LEVELS] = {"quality", "balanced", "performance", "ultra_performance"};
static struct {
int on; // dynamic resolution is acting now
int base, low; // the player's step (the sharpest used) and the lowest one used
int level; // the step in use
int settle; // frames the step has been held
int step; // frames to hold a step
uint64_t held_from; // plat_now_ns when the step in use was taken
uint64_t hold_ns; // how long to hold a step
double avg_ns; // moving average of the GPU's busy time per frame
double target_ns;
double target_ms; // NX2NAVI_DYN_RES_TARGET; under 1: the frame time of the game's rate
double target_ns; // the target in use
uint64_t last_busy; // vk_gpu_busy_all at the previous presented frame
unsigned cfg_seen; // cfg_version the settings were read at
char base_mode[32];
@@ -43,22 +51,31 @@ static int mode_index(const char *m) {
return -1;
}
// The target in ns: the setting's, else the frame time of the rate the game draws at under the limit.
static double target_now_ns(void) {
if (D.target_ms >= 1) return D.target_ms * 1e6;
double hz = frame_step_game_hz();
return (hz > 0 ? 1000.0 / hz : DYN_TARGET_MS) * 1e6;
}
// The settings, again whenever one changed (the menu switches them live).
static void dynres_read(void) {
D.cfg_seen = cfg_version();
const char *m = cfg_value("NX2NAVI_DLSS");
int want = cfg_bool("NX2NAVI_DYN_RES"), base = mode_index(m);
double ms = cfg_double("NX2NAVI_DYN_RES_TARGET");
D.target_ns = (ms >= 1 ? ms : 16.7) * 1e6;
D.step = cfg_int("NX2NAVI_DYN_RES_STEP");
if (D.step < 8) D.step = 8;
D.target_ms = cfg_double("NX2NAVI_DYN_RES_TARGET");
D.target_ns = target_now_ns();
int step = cfg_int("NX2NAVI_DYN_RES_STEP");
if (step < 8) step = 8;
D.hold_ns = (uint64_t)step * 1000000000ull / 60;
int on = want && base >= 0;
if (on && (!D.on || strcmp(m, D.base_mode))) { // (re)start at the player's step
D.base = D.level = base;
D.low = base > 2 ? base : 2;
D.settle = 0, D.avg_ns = 0, D.last_busy = vk_gpu_busy_all();
rt_log("resolution: dynamic resolution on: target %.1f ms, DLSS %s down to %s", D.target_ns / 1e6, DYN_MODE[D.base],
DYN_MODE[D.low]);
D.held_from = plat_now_ns(), D.avg_ns = 0, D.last_busy = vk_gpu_busy_all();
rt_log("resolution: dynamic resolution on: target %.1f ms (%s), each step held %.2f s, DLSS %s down to %s",
D.target_ns / 1e6, D.target_ms >= 1 ? "as set" : "the frame limit's", D.hold_ns / 1e9,
DYN_MODE[D.base], DYN_MODE[D.low]);
} else if (!on && D.on) {
rt_log("resolution: dynamic resolution off: DLSS %s as set", m);
} else if (want && base < 0) {
@@ -77,6 +94,16 @@ const char *dynres_override(void) { return D.on && D.level != D.base ? DYN_MODE[
void dynres_frame(void) {
if (cfg_version() != D.cfg_seen) dynres_read();
if (!D.on) return;
double target = target_now_ns(); // the limit, the monitor's rate and frame generation change without a setting
if (target != D.target_ns) { // logged when it moves by more than 1 %: a measured refresh rate wanders a little
static double logged_ns;
static int said;
if (fabs(target - logged_ns) > logged_ns * 0.01 && said < 20) {
said++, logged_ns = target;
rt_log("resolution: dynamic resolution: target %.2f ms (the frame limit's)", target / 1e6);
}
D.target_ns = target;
}
uint64_t now = vk_gpu_busy_all(), d = now - D.last_busy;
D.last_busy = now;
if (!d || d > 1000000000ull) return; // no timing this frame yet, or a stall that says nothing about the scene
@@ -87,12 +114,12 @@ void dynres_frame(void) {
rt_log("resolution: dynamic resolution: GPU %.2f ms a frame, DLSS %s, target %.2f ms", D.avg_ns / 1e6,
DYN_MODE[D.level], D.target_ns / 1e6);
}
if (++D.settle < D.step) return;
if (plat_now_ns() - D.held_from < D.hold_ns) return;
int to = D.level;
if (D.avg_ns > D.target_ns * 1.08 && D.level < D.low) to = D.level + 1;
else if (D.avg_ns < D.target_ns * 0.75 && D.level > D.base) to = D.level - 1;
if (to == D.level) return;
rt_log("resolution: dynamic resolution: GPU %.2f ms a frame against %.2f ms: DLSS %s -> %s", D.avg_ns / 1e6,
D.target_ns / 1e6, DYN_MODE[D.level], DYN_MODE[to]);
D.level = to, D.settle = 0;
D.level = to, D.held_from = plat_now_ns();
}

166
tests/dynres_check.py Normal file
View File

@@ -0,0 +1,166 @@
"""Dynamic resolution's target and step hold (runtime/dynres.c): builds dynres.c, cut by its text, with stand-ins for
the settings, the GPU's busy time, the clock and the game's rate under the limit (frame_rate.c frame_step_game_hz),
and checks that
- the default target is the frame time of the game's rate (1000 / the limit: 60, 144 and 240 fps), 16.7 ms while
that rate is unknown or unlimited, and a target that is set wins at any rate;
- a limit changed while it runs moves the target on the next frame, with one log line;
- each step is held for dyn_res_step 60ths of a second of the clock at 60 and 240 fps (45: 0.75 s; 90: 1.5 s), down
while the GPU is over the target and back up once it is under three quarters of it.
No game files, no GPU. Usage: python tests/dynres_check.py [--cc <clang>]
"""
import argparse
import os
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MSYS_BIN = Path(r'C:\msys64\ucrt64\bin')
HARNESS = r'''
#include <math.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static unsigned ver = 1;
static const char *dlss = "quality", *dyn = "1";
static double set_target_ms; // NX2NAVI_DYN_RES_TARGET as cfg_double reads it (0: the default)
static int set_step = 45; // NX2NAVI_DYN_RES_STEP
static double game_hz = 60;
static uint64_t busy = 1, now_ns = 1000000000ull;
static int timing, logs;
static char last_log[256];
static unsigned cfg_version(void) { return ver; }
static const char *cfg_value(const char *k) {
return !strcmp(k, "NX2NAVI_DLSS") ? dlss : !strcmp(k, "NX2NAVI_DYN_RES") ? dyn : "0";
}
static int cfg_bool(const char *k) { return strcmp(cfg_value(k), "0") != 0; }
static double cfg_double(const char *k) { return !strcmp(k, "NX2NAVI_DYN_RES_TARGET") ? set_target_ms : 0; }
static int cfg_int(const char *k) { return !strcmp(k, "NX2NAVI_DYN_RES_STEP") ? set_step : 0; }
static double frame_step_game_hz(void) { return game_hz; }
static uint64_t vk_gpu_busy_all(void) { return busy; }
static void perf_gpu_timing_want(int on) { timing = on; }
static uint64_t plat_now_ns(void) { return now_ns; }
static void rt_log(const char *fmt, ...) {
va_list a;
va_start(a, fmt);
vsnprintf(last_log, sizeof last_log, fmt, a);
va_end(a);
logs++;
}
%s
static int fails;
#define CHECK(cond, ...) do { if (!(cond)) { printf("FAIL: " __VA_ARGS__); putchar('\n'); fails++; } } while (0)
#define NEAR(a, b) (fabs((a) - (b)) <= (b) * 1e-6)
// One presented frame at hz with the GPU busy gpu_ms; returns the step in use (0 quality .. 3 ultra performance).
static int frame(double hz, double gpu_ms) {
now_ns += (uint64_t)(1e9 / hz);
busy += (uint64_t)(gpu_ms * 1e6);
dynres_frame();
return D.level;
}
static void restart(void) {
dyn = "0", ver++, frame(60, 1);
dyn = "1", ver++, frame(60, 1);
}
// Frames at hz with the GPU busy gpu_ms until the step changes (at most limit frames): their count, -1 for none.
static int frames_to_change(double hz, double gpu_ms, int limit) {
int from = D.level;
for (int k = 1; k <= limit; k++)
if (frame(hz, gpu_ms) != from) return k;
return -1;
}
int main(void) {
// The default target follows the game's rate.
static const double rates[] = {60, 144, 240, 0};
for (int i = 0; i < 4; i++) {
game_hz = rates[i];
restart();
double want = rates[i] > 0 ? 1e9 / rates[i] : 16.7e6;
CHECK(NEAR(D.target_ns, want), "at %%.0f fps the target is %%.3f ms, want %%.3f", rates[i], D.target_ns / 1e6,
want / 1e6);
CHECK(strstr(last_log, "dynamic resolution on") && strstr(last_log, "the frame limit's"), "start line: %%s",
last_log);
}
CHECK(timing, "GPU timestamps asked for while it runs");
// A target that is set wins.
set_target_ms = 10, game_hz = 240;
restart();
frame(240, 1);
CHECK(NEAR(D.target_ns, 10e6) && strstr(last_log, "as set"), "a set target: %%.3f ms (%%s)", D.target_ns / 1e6,
last_log);
set_target_ms = 0;
// A limit changed while it runs: the next frame's target, one line.
game_hz = 60;
restart();
int before = logs;
game_hz = 144;
frame(144, 1);
CHECK(NEAR(D.target_ns, 1e9 / 144) && logs == before + 1 && strstr(last_log, "6.94"), "live limit: %%.3f ms, %%d "
"lines (%%s)", D.target_ns / 1e6, logs - before, last_log);
before = logs;
for (int k = 0; k < 100; k++) frame(144, 1);
CHECK(logs == before, "a steady limit logged %%d lines", logs - before);
// Each step is held for a time, whatever the frame rate.
static const struct { double hz; int step; double hold_s; } holds[] = {{60, 45, 0.75}, {240, 45, 0.75},
{240, 90, 1.5}, {144, 8, 8 / 60.0}};
for (int i = 0; i < 4; i++) {
double hz = holds[i].hz;
game_hz = hz, set_step = holds[i].step;
restart();
double target = 1000 / hz;
int want = (int)ceil(holds[i].hold_s * hz);
int n = frames_to_change(hz, target * 2, 100000);
CHECK(D.level == 1 && n >= want - 1 && n <= want + 1, "%%.0f fps, step %%d: down after %%d frames, want %%d",
hz, holds[i].step, n, want);
n = frames_to_change(hz, target * 2, 100000);
CHECK(D.level == 2 && n >= want - 1 && n <= want + 1, "%%.0f fps: the next step after %%d frames, want %%d", hz,
n, want);
n = frames_to_change(hz, target * 2, want * 3);
CHECK(n < 0 && D.level == 2, "%%.0f fps: below performance (%%d)", hz, D.level);
n = frames_to_change(hz, target * 0.5, 100000); // the average needs a few frames to fall under 75 %%
CHECK(D.level == 1 && n >= 1 && n <= want + 40, "%%.0f fps: up after %%d frames", hz, n);
n = frames_to_change(hz, target * 0.5, 100000);
CHECK(D.level == 0 && n >= want - 1 && n <= want + 1, "%%.0f fps: back to quality after %%d frames, want %%d",
hz, n, want);
n = frames_to_change(hz, target * 0.9, want * 3);
CHECK(n < 0, "%%.0f fps: a GPU between three quarters of the target and 8 %%%% over it moved the step", hz);
}
set_step = 45;
printf(fails ? "dynres: %%d failures\n" : "dynres: ok\n", fails);
return fails != 0;
}
'''
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--cc', help='the C compiler (default: MSYS2 UCRT64 clang on Windows, clang elsewhere)')
args = ap.parse_args()
win = os.name == 'nt'
cc = args.cc or (str(MSYS_BIN / 'clang.exe') if win and MSYS_BIN.exists() else 'clang')
env = dict(os.environ)
if win and MSYS_BIN.exists():
env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '')
src = (ROOT / 'runtime' / 'dynres.c').read_text(encoding='utf-8')
body = src[src.index('#define DYN_LEVELS'):]
with tempfile.TemporaryDirectory(prefix='nx2navi-dynres-') as tmp:
c = Path(tmp) / 'check.c'
c.write_text(HARNESS % body, encoding='utf-8')
exe = Path(tmp) / ('check.exe' if win else 'check')
subprocess.check_call([cc, '-O2', '-Wall', '-Wextra', '-Wno-unused-function', str(c), '-o', str(exe),
*([] if win else ['-lm'])], env=env)
return subprocess.call([str(exe)])
if __name__ == '__main__':
sys.exit(main())