diff --git a/docs/maxwell.md b/docs/maxwell.md index edc25dfc..c3fa2b11 100644 --- a/docs/maxwell.md +++ b/docs/maxwell.md @@ -143,7 +143,12 @@ Grep-level look on 2026-10-22, statuses updated 2026-10-26; "saw" is what the gr per frame in the field, ~21 in a fight); the game's CPU reads them (guest f_70010c3298) to cull objects. A report reaches guest memory as a CPU write after its submission's fence, a ClearBuffer or ReportValue as a fill the GPU makes in stream order, so a report followed by a clear of the same record ended as the report, and a condition - replayed after the clear read memory from before the fill. A clear of at most 256 bytes is now also applied in the + replayed after the clear read memory from before the fill. A draw that finds the query ring full (every slot still + waiting for the completion thread, in a hitch at a high frame rate) is left uncounted and the report after it says + what the rest counted, at least 1, which the game reads as an object all but hidden for that frame; + `NX2NAVI_OQ_HOLD=1` makes such a report repeat the larger of that and its record's last complete count + (`vk_exec_xfer.c` `oq_report_count`, `vk_oq_hold.h`), and the log counts them ("missed draws the full occlusion + query ring left uncounted"). A clear of at most 256 bytes is now also applied in the replay to the counter records it covers (`vk_exec_xfer.c` `clear_small`, `NX2NAVI_CLEAR_SMALL_CPU`): earlier reports leave the cleared bytes alone, conditions read them from memory. Other small clears stay GPU fills alone, since the GPU reads guest memory too and a write ahead of it would reach passes that still want the old value. Whether the diff --git a/nx2navi.ini.example b/nx2navi.ini.example index 6c951b5c..13c9ff57 100644 --- a/nx2navi.ini.example +++ b/nx2navi.ini.example @@ -140,6 +140,13 @@ # order the game issued them, 0 or 1. Default: on. 0 leaves the clear to the video card alone, as before, and a # report issued before the clear can then be written after it. # clear_small_cpu = 1 +# Occlusion counts the replay could not take, 0 or 1. Default: off. When the GPU or the thread that reads the results +# falls behind (a hitch at a high frame rate), some draws find no free occlusion query and go uncounted, and the +# game's report after them says only what the rest counted, at least 1: the object it tested looks all but hidden for +# that frame, and whatever the game scales by that count drops for one frame. 1 makes such a report repeat the last +# full count of the same object (or what was counted, if that is more). The log says how often it happens either way +# ("samples-passed reports so far missed draws", at the 1st, 10th, 100th...). Read at start. +# oq_hold = 0 # EXPERIMENT, 0 or 1. Default: off. The game asks the video card how much of an object would show behind what is # already drawn (an occlusion query) and leaves hidden objects out. With 1 it asks nothing and draws them all: more # draws in a frame, and no queries to issue or results to wait for and read back. Whether that gains or loses frames diff --git a/runtime/cfg.c b/runtime/cfg.c index 48873237..6b0235c0 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -53,7 +53,7 @@ static const char *const known[] = { "HDR_BLOWOUT", "HDR_CONTRAST", "HDR_EXPOSURE", "HDR_FLARE", "HDR_GAMMA", "HDR_GRADE", "HDR_HIGHLIGHT_SAT", "HDR_HIGHLIGHTS", "HDR_HUE", "HDR_HUE_CLIP", "HDR_MATCH_SLOPE", "HDR_MIDGRAY", "HDR_SATURATION", "HDR_SHADOWS", "HDR_TONEMAP", "HDR_TONE_SLOPE", "HIGHFPS_FIX", "INPUT", "INPUT_RECORD", "INPUT_SCRIPT", "LANG", "LIB_PREBUILD", - "BIG_BUILD_FREE_MB", "BIG_BUILD_MAX_MB", "BUILD_FREE_MB", "BUILD_RESERVE_MB", "LIB_PREBUILD_BIG", "LIB_PREBUILD_BIG_BG", "COMPUTE_WARM_REUSE", "LIB_PREBUILD_KEEP", "LIB_PREBUILD_THREADS", "LIB_WARM", "LINE_WIDTH", "POOL_SHOWN_FIRST", "POOL_DATA_FIRST", "TEX_REUSE_NAMED", "TEX_OVERWRITTEN_USED", "PIPE_LINK_MS", "FRAME_HOLD_MS", "PACE_SMOOTH", "OQ_BATCH", "CLEAR_SMALL_CPU", "GATE_WAIT_MS", "LOCALE", "LOD_GUARD", "LSTICK_DEADZONE", "MEMORY", "MEM_CENSUS", "MEM_NEED_MB", "MENU_LANG", "MENU_SOUND_VOLUME", "MENU_TEST", + "BIG_BUILD_FREE_MB", "BIG_BUILD_MAX_MB", "BUILD_FREE_MB", "BUILD_RESERVE_MB", "LIB_PREBUILD_BIG", "LIB_PREBUILD_BIG_BG", "COMPUTE_WARM_REUSE", "LIB_PREBUILD_KEEP", "LIB_PREBUILD_THREADS", "LIB_WARM", "LINE_WIDTH", "POOL_SHOWN_FIRST", "POOL_DATA_FIRST", "TEX_REUSE_NAMED", "TEX_OVERWRITTEN_USED", "PIPE_LINK_MS", "FRAME_HOLD_MS", "PACE_SMOOTH", "OQ_BATCH", "OQ_HOLD", "CLEAR_SMALL_CPU", "GATE_WAIT_MS", "LOCALE", "LOD_GUARD", "LSTICK_DEADZONE", "MEMORY", "MEM_CENSUS", "MEM_NEED_MB", "MENU_LANG", "MENU_SOUND_VOLUME", "MENU_TEST", "MENU_CURSOR", "MENU_CURSOR_LOG", "MENU_TEST_CLICK", "MENU_TEST_DIR", "MENU_TEST_SET", "MINIMAL_LAYOUT", "MICROPHONE", "MIRROR_GAP", "MODS_DIR", "MODS_DISABLED", "MODS_ORDER", "MODS_REBUILD", "MONITOR", "MOTION", "MOUSE_MODE", "MOUSE_DIRECT_PATCH", "MOUSE_SENS", "MOUSE_TRACE", "MOVIE_HOST", "MOVIE_PACE", "MOVIE_CLOCK", "TEST_HITCH", "SPRINT_BUTTON", "SPRINT_MODE", "SPRINT_BUTTON_PATCH", "TEXT_SPEED", "TEXT_SPEED_PATCH", "MOTION_GUARD_DEGREES", "MOTION_POLICY_TRACE", "SELECTIVE_MOTION", "SELECTIVE_MOTION_RAW", // motion_owner.c, vk_fg_pose.inc "SKIN_MOTION", // vk_skin_motion.inc @@ -150,6 +150,7 @@ static const struct { const char *key, *section; } sections[] = { {"PIPE_FRAME_WAIT_MS", "performance"}, {"PIPE_SKIP_STEADY", "performance"}, {"DRAW_WRITERS_WAIT", "performance"}, {"CACHE_DIE", "performance"}, {"ONE_PER_CORE", "performance"}, {"PIPE_SEED_BOOT", "performance"}, {"LIB_PREBUILD", "performance"}, {"LIB_PREBUILD_THREADS", "performance"}, {"LIB_WARM", "performance"}, {"PIPE_LINK_MS", "performance"}, {"PIPE_LINK_DRAW_MS", "performance"}, {"PIPE_AHEAD", "performance"}, {"LINE_WIDTH", "performance"}, {"POOL_SHOWN_FIRST", "performance"}, {"POOL_DATA_FIRST", "performance"}, {"TEX_REUSE_NAMED", "performance"}, {"TEX_OVERWRITTEN_USED", "performance"}, {"PIPE_FAST_WORKERS", "performance"}, {"GPL_FEEDBACK", "performance"}, {"GPL_MESH", "performance"}, {"FRAME_HOLD_MS", "performance"}, {"PACE_SMOOTH", "performance"}, {"OQ_BATCH", "performance"}, {"CLEAR_SMALL_CPU", "performance"}, {"PATCH_OCCLUSION_OFF", "performance"}, {"GATE_WAIT_MS", "performance"}, {"PIPE_PREBUILD", "performance"}, {"PIPE_SEED", "performance"}, + {"OQ_HOLD", "performance"}, {"COMPUTE_KEEP", "performance"}, {"PREWARM_XLATE", "performance"}, {"GPL", "performance"}, {"ASTC", "performance"}, {"MEMORY", "performance"}, {"TEX_ALIAS", "performance"}, {"DEPTH_INIT_INLINE", "performance"}, {"DEPTH_ALIAS_LOG", "diagnostics"}, {"TEX_DISUSE", "performance"}, {"TEX_DISUSE_EVICT", "performance"}, {"TEX_GEN_UNDO", "performance"}, {"TEX_REUSE", "performance"}, {"TEX_SPLIT_FILL", "performance"}, {"TEX_VRAM_PRESSURE", "performance"}, {"TEX_RELEASED_HOLD", "performance"}, {"TEX_PROC_COMMIT_MB", "performance"}, {"ASTC_BC", "performance"}, {"LOD_GUARD", "performance"}, {"FIX_C0", "performance"}, {"FIX_ZERO", "performance"}, {"WATER_PROJECTIVE", "performance"}, @@ -340,6 +341,9 @@ static const CfgMeta metas[] = { {"PACE_SMOOTH", "1", "0|1", RESTART}, // vk_backend.c pace_smooth {"OQ_BATCH", "1", "0|1", RESTART}, // vk_exec_xfer.c oq_take {"CLEAR_SMALL_CPU", "1", "0|1", RESTART}, // vk_exec_xfer.c clear_small + // vk_exec_xfer.c oq_report_count: a samples-passed report missing draws the full query ring left uncounted + // repeats its record's last complete count where that is more than the part counted + {"OQ_HOLD", "0", "0|1", RESTART}, // the patch set occlusion_off (recomp/patches-v111.txt): the game makes no occlusion queries. The row is what // gives the set its default (patches.c rt_patches_init). {"PATCH_OCCLUSION_OFF", "0", "0|1", RESTART}, diff --git a/runtime/vk_exec_xfer.c b/runtime/vk_exec_xfer.c index 6a15e19a..1dc37041 100644 --- a/runtime/vk_exec_xfer.c +++ b/runtime/vk_exec_xfer.c @@ -42,6 +42,7 @@ #include "tex.h" #include "vk.h" #include "vk_exec.h" +#include "vk_oq_hold.h" enum { W_FORMAT, W_REGION, W_BUFFER, W_OVERLAP, W_COPY, W_CLEAR, W_QUERY, W_COUNT }; @@ -556,6 +557,7 @@ void vk_exec_oq_draw(VkExec *ex) { void vk_exec_oq_pass_end(VkExec *ex) { oq_close(ex); } static void oq_batch_drop(void); // the results kept by the completion thread (oq_take) +static void oq_hold_drop(void); // the counts remembered by the completion thread (oq_report_count) void vk_exec_xfer_shutdown(void) { if (oq_state > 0) vkDestroyQueryPool(vk_ctx()->device, oq_pool, NULL); @@ -563,6 +565,7 @@ void vk_exec_xfer_shutdown(void) { oq_nseg = 0, oq_lost = 0, oq_count = 0; memset((void *)oq_busy, 0, sizeof oq_busy); oq_batch_drop(); + oq_hold_drop(); } // The completion thread reads the results in batches (NX2NAVI_OQ_BATCH, default 1; 0: every segment by itself, as @@ -630,6 +633,35 @@ static uint64_t oq_take(VkDevice dev, uint32_t q, int *avail) { return OQB.v[2 * k]; } +// NX2NAVI_OQ_HOLD (default 0): a report whose segment left draws uncounted (a full ring, vk_exec_oq_draw) writes the +// larger of what was counted and the last complete count of its record (vk_oq_hold.h); off, what was counted, at least +// 1. Such reports are counted either way and logged at the 1st, 10th, 100th... so a log shows how often the ring ran +// full. Completion thread only, as oq_count. +static OqHold oq_held; +static int oq_hold_on = -1; +static uint64_t oq_lossy; + +static void oq_hold_drop(void) { memset(&oq_held, 0, sizeof oq_held), oq_lossy = 0, oq_hold_on = -1; } + +static uint64_t oq_report_count(const OqSegment *s, uint64_t counted) { + if (oq_hold_on < 0) { + oq_hold_on = cfg_bool("NX2NAVI_OQ_HOLD"); + if (oq_hold_on) rt_log("vk: samples-passed reports that miss draws repeat their record's last complete count (NX2NAVI_OQ_HOLD)"); + } + if (!s->lost) { + if (oq_hold_on) oqh_note(&oq_held, s->addr, counted); + return counted; + } + uint64_t v = oq_hold_on ? oqh_lost(&oq_held, s->addr, counted) : counted; + uint64_t n = ++oq_lossy, p = 1; + while (p < n) p *= 10; + if (p == n) + rt_log("vk: %llu samples-passed reports so far missed draws the full occlusion query ring left uncounted (this " + "one: %llu counted, %llu reported%s)", (unsigned long long)n, (unsigned long long)counted, + (unsigned long long)v, oq_hold_on ? ", NX2NAVI_OQ_HOLD" : ""); + return v; +} + static void oq_cb(void *arg) { OqSegment *s = arg; VkDevice dev = vk_ctx()->device; @@ -667,7 +699,7 @@ static void oq_cb(void *arg) { if (s->lost && !oq_count) oq_count = 1; if (s->addr) { if (vk_sync_effect_late()) __atomic_fetch_add(&late_counts, 1, __ATOMIC_RELAXED); - report_write(s->addr, s->seq, oq_count, 0); + report_write(s->addr, s->seq, oq_report_count(s, oq_count), 0); } } free(s->q), free(s); diff --git a/runtime/vk_oq_hold.h b/runtime/vk_oq_hold.h new file mode 100644 index 00000000..740f7456 --- /dev/null +++ b/runtime/vk_oq_hold.h @@ -0,0 +1,41 @@ +// The count a samples-passed report writes when the replay could not count all of its draws (vk_exec_xfer.c oq_cb, +// NX2NAVI_OQ_HOLD). Header only, so that tests/oq_hold_check.c checks it without a device. +// +// A draw that finds every slot of the occlusion query ring still waiting for the completion thread is left uncounted, +// and the report after it says only what the other draws counted, at least 1. The game reads these counts as how much +// of an object shows, and a count of 1 where an object's proxy covers thousands of samples tells it the object is all +// but hidden for that frame: whatever it scales by that count (a light's glare, an object's fade) drops for one frame +// and comes back, a flicker. The ring fills while the GPU or the completion thread falls behind the replay, in a hitch +// at a few hundred frames a second. With the hold such a report writes the larger of what was counted and what the +// last complete report into the same record said: the previous frame's answer for that object. A record with no +// complete report remembered keeps the old rule. +#pragma once +#include + +#define OQH_N 1024 // records remembered, direct-mapped by address; a collision forgets the older one + +typedef struct { + uint64_t addr[OQH_N]; + uint64_t count[OQH_N]; +} OqHold; + +static inline uint32_t oqh_slot(uint64_t addr) { + uint64_t h = (addr >> 4) * 0x9E3779B97F4A7C15ull; // records are 16 bytes apart at least + return (uint32_t)(h >> 54) & (OQH_N - 1); +} + +// A complete report of count into the record at addr. +static inline void oqh_note(OqHold *t, uint64_t addr, uint64_t count) { + if (!addr) return; + uint32_t i = oqh_slot(addr); + t->addr[i] = addr, t->count[i] = count; +} + +// The count to report into addr for a report that missed draws, of which the others counted `counted`. +static inline uint64_t oqh_lost(const OqHold *t, uint64_t addr, uint64_t counted) { + uint64_t c = counted ? counted : 1; + if (!addr) return c; + uint32_t i = oqh_slot(addr); + if (t->addr[i] == addr && t->count[i] > c) c = t->count[i]; + return c; +} diff --git a/tests/oq_hold_check.c b/tests/oq_hold_check.c new file mode 100644 index 00000000..d5a34782 --- /dev/null +++ b/tests/oq_hold_check.c @@ -0,0 +1,66 @@ +// runtime/vk_oq_hold.h: the count a samples-passed report writes when the full occlusion query ring left some of its +// draws uncounted (vk_exec_xfer.c oq_report_count, NX2NAVI_OQ_HOLD). Built and run by tests/oq_hold_check.py; prints +// each failed check and exits 1 on any. +#include +#include + +#include "vk_oq_hold.h" + +static int fails; +#define CHECK(c, ...) do { if (!(c)) { fails++; printf("FAIL line %d: ", __LINE__); printf(__VA_ARGS__); printf("\n"); } } while (0) + +static OqHold t; + +int main(void) { + // ---- nothing remembered: the old rule, what was counted and at least 1 + CHECK(oqh_lost(&t, 0x5000, 0) == 1, "an empty table gave %llu for nothing counted, not 1", + (unsigned long long)oqh_lost(&t, 0x5000, 0)); + CHECK(oqh_lost(&t, 0x5000, 37) == 37, "an empty table changed a count of 37"); + // ---- a record reported complete, then a frame that missed draws: the last count holds + oqh_note(&t, 0x5000, 4096); + CHECK(oqh_lost(&t, 0x5000, 0) == 4096, "a lost report of a record last at 4096 gave %llu", + (unsigned long long)oqh_lost(&t, 0x5000, 0)); + CHECK(oqh_lost(&t, 0x5000, 12) == 4096, "a lost report that counted 12 of its draws gave %llu, not the held 4096", + (unsigned long long)oqh_lost(&t, 0x5000, 12)); + // what was counted is a lower bound: above the held count it wins + CHECK(oqh_lost(&t, 0x5000, 5000) == 5000, "a lost report that counted 5000 gave %llu", + (unsigned long long)oqh_lost(&t, 0x5000, 5000)); + // ---- a newer complete report replaces the held count, an occluded one too + oqh_note(&t, 0x5000, 0); + CHECK(oqh_lost(&t, 0x5000, 0) == 1, "a record last reported hidden (0) gave %llu for a lost report, not 1", + (unsigned long long)oqh_lost(&t, 0x5000, 0)); + oqh_note(&t, 0x5000, 300); + CHECK(oqh_lost(&t, 0x5000, 7) == 300, "the newest complete count (300) did not hold"); + // ---- other records keep their own counts; a record next to it is another one + oqh_note(&t, 0x5010, 9000); + CHECK(oqh_lost(&t, 0x5000, 0) == 300 || oqh_slot(0x5000) == oqh_slot(0x5010), + "the record 16 bytes on changed this one's count"); + CHECK(oqh_lost(&t, 0x5010, 2) == 9000, "the record 16 bytes on did not hold its own count"); + CHECK(oqh_lost(&t, 0x6000, 2) == 2, "a record never reported took another's count"); + // ---- a record that shares a slot with another forgets the older count, never takes the other's + uint64_t a = 0x100000, b = 0; + for (uint64_t x = a + 16; x < a + (1ull << 24); x += 16) + if (oqh_slot(x) == oqh_slot(a)) { b = x; break; } + CHECK(b != 0, "no address shares a slot with %#llx in 16 MiB", (unsigned long long)a); + if (b) { + oqh_note(&t, a, 111); + oqh_note(&t, b, 222); + CHECK(oqh_lost(&t, a, 3) == 3, "an overwritten record gave %llu, not what it counted", + (unsigned long long)oqh_lost(&t, a, 3)); + CHECK(oqh_lost(&t, b, 3) == 222, "the newer record of a slot lost its count"); + } + // ---- a report with no CPU-visible record (address 0) is neither remembered nor held + oqh_note(&t, 0, 777); + CHECK(oqh_lost(&t, 0, 0) == 1, "address 0 was remembered"); + // ---- the 1024 records of a frame's queries spread over the table: few collisions among consecutive records + int used[OQH_N]; + memset(used, 0, sizeof used); + int collisions = 0; + for (int i = 0; i < 256; i++) { + uint32_t s = oqh_slot(0x2692c7000ull + 32ull * (uint64_t)i); + collisions += used[s]++ != 0; + } + CHECK(collisions < 64, "256 records 32 bytes apart collided %d times in %d slots", collisions, OQH_N); + if (!fails) printf("oq_hold_check: all checks passed\n"); + return fails ? 1 : 0; +} diff --git a/tests/oq_hold_check.py b/tests/oq_hold_check.py new file mode 100644 index 00000000..677ffc84 --- /dev/null +++ b/tests/oq_hold_check.py @@ -0,0 +1,41 @@ +"""Build and run tests/oq_hold_check.c: runtime/vk_oq_hold.h, the count a samples-passed report writes when the full +occlusion query ring left some of its draws uncounted (vk_exec_xfer.c oq_report_count, NX2NAVI_OQ_HOLD): the larger of +what was counted and the record's last complete count. + +No GPU, no game files, any Python. + +Usage: oq_hold_check.py +""" +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MSYS_BIN = Path(r'C:\msys64\ucrt64\bin') + + +def compiler(): + """$CC, else MSYS2 UCRT64's clang, else clang on PATH (as tests/diff/common.py picks it, without its Unicorn).""" + if os.environ.get('CC'): + return os.environ['CC'] + if os.name == 'nt' and (MSYS_BIN / 'clang.exe').exists(): + return str(MSYS_BIN / 'clang.exe') + return shutil.which('clang') or 'clang' + + +def main(): + work = ROOT / 'build' / 'test' / 'oq_hold' + work.mkdir(parents=True, exist_ok=True) + exe = work / ('oq_hold_check.exe' if os.name == 'nt' else 'oq_hold_check') + env = dict(os.environ) + if os.name == 'nt' and MSYS_BIN.exists(): + env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '') + subprocess.check_call([compiler(), '-std=gnu11', '-O1', '-Wall', '-Werror', '-I', str(ROOT / 'runtime'), + str(ROOT / 'tests' / 'oq_hold_check.c'), '-o', str(exe)], env=env) + return subprocess.run([str(exe)], env=env).returncode + + +if __name__ == '__main__': + sys.exit(main())