Check that a part's CPU inputs reach the GPU ahead of its own noted writes, and let a frame dump pick shaders and keep raw texels.

This pull request fixed lighting that added up frame after frame under shadow storage on Linux: a GPU write
noted while a part was recorded kept that part's CPU inputs (the game's resets of its lighting accumulators)
out of the shadow. Its fix marked noted ranges as submitted in vk_memory.c; the base copies that came with
the black-world fix already move every CPU-changed byte in before the part runs, so that part is not taken
and no SHADOW_PENDING_INPUTS setting is added. Taken from it:
- tex_probe.c (--probe-vkmem): a range noted as a future GPU write still gets the CPU's bytes before the
  GPU reads it. The pull request's other check, that a CPU write made after a GPU result loses to it until
  the write-back, is left out: with base copies the later CPU write wins, as it does on the console.
- vk_dump.c: NX2NAVI_DUMP_SHADERS=<hash>,... copies images only for passes that use those shaders, and
  NX2NAVI_DUMP_RAW=1 also saves each copied image's texel bytes.
- tools/fix_linux_ambient_power.py, as it is: patches one translated shader of a player's own cache so a
  zero exponent no longer gives NaN. It holds two hashes and no shader data.

Pull request #264 by intellq. The Windows files compile; the probe was not run, and nothing was run on
Linux here.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-10 06:24:56 -05:00
parent f740e95a62
commit df2427d00a
4 changed files with 146 additions and 11 deletions

View File

@@ -41,7 +41,7 @@ static const char *const known[] = {
"BIND_ZL", "BIND_ZR", "BLOCK_VIEWS_3D", "CACHE_DIE", "CLOTH_REALTIME", "COLOR_BRIGHTNESS", "COMPUTE_KEEP", "COLOR_CONTRAST", "COLOR_GAMMA",
"COLOR_PRESET", "COLOR_SATURATION", "CONTACT_DEBUG", "CONTACT_SHADOWS", "CORE_DIAG", "CRIT", "CRIT_OUT",
"DESC_TEMPLATE", "DEVICE_PROFILE", "DISCORD_APP_ID", "DISCORD_IMAGE", "DISCORD_RPC", "DLAA", "DLAA_CAM_KEEP", "DLSS_IDLE_FREE", "DLAA_JITTER", "DLAA_MV", "DLAA_MASKDEBUG", "DLAA_MVDEBUG", "DLAA_OBJMV", "DLAA_SURVEY", "DLAA_TIMES", "DLSS", "DLSS_CHAR_MASK", "DLSS_PRESET", "DLSS_REACTIVE", "DUMP", "DUMP_BUF", "DUMP_DIR",
"DUMP_LOG", "DUMP_FILE", "DS3", "LOG_CS_BINDS", "LOG_TEX_FLAGS", "LOG_MESH", "MESH", "POOL", "NATIVE_TENSOR", "LOG_RCOND", "LOG_RT_SIZES", "DUMP_WHITE", "DUMP_AFTER", "DUMP_MOVIE", "SUBMIT_FINISH", "ACQUIRE_WAIT", "CRASH_FILTER_GUARD", "CS_FALLBACK", "EXEFS", "FEEDBACK", "FIX_C0", "FIX_ZERO", "FLIGHT", "FLIGHT_AT", "FLIGHT_DIR", "FLIGHT_FRAMES",
"DUMP_LOG", "DUMP_RAW", "DUMP_SHADERS", "DUMP_FILE", "DS3", "LOG_CS_BINDS", "LOG_TEX_FLAGS", "LOG_MESH", "MESH", "POOL", "NATIVE_TENSOR", "LOG_RCOND", "LOG_RT_SIZES", "DUMP_WHITE", "DUMP_AFTER", "DUMP_MOVIE", "SUBMIT_FINISH", "ACQUIRE_WAIT", "CRASH_FILTER_GUARD", "CS_FALLBACK", "EXEFS", "FEEDBACK", "FIX_C0", "FIX_ZERO", "FLIGHT", "FLIGHT_AT", "FLIGHT_DIR", "FLIGHT_FRAMES",
"FLIGHT_SPIKE_MS", "FLIGHT_THUMBS", "FLUSH_SPAN", "FLUSH_UPLOAD", "FOG_CULL", "FOLIAGE_SHADOW_CULL", "FOLIAGE_SHADOW_VRS", "FOV", "FOV_PATCH", "FG_HUDLESS_SHOW", "FPS60", "FPS_LIMIT", "FRAMEGEN", "FRAMEGEN_MULTIPLIER", "FREEZE_IDLE", "FREE_CORES",
"FULLSCREEN", "FXAA", "GAME_ASPECT", "GAME_RES", "GPA", "GPA_EMPTY", "GPA_TWIN", "GPA_TWIN_MAX_KB", "GPL", "GPL_DRAW_BUILD", "GPU", "GPU_INDEX", "GPU_PASSES", "GPU_NAME", "HDR", "HDR_BLOWOUT", "HDR_CONTRAST",
"HDR_EXPOSURE", "HDR_FLARE", "HDR_GAMMA", "HDR_GAME_NITS", "HDR_GRADE", "HDR_HIGHLIGHT_SAT", "HDR_HIGHLIGHTS", "HDR_HUE",

View File

@@ -348,6 +348,7 @@ static int test_import(pool_t *pool) {
vk_mem_resolve(a + 0x1000, 0x20000, &sp);
mem_written_pages(a, pool->size, pages, 64);
vk_mem_gpu_pending(a + 0x1000, 0x20000);
vk_mem_shadow_sync(); // as a submit does: the part's CPU inputs go in before its GPU work
copy_wait(sb, 0, sp.buffer, sp.offset, 0x20000);
vk_gpu_written(a + 0x1000, 0x20000);
GCHECK(!memcmp(m + 0x1000, s, 0x20000), "GPU copy into the imported heap range differs from the source");
@@ -362,10 +363,14 @@ static int test_import(pool_t *pool) {
GCHECK(n == 4 && pages[0] == a + 0x30000 && pages[3] == a + 0x33000, "CPU writes to 4 pages: %llu reported",
(unsigned long long)n);
vk_mem_resolve(a + 0x30000, 0x4000, &sp);
// A GPU write noted for the part being submitted must not keep the part's CPU inputs out: a buffer the shaders
// add to and the CPU resets (the game's lighting accumulators) depends on the reset reaching the GPU first.
vk_mem_gpu_pending(a + 0x30000, 0x4000);
vk_mem_shadow_sync();
copy_wait(sp.buffer, sp.offset, sb, 0, 0x4000);
vmaInvalidateAllocation(c->vma, sa, 0, VK_WHOLE_SIZE);
GCHECK(!memcmp(s, m + 0x30000, 0x4000), "GPU read of CPU-written heap differs");
vk_gpu_written(a + 0x30000, 0x4000);
vmaDestroyBuffer(c->vma, sb, sa);
// generations: written after `since` -> 1, then 0, explicit host write -> 1

View File

@@ -24,6 +24,8 @@
// before the submission (vk_exec_draw.c upd_note).
// NX2NAVI_DUMP_SKIP_RUNS=1: a render pass with the same targets as the render pass before it is logged but not copied
// (the terrain decals draw one pass each into the G-buffer and would use up both caps).
// NX2NAVI_DUMP_SHADERS=<hash>,...: copy images only for passes using those shader hashes; metadata covers the whole frame.
// NX2NAVI_DUMP_RAW=1: also save each copied image's native texel bytes as .bin, for numerical analysis.
#include <math.h>
#include <stdarg.h>
#include <stdio.h>
@@ -60,6 +62,8 @@ static uint64_t dump_after; // NX2NAVI_DUMP_AFTER
static FILE *passes_f, *images_f;
static int seq, finished, copied; // copied: passes and dispatches of the dump frame whose images were copied
static int skip_runs, dump_ubo; // NX2NAVI_DUMP_SKIP_RUNS, NX2NAVI_DUMP_UBO (vec4 per block, 0 for none)
static int dump_raw, ncopy_shaders;
static uint64_t copy_shaders[16];
static uint32_t want_tex[16][2]; // NX2NAVI_DUMP_TEX: slot id and 0, or width and height
static int nwant_tex;
static uint64_t last_targets; // targets_sig of the last render pass; 0 after a dispatch
@@ -288,10 +292,10 @@ static void job_done(void *arg) {
VmaAllocator vma = vk_ctx()->vma;
vmaInvalidateAllocation(vma, j->alloc, 0, VK_WHOLE_SIZE);
write_images(j);
// A BC texture's raw blocks also go to <name>.bin, packed rows of blocks, so a script can decode them: the PNG
// shows the block words through the integer mapping, which loses their bits.
// Raw captures retain the exact texel bytes. BC blocks always get a .bin because the PNG's integer mapping
// loses their bits; other formats get one when DUMP_RAW is enabled.
size_t nl = strlen(j->name);
if (nl > 7 && !strcmp(j->name + nl - 7, "_blocks")) {
if (dump_raw || (nl > 7 && !strcmp(j->name + nl - 7, "_blocks"))) {
char path[600];
snprintf(path, sizeof path, "%s/%s.bin", dir, j->name);
FILE *f = fopen(path, "wb");
@@ -524,9 +528,10 @@ static void queue_lut(const VkTexImage *t, int id) {
if (frame_now() != dump_frame) return;
for (int i = 0; i < nluts_done; i++)
if (luts_done[i] == t->image) return;
if (nluts_done == MAX_LUTS) return;
luts_done[nluts_done++] = t->image;
uint32_t nl = t->type != VK_IMAGE_TYPE_3D && t->layers > 1 ? t->layers : 1; // a 2D array: one copy per layer
for (int i = 0; i < nluts; i++)
if (luts[i].image == t->image) return;
if (nluts_done == MAX_LUTS || nluts == MAX_LUTS) return;
uint32_t nl = t->type != VK_IMAGE_TYPE_3D && t->layers > 1 ? t->layers : 1;
for (uint32_t k = 0; k < nl && nluts < MAX_LUTS; k++)
luts[nluts++] = (Lut){t->image, t->format, t->width, t->height, t->type == VK_IMAGE_TYPE_3D ? t->depth : 1, id,
t->type != VK_IMAGE_TYPE_3D, k, nl};
@@ -559,6 +564,11 @@ static void flush_luts(VkExec *ex, int n) {
: copy_out(ex, l->image, l->format, asp, 0, l->layer, l->w, l->h, l->d, name);
fprintf(passes_f, " %s tex %d -> %s.png%s%s\n", l->is2d ? "2D" : "3D", l->id, name, why ? " skipped: " : "",
why ? why : "");
if (!why && nluts_done < MAX_LUTS) {
int known = 0;
for (int k = 0; k < nluts_done; k++) known |= luts_done[k] == l->image;
if (!known) luts_done[nluts_done++] = l->image;
}
}
nluts = 0;
}
@@ -909,9 +919,30 @@ static uint64_t targets_sig(const VkExec *ex) {
}
// Sets pass_skip for the pass or dispatch being logged and counts it when it copies.
static void pass_decide(int same_targets) {
static int render_copy_selected(void) {
if (!ncopy_shaders) return 1;
for (int k = 0; k < ncopy_shaders; k++) {
char vertex[24], fragment[24];
snprintf(vertex, sizeof vertex, "vs %016llx", (unsigned long long)copy_shaders[k]);
snprintf(fragment, sizeof fragment, "fs %016llx", (unsigned long long)copy_shaders[k]);
for (int i = 0; i < nents; i++)
if (strstr(ents[i].text, vertex) || strstr(ents[i].text, fragment)) return 1;
}
return 0;
}
static int compute_copy_selected(const GfxShader *cs) {
if (!ncopy_shaders) return 1;
uint64_t h = shader_hash(cs);
for (int k = 0; k < ncopy_shaders; k++)
if (copy_shaders[k] == h) return 1;
return 0;
}
static void pass_decide(int same_targets, int selected) {
if (frame_now() != dump_frame) copied = 0;
pass_skip = copied >= MAX_PASSES ? "pass cap" : same_targets ? "same targets as the previous pass" : NULL;
pass_skip = !selected ? "shader not selected" : copied >= MAX_PASSES ? "pass cap" :
same_targets ? "same targets as the previous pass" : NULL;
if (!pass_skip) copied++;
}
@@ -928,7 +959,7 @@ void vk_dump_pass_end(VkExec *ex) {
if (!active()) goto out;
int n = seq++;
uint64_t sig = targets_sig(ex);
pass_decide(skip_runs && sig == last_targets);
pass_decide(skip_runs && sig == last_targets, render_copy_selected());
last_targets = sig;
fprintf(passes_f, "pass %04d: render pass, draws %d, area %ux%u layers %u, fence %llu, frame %llu\n", n, pass_draws,
ex->pass_w, ex->pass_h, ex->pass_layers, (unsigned long long)ex->fence, (unsigned long long)frame_now());
@@ -958,7 +989,7 @@ void vk_dump_dispatch(VkExec *ex, const GfxShader *cs, VkPipeline p, const VkPip
plat_mutex_lock(&rec_lock);
if (!active()) goto out;
int n = seq++;
pass_decide(0);
pass_decide(0, compute_copy_selected(cs));
last_targets = 0;
Str b = {0};
describe_bindings(&b, ex, info);
@@ -1143,6 +1174,16 @@ void vk_dump_init(void) {
if (dump_ubo == 1) dump_ubo = 16;
if (dump_ubo > 4096) dump_ubo = 4096;
if (dump_ubo < 0) dump_ubo = 0;
const char *raw = cfg_get("NX2NAVI_DUMP_RAW");
dump_raw = raw && *raw && *raw != '0';
const char *shaders = cfg_get("NX2NAVI_DUMP_SHADERS");
for (const char *p = shaders; p && *p && ncopy_shaders < 16;) {
char *end;
uint64_t h = strtoull(p, &end, 16);
if (end == p) break;
copy_shaders[ncopy_shaders++] = h;
p = *end ? end + 1 : end;
}
const char *wt = cfg_get("NX2NAVI_DUMP_TEX");
for (char *q = (char *)wt; q && *q && nwant_tex < 16; q += *q == ',') {
uint32_t *w = want_tex[nwant_tex++];

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Apply the verified Ocarina 1.1.1 ambient-power workaround to a Linux shader cache file.
The affected shader evaluates exp2(log2(height) * exponent). A zero height
and zero exponent produce a NaN, which then contaminates ambient lighting
and volumetric history. Select a zero product when the exponent is zero.
This is a narrowly scoped compatibility workaround, not a claim of exact
NVIDIA instruction equivalence. It does not address exposure or memory use.
No game shader data is embedded in this tool.
"""
import argparse
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
ORIGINAL_HASH = 0xA806B657C39684BD
PATCHED_HASH = 0x0A0281063D5EF5BD
def shader_hash(data):
value = 0xCBF29CE484222325
for byte in data:
value = ((value ^ byte) * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
return value
def patch_text(source):
pattern = re.compile(
r'(?P<ptr>%\w+) = OpAccessChain %\w+ %cp_c3 %int_0 %int_2 %int_1\s+'
r'(?P<exponent>%\w+) = OpLoad %float (?P=ptr)\s+'
r'(?P<product>%\w+) = OpFMul %float (?P<logarithm>%\w+) (?P=exponent)\s+'
r'(?P<result>%\w+) = OpExtInst %float %\w+ Exp2 (?P=product)'
)
matches = list(pattern.finditer(source))
if len(matches) != 1:
raise ValueError('Expected exactly one supported ambient-power expression')
m = matches[0]
old = f"{m['product']} = OpFMul %float {m['logarithm']} {m['exponent']}"
replacement = (
f"%linux_pow_product = OpFMul %float {m['logarithm']} {m['exponent']}\n"
f"%linux_pow_zero_exponent = OpFOrdEqual %bool {m['exponent']} %float_0\n"
f"{m['product']} = OpSelect %float %linux_pow_zero_exponent %float_0 %linux_pow_product"
)
if source.count(old) != 1:
raise ValueError('The supported multiply instruction is not unique')
return source.replace(old, replacement)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('shader', type=Path, help='Supported .spv shader cache file')
destination = parser.add_mutually_exclusive_group(required=True)
destination.add_argument('--output', type=Path)
destination.add_argument('--in-place', action='store_true')
args = parser.parse_args()
original = args.shader.read_bytes()
current = shader_hash(original)
if current == PATCHED_HASH:
if args.output:
args.output.write_bytes(original)
print(f'Already patched: {current:016x}')
return
if current != ORIGINAL_HASH:
parser.error(f'Unsupported shader hash {current:016x}; file left unchanged')
for command in ('spirv-dis', 'spirv-as', 'spirv-val'):
if not shutil.which(command):
parser.error(f'Required command not found: {command}')
target = args.shader if args.in_place else args.output
with tempfile.TemporaryDirectory(prefix='.ambient-power-', dir=target.parent) as work:
work = Path(work)
assembly = work / 'shader.spvasm'
binary = work / 'shader.spv'
subprocess.run(['spirv-dis', str(args.shader), '-o', str(assembly)], check=True)
assembly.write_text(patch_text(assembly.read_text()))
subprocess.run(['spirv-as', '--target-env', 'vulkan1.1', str(assembly), '-o', str(binary)], check=True)
subprocess.run(['spirv-val', '--target-env', 'vulkan1.1', str(binary)], check=True)
patched = binary.read_bytes()
result = shader_hash(patched)
if result != PATCHED_HASH:
raise ValueError(f'Unexpected assembled shader hash {result:016x}; file left unchanged')
binary.chmod(args.shader.stat().st_mode & 0o777)
binary.replace(target)
print(f'Patched {target}: {current:016x} -> {result:016x}; Vulkan 1.1 validation passed')
if __name__ == '__main__':
main()