Add oq_hold, which makes a samples-passed report whose draws the full occlusion query ring left uncounted repeat its record's last complete count, and log how often such reports happen.

A draw that finds every query slot still waiting for the completion thread goes uncounted, and the report after it said only what the other draws counted, at least 1: the game takes the object it tested for all but hidden for that frame. With the setting the report writes the larger of that and the last complete count reported into the same record. Off by default and read at start; the count of such reports is logged at the 1st, 10th, 100th and so on either way. tests/oq_hold_check.py checks the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-13 08:33:35 -05:00
parent cc5c8f87ad
commit 9d5f16bda4
7 changed files with 199 additions and 3 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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},

View File

@@ -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);

41
runtime/vk_oq_hold.h Normal file
View File

@@ -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 <stdint.h>
#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;
}

66
tests/oq_hold_check.c Normal file
View File

@@ -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 <stdio.h>
#include <string.h>
#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;
}

41
tests/oq_hold_check.py Normal file
View File

@@ -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())