Files
NX2navi/tools/package_build.py
chrg6 719ee94e67 Write a pipeline cache only when its data grew, and give what a run compiles a cache file of its own.
pipeline-cache.bin was copied out of the driver and written whole at every save after any pipeline was created, also one the cache had held: tonight's logs show three saves of 358.1 MB in a run that added 0.3 MB, and 302 to 306 MB written in each of seven sessions that added about 0.5 MB each. The library warm in play built its 37,572 stage libraries into the same cache, which grew it by 1.6 GB: a save of 2026.7 MB in play, a file over pipeline_cache_max_mb, and the next start dropped it whole and compiled the pipelines it had held.

cache_save now asks a cache for the size of its data first and neither copies nor writes one whose size is what its file holds. What a run compiles goes into a second cache with its own file, pipeline-cache-play.bin (pipeline_cache_play, default 1; needs pipelineCreationCacheControl): pipe_create_in asks the first cache with VK_PIPELINE_CREATE_FAIL_ON_PIPELINE_COMPILE_REQUIRED_BIT and builds into the play cache only when that says a compile is needed, and in play only the play file is saved. At a start the play file is read back and merged into the first cache in memory; once it reached pipeline_cache_play_mb (64) it is folded in for good: pipeline-cache.bin is written once, before play, and the play file removed. The preparation modes fold it at any size and build into the one file as before. The library warm's threads create with no cache of ours, so their libraries reach the driver's own cache alone.

Checked with --probe-prewarm on a copy of a dev folder's caches (3,212 recorded pipelines, 333 compute shaders), no game run: with a filled pipeline-cache.bin every pipeline came from it and neither file was written; with none and a cold driver cache the compiles went into pipeline-cache-play.bin (75.9 MB written, pipeline-cache.bin not created); the next start merged it and wrote pipeline-cache.bin once (191.1 MB) and removed it; kept below the fold size, a play file of 68.8 MB gave 1,445 pipelines at 0.3 ms from the first cache where 470 hit without the merge, and was not written again. Not checked in play: the saves during a session, the library warm without a cache, and drivers other than NVIDIA's.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 04:56:26 -05:00

114 lines
5.8 KiB
Python

"""Package a built nx2navi.exe into a folder that runs from anywhere: build/<version>-portable by default.
The folder holds the exe, its DLLs, the shader translator (NativeAOT: no .NET), the RomFS index and patches, the
extracted ExeFS (the game code the exe loads), the shared fonts, the prepared shader and pipeline files. There is
no config file: nx2navi.exe finds the exefs, romfs, profile and shared-fonts folders next to itself. Without --with-romfs
the 16 GB RomFS image stays out; copy build/extracted/<version>/romfs/romfs.bin to <folder>/romfs/ or set NX2NAVI_ROMFS
(a path, relative ones to the exe's folder).
The folder contains game code and data derived from the game: for your own machines only, never publish or share it.
Saves go to <folder>/profile.
.venv/Scripts/python tools/package_build.py [--version v121] [--build build/v121] [--exe PATH/nx2navi.exe]
[--out build/v121-portable] [--with-romfs] [--no-shader-cache]
"""
import argparse
import os
import shutil
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
EXE = '.exe' if os.name == 'nt' else ''
# A Linux source build links the system's SDL3, Opus and libvpx: the other machine needs them installed
DLLS = ('SDL3.dll', 'libopus-0.dll', 'libiconv-2.dll') if os.name == 'nt' else ()
# Prepared state next to the exe: translator answers, programs, the prepared list, pipeline cache and its keys, and the
# variant lists `nx2navi.exe --prepare-shaders` reads (the driver keys its library cache by exe path, so each copy runs it).
STATE = ('romfs-index.bin', 'pipeline-cache.bin', 'pipeline-cache-play.bin', 'pipeline-keys.bin', 'shader-answers.bin',
'shader-programs.bin', 'shader-prepared.bin', 'seed-answers.bin', 'seed-programs.bin')
def copy(src, dst):
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
def copy_tree(src, dst, workers=16):
jobs = []
for dirpath, _, files in os.walk(src):
rel = Path(dirpath).relative_to(src)
(dst / rel).mkdir(parents=True, exist_ok=True)
jobs += [(Path(dirpath) / f, dst / rel / f) for f in files if not f.endswith('.tmp')]
with ThreadPoolExecutor(workers) as pool:
list(pool.map(lambda j: shutil.copy2(*j), jobs))
return len(jobs)
def size_of(path):
return sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file())
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--version', default='v121', help='game version (v121 or v143): the defaults below follow it')
ap.add_argument('--build', type=Path, help='build dir with the prepared state (default build/<version>)')
ap.add_argument('--exe', type=Path, help='nx2navi.exe to package (default: <build>/nx2navi.exe); its DLLs are taken from --build')
ap.add_argument('--xlate', type=Path, default=ROOT / 'build' / 'xlate' / f'nx2navi_xlate{EXE}')
ap.add_argument('--extracted', type=Path, help='default build/extracted/<version>')
ap.add_argument('--out', type=Path, help='default build/<version>-portable')
ap.add_argument('--with-romfs', action='store_true', help='also copy romfs.bin (about 16 GB)')
ap.add_argument('--no-shader-cache', action='store_true', help='leave out shader-cache (about 2 GB): translates again')
a = ap.parse_args()
a.build = a.build or ROOT / 'build' / a.version
a.extracted = a.extracted or ROOT / 'build' / 'extracted' / a.version
a.out = a.out or ROOT / 'build' / f'{a.version}-portable'
build, out = a.build.resolve(), a.out.resolve()
exe = (a.exe or build / f'nx2navi{EXE}').resolve()
fonts = a.extracted.resolve().parent / 'firmware' / 'shared-fonts'
exefs = a.extracted.resolve() / 'exefs'
romfs = a.extracted.resolve() / 'romfs' / 'romfs.bin'
need = [exe, a.xlate, exefs, fonts, build / 'romfs-patches'] + [build / d for d in DLLS] + [build / 'romfs-index.bin']
if a.with_romfs:
need.append(romfs)
missing = [str(p) for p in need if not p.exists()]
if missing:
sys.exit('package_build: missing: ' + ', '.join(missing))
if out == build:
sys.exit('package_build: --out must not be the build dir')
if not str(out).startswith(str(ROOT / 'build')) and not str(out).startswith(str(ROOT / 'generated')):
print('package_build: warning: the folder holds game code; keep it out of the repository')
out.mkdir(parents=True, exist_ok=True)
copy(exe, out / f'nx2navi{EXE}')
for d in DLLS:
copy(build / d, out / d)
copy(a.xlate, out / f'nx2navi_xlate{EXE}')
idf = a.xlate.with_suffix('.id')
if idf.exists():
copy(idf, out / 'nx2navi_xlate.id')
for f in STATE:
if (build / f).exists():
copy(build / f, out / f)
else:
print(f'package_build: {f} not in {build}, skipped')
n = copy_tree(build / 'romfs-patches', out / 'romfs-patches')
for name in ('romfs-patches-xbox', 'romfs-patches-xbox-label'): # prompts = xbox (make_romfs_patches.py --prompts)
if (build / name).is_dir():
n += copy_tree(build / name, out / name)
n += copy_tree(exefs, out / 'exefs')
n += copy_tree(fonts, out / 'shared-fonts')
if not a.no_shader_cache and (build / 'shader-cache').is_dir():
n += copy_tree(build / 'shader-cache', out / 'shader-cache')
(out / 'profile').mkdir(exist_ok=True)
if a.with_romfs:
(out / 'romfs').mkdir(exist_ok=True)
print('package_build: copying romfs.bin (16 GB) ...')
shutil.copyfile(romfs, out / 'romfs' / 'romfs.bin')
print(f'package_build: {out}: {n} files in folders, {size_of(out) / 2**30:.2f} GiB'
+ ('' if a.with_romfs else f'; no RomFS: put romfs.bin into romfs{os.sep} or set NX2NAVI_ROMFS'))
if __name__ == '__main__':
main()