From af06160284c1e5f2902ef8afd2bedc69948c2421 Mon Sep 17 00:00:00 2001 From: chrg6 Date: Fri, 13 Nov 2026 17:27:47 -0500 Subject: [PATCH] Leave lazily armed memory out of the camera-speed search, so a sensitivity change no longer fetches GPU buffers. camera_speed.c rewrite reads the guest's heap and alias regions in 16 MiB pieces under mem_read_hold and skipped only untouched lazy-heap runs. A piece holding pages the lazy write-back had armed (GPU-written buffers the CPU has not read since) faulted into memory.c lazy_fault on each armed range, which fetches it from the GPU under frz_lock while the game runs, disarms those buffers, and keeps the game's mapping calls waiting on the read hold meanwhile. This happened at every change of the stick sensitivity with the lazy write-back on (the Windows default except on AMD's driver). A piece that mem_lazy_touches reports armed is now gone through in 64 KiB steps, and the armed steps are not read; the camera table never lies in a GPU-written buffer. The step after a piece still starts a little before its end, so a copy across two steps or two pieces is found as before. The first search that leaves memory out logs how much, since with NX2NAVI_WB_PROBE every range counts as armed and the search then reports the table "not in memory". A range armed between the check and the read still costs one fetch, as before. Not reproduced before the change (no log of a sensitivity change under trace). tests/camera_search_check.py cuts factor and rewrite from camera_speed.c over a 40 MiB stand-in heap with one armed 64 KiB step: the copies in plain memory, across two pieces and across two steps are rewritten, the copy in armed memory is left alone and no read touches it, the left-out memory is logged once, and with every range armed nothing is read. Against the previous camera_speed.c it fails six of its checks. mouse_stick_check passes, and the compile check adds no warnings. Co-Authored-By: Claude Opus 5.5 --- runtime/camera_speed.c | 24 +++++++- tests/camera_search_check.py | 113 +++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 tests/camera_search_check.py diff --git a/runtime/camera_speed.c b/runtime/camera_speed.c index 50237675..ac5765ce 100644 --- a/runtime/camera_speed.c +++ b/runtime/camera_speed.c @@ -161,8 +161,15 @@ static void factor(float out[8], float k) { // the array at factor k, as cam_p // read and write; each copy found becomes the array at factor to and is remembered (copies, when fresh is set). // Returns how many were rewritten. Runs on its own thread: the regions are read 16 MiB at a time under mem_read_hold, // so the game's own mapping calls wait for one piece at most. +// Pages the lazy write-back has armed (GPU-written buffers the CPU has not read since, never the camera table) are +// left alone: a read would fetch each armed range from the GPU inside the fault handler (memory.c lazy_fault) while +// the game runs, and hold the game's mapping calls meanwhile. A piece that holds some is gone through in STEP pieces, +// without the armed ones; the first search that leaves some out says so, since with NX2NAVI_WB_PROBE every piece +// counts as armed and the search then finds nothing. static int rewrite(float k, float to, int fresh) { - enum { PIECE = 16 << 20 }; + enum { PIECE = 16 << 20, STEP = 64 << 10 }; + static int told; + uint64_t left_out = 0; float from[8], into[8]; factor(from, k); factor(into, to); @@ -189,6 +196,14 @@ static int rewrite(float k, float to, int fresh) { continue; } uint64_t n = stop - a < PIECE ? stop - a : PIECE; + if (mem_lazy_touches(a, n)) { + if (n > STEP) n = STEP; + if (mem_lazy_touches(a, n)) { + a += n; + left_out += n; + continue; + } + } if (n >= len && mem_read_hold(a, n)) { const uint8_t *p = (const uint8_t *)(uintptr_t)a, *e = p + n; for (const uint8_t *q = p; q + len <= e && (q = memchr(q, first, (size_t)(e - q) - len + 1)); q++) { @@ -201,10 +216,15 @@ static int rewrite(float k, float to, int fresh) { mem_read_release(); } // a copy across two pieces: the next piece starts a little before this one's end - a += n < PIECE ? n : n - (len - 1); + a += a + n >= stop ? n : n - (len - 1); } } } + if (left_out && !told) { + told = 1; + rt_log("camera speed: the search left out %.1f MiB the GPU wrote and the CPU has not read since (lazy " + "write-back; with NX2NAVI_WB_PROBE all of it)", left_out / 1048576.0); + } return found; } diff --git a/tests/camera_search_check.py b/tests/camera_search_check.py new file mode 100644 index 00000000..d113a92c --- /dev/null +++ b/tests/camera_search_check.py @@ -0,0 +1,113 @@ +"""The camera-speed search leaves lazily armed memory alone (runtime/camera_speed.c rewrite): builds factor and rewrite, +cut from camera_speed.c by their text, over a 40 MiB stand-in for the guest's heap with stand-ins for the memory calls, +and checks that + + - copies of the array in plain memory are found and rewritten, also one across two 16 MiB pieces and one across two + 64 KiB steps of a piece that holds armed memory; + - no read touches an armed range (a GPU-written buffer the CPU has not read), and a copy inside one is left as it is; + - the first search that leaves armed memory out logs it once, and with every range armed (NX2NAVI_WB_PROBE) the search + reads nothing. + +No game files. Usage: python tests/camera_search_check.py [--cc ] +""" +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 +#include +#include +#include +typedef struct { uint64_t base, size; } RtRange; +typedef struct { RtRange aslr, stack, heap, alias, kernel, hostfn; uint64_t pool; } RtGuestLayout; +typedef struct { uint64_t base, size; uint32_t state, attr, perm, ipc, device, pad; } RtMemoryInfo; +#define HEAP (40u << 20) +static uint8_t *heap; +static RtGuestLayout layout; +static const RtGuestLayout *mem_layout(void) { return &layout; } +static uint32_t mem_query(uint64_t a, RtMemoryInfo *out) { + uint64_t lo = (uint64_t)(uintptr_t)heap; + memset(out, 0, sizeof *out); + if (a >= lo && a < lo + HEAP) *out = (RtMemoryInfo){lo, HEAP, 1, 0, 3}; + return 0; +} +// armed: [arm_lo, arm_hi) as offsets in the heap; all_armed: the write-back probe +static uint64_t arm_lo, arm_hi; +static int all_armed, armed_reads, holds, logs; +static uint64_t off(uint64_t a) { return a - (uint64_t)(uintptr_t)heap; } +static int mem_lazy_touches(uint64_t a, uint64_t n) { return all_armed || (off(a) < arm_hi && off(a) + n > arm_lo); } +static uint64_t mem_untouched_run(uint64_t a, uint64_t end) { (void)a, (void)end; return 0; } +static int mem_read_hold(uint64_t a, uint64_t n) { + holds++; + armed_reads += off(a) < arm_hi && off(a) + n > arm_lo; + return 1; +} +static void mem_read_release(void) {} +static void mem_thaw(uint64_t a, uint64_t n) { (void)a, (void)n; } +static void rt_log(const char *fmt, ...) { (void)fmt; logs++; } +static float base[8] = {0.8f, 1.2f, 1.6f, 2.0f, 2.4f}; +static int base_n = 5; +%s +static int fails; +#define CHECK(cond, ...) do { if (!(cond)) { printf("FAIL: " __VA_ARGS__); putchar('\n'); fails++; } } while (0) +static void put(uint64_t at, float k) { float v[8]; factor(v, k); memcpy(heap + at, v, sizeof(float) * 5); } +static int holds_at(uint64_t at, float k) { float v[8]; factor(v, k); return !memcmp(heap + at, v, sizeof(float) * 5); } +int main(void) { + heap = calloc(HEAP, 1); + layout.heap = (RtRange){(uint64_t)(uintptr_t)heap, HEAP}; + arm_lo = (20u << 20) + (128u << 10), arm_hi = arm_lo + (64u << 10); // one armed 64 KiB step in the second piece + uint64_t plain = 1000, across_piece = (16u << 20) - 8, across_step = (20u << 20) + (64u << 10) - 6; + uint64_t in_armed = arm_lo + 100; + put(plain, 1.0f), put(across_piece, 1.0f), put(across_step, 1.0f), put(in_armed, 1.0f); + int n = rewrite(1.0f, 2.0f, 1); + CHECK(n == 3, "three copies outside armed memory rewritten (%%d)", n); + CHECK(holds_at(plain, 2.0f) && holds_at(across_piece, 2.0f) && holds_at(across_step, 2.0f), + "the plain copy, the one across two pieces and the one across two steps hold the new factor"); + CHECK(holds_at(in_armed, 1.0f), "the copy in armed memory is left as it is"); + CHECK(!armed_reads, "no read touches armed memory (%%d)", armed_reads); + CHECK(logs == 1, "the left-out memory is logged once (%%d)", logs); + n = rewrite(2.0f, 1.5f, 1); + CHECK(n == 3 && logs == 1, "a second search finds the same three and logs nothing more (%%d, %%d)", n, logs); + all_armed = 1, holds = 0; + n = rewrite(1.5f, 1.0f, 1); + CHECK(n == 0 && holds == 0, "with every range armed nothing is read (%%d found, %%d reads)", n, holds); + printf(fails ? "camera search: %%d failures\n" : "camera search: ok\n", fails); + return fails != 0; +} +''' + + +def cut(text, start, end_marker): + a = text.index(start) + b = text.index(end_marker, a) + return text[a:b] + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + 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' / 'camera_speed.c').read_text(encoding='utf-8') + body = cut(src, '#define COPIES 16', '\n// The copies of the last search, still holding the array at factor k') + with tempfile.TemporaryDirectory(prefix='nx2navi-camera-search-') 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', '-Wno-unused-function', str(c), '-o', str(exe)], env=env) + return subprocess.call([str(exe)]) + + +if __name__ == '__main__': + sys.exit(main())