mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
The review of the packaging guard (b4c21998) found that D-1's optional behaviour probe was left out without a reason: check_built compares only nx2navi_xlate.id with the checkout's sources, so an old exe beside a current .id still passed, and while the exe build record waits (G-12), a probe of the exe is the only landable check on the exe itself. It also passes the release scripts' folder, as that translator declares 40960 entries and clamps to 40959. tools/xlate_smoke.py gains pool_check(exe): it sends a hand-assembled fragment shader that reads a texture handle from an input (IPA R4 = a[0x80]; TXQ.B R0, R4; EXIT), which the translator reads from the texture pool array, and refuses an exe that does not run or answer, whose res.json texture pool entry (pool 1) has another array length than tools/xlate/Program.cs TexturePoolLength, or whose SPIR-V lacks that array or a GLSL.std.450 UMin by length - 1 (spirv_clamps reads the module itself, no disassembler needed). xlate_smoke.py runs it last and takes --exe. make_launcher.check_xlate runs pool_check on the exe after check_built on Windows, still before anything is downloaded, built or deleted. Not reproduced before the change beyond the audit's probe. On this PC, pool_check passes on work\xlate_6866a13e (and make_launcher.check_xlate on that folder), refuses the 0.9.2 translator (identity 4d051299) with "declares the texture pool with [32768] entries, where its sources say 40960", also through check_xlate beside a current .id, and xlate_smoke.py --exe passes on work\xlate_6866a13e. tests/xlate_package_check.py now checks spirv_clamps on a module it assembles, that pool_check and check_xlate refuse an exe that does not run beside a matching .id, and that a translator built in build/xlate passes when there is one; it passes. docs/building.md names pool_check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
460 lines
26 KiB
Python
460 lines
26 KiB
Python
"""Build a release of the NX2navi launcher (docs/launcher.md, "Releases").
|
|
|
|
python tools/make_launcher.py --xlate build/xlate [--ffx amd_fidelityfx_vk.dll] [--dlss nvngx_dlss.dll | --no-dlss]
|
|
[--ffx4 FOLDER]
|
|
[--prompt-art FOLDER [FOLDER ...]] [--out build/launcher-release]
|
|
[--pgo build/pgo/pgo-v111.profdata] [--feed owner/name]
|
|
[--notes notes.md] [--publish]
|
|
|
|
Needs Python 3.14 (tarfile and zstd for the MSYS2 packages), uv on PATH, network access on the first run, and
|
|
either Windows (msiexec, and the C compiler of MSYS2 it downloads itself) or Linux with clang, lld and llvm-windres
|
|
(it cross-compiles against the same MSYS2 packages) plus msitools. Downloads are cached in toolchain/launcher-dl/ and
|
|
checked against the sha256 in PINS.
|
|
|
|
The release folder (--out) holds:
|
|
|
|
NX2navi/ the launcher folder exactly as NX2navi-Setup.exe installs it:
|
|
NX2navi.exe launcher/native/nx2navi.c with the Navi icon (launcher/ui/art.py)
|
|
python/ python.org's embeddable Python 3.14.9, tkinter and Tcl/Tk 9.0 from its tcltk.msi,
|
|
the packages of tools/requirements-launcher.txt, runtime-id.txt
|
|
app/current.txt, app/<version>/ launcher/ (without native/), runtime/, recomp/, the tools make_game.py uses,
|
|
bin/nx2navi_xlate.exe and .id (and amd_fidelityfx_vk.dll with --ffx, AMD's two
|
|
FSR 4 libraries with --ffx4),
|
|
bin/nvngx_dlss.dll and its notice (NVIDIA DLSS at the version tools/dlss_pin.py
|
|
pins, downloaded from NVIDIA's DLSS SDK repository and checked, or --dlss; not with
|
|
--no-dlss),
|
|
bin/dlssg/ with --dlssg (DLSS Frame Generation: nx2navi_dlssg.dll, Streamline's
|
|
runtime DLLs and nvngx_dlssg.dll at the version tools/streamline_pin.py pins,
|
|
downloaded from NVIDIA's Streamline SDK release and checked, or --streamline; their
|
|
licences and a notice),
|
|
lib/libnx2navirt.a (the runtime, prebuilt with the release's compiler),
|
|
data/pgo-v111.profdata and .json with --pgo (the execution profile make_game.py
|
|
compiles the lifted code and the runtime with, the prebuilt runtime included, and
|
|
its record: tools/pgo.py merge),
|
|
prompt-art/ with --prompt-art (the button art make_game.py draws the Xbox,
|
|
PlayStation, Steam Deck and keyboard and mouse prompts from, tools/prompt_art.py art_files),
|
|
toolchain.lock.json (the MSYS2 packages of today, pinned), feed.json, VERSION, the
|
|
licence files
|
|
NX2navi-Setup-<version>.exe launcher/native/setup.c with the folder above appended as a zip: the one file
|
|
players download
|
|
NX2navi-app-<version>.zip app/<version> alone: what the launcher downloads to update itself
|
|
NX2navi-runtime-<id>.zip python/ alone: downloaded only when a release changes the Python
|
|
NX2navi-update.json the manifest the launcher reads (launcher/feed.py)
|
|
|
|
--prompt-art names the button art: folders that hold XGamepad, P5Gamepad and Keyboard_Mouse, or those three folders
|
|
themselves. Only the pictures tools/pad_prompts.py reads are copied; the art stays out of the repository. Without it
|
|
the release has no prompt-art folder and builds the game with the game's own prompts only.
|
|
|
|
--pgo names the execution profile of a play session (docs/building.md, "Profile-guided build"). It is derived from
|
|
the game's code and stays out of the repository; its record must name this checkout's recomp/, or the release stops
|
|
(tools/pgo.py fitting). Without it the release builds the game as before, the lifted code at -O1. The profile is
|
|
part of NX2navi-app-<version>.zip, so an update brings it and the launcher's next build of the game compiles with it.
|
|
|
|
--publish uploads the four files as GitHub release v<version> of the --feed repository with the gh CLI (a draft
|
|
first is wiser: add --draft). check_content runs over everything before it is packed: the release holds no game data.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
sys.path.insert(0, str(ROOT / 'tools'))
|
|
import build_shaders # noqa: E402
|
|
import check_content # noqa: E402
|
|
import dlss_pin # noqa: E402
|
|
import pgo # noqa: E402
|
|
import prompt_art # noqa: E402
|
|
import streamline_pin # noqa: E402
|
|
from launcher import toolchain # noqa: E402
|
|
from launcher.paths import Layout # noqa: E402
|
|
|
|
MAIN_ROOT = (ROOT / 'build').resolve().parent # build/ and toolchain/ are junctions in a worktree
|
|
DL = MAIN_ROOT / 'toolchain' / 'launcher-dl'
|
|
WORK = MAIN_ROOT / 'toolchain' / 'launcher-work'
|
|
PY_VERSION = '3.14.9'
|
|
PINS = {
|
|
'python-embed': (f'https://www.python.org/ftp/python/{PY_VERSION}/python-{PY_VERSION}-embed-amd64.zip',
|
|
'3c01d7cfc666f746a6f064f60fb2156ab0ec464d3d738ab796371a46a9bb24a2'),
|
|
# the installer's own Tcl/Tk and tkinter component, published beside it
|
|
'tcltk': (f'https://www.python.org/ftp/python/{PY_VERSION}/amd64/tcltk.msi',
|
|
'ac87609db3f715404dd41a989c2f1706ea1512f3d86217f938d11c63af8eb505'),
|
|
}
|
|
TOOLS = ('make_game.py', 'nup.py', 'romfs_index.py', 'check_content.py', 'hostmem.py', 'prepare_shaders.py',
|
|
'shader_census.py', 'shader_list.py', 'pipeline_list.py', 'zstd_compat.py', 'pad_prompts.py', 'prompt_art.py', 'button_icons.py', 'romfs_check.py',
|
|
'pack_shader_cache.py', 'mod_files.py', 'cheats.py', 'exefs_patches.py')
|
|
TOP = ('LICENSE', 'LICENSE-ADDITIONS', 'THIRD_PARTY_NOTICES.md', 'nx2navi.ini.example')
|
|
SKIP_DIRS = {'__pycache__', '.pytest_cache', 'native'}
|
|
SKIP_SUFFIXES = {'.o', '.obj', '.pyc', '.exe', '.dll', '.a', '.lib'}
|
|
TCLTK_FILES = ('_tkinter.pyd', 'tcl90.dll', 'tcl9tk90.dll', 'zlib1.dll', 'libtommath.dll')
|
|
DEFAULT_FEED = 'tangerineturtle1/NX2navi'
|
|
|
|
|
|
def say(msg):
|
|
print(msg, flush=True)
|
|
|
|
|
|
def sha256_file(path):
|
|
h = hashlib.sha256()
|
|
with open(path, 'rb') as f:
|
|
while b := f.read(1 << 20):
|
|
h.update(b)
|
|
return h.hexdigest()
|
|
|
|
|
|
def fetch(name):
|
|
url, want = PINS[name]
|
|
dest = DL / url.rsplit('/', 1)[1]
|
|
if dest.is_file() and sha256_file(dest) == want:
|
|
return dest
|
|
from launcher import net
|
|
say(f'download {url}')
|
|
net.download(url, dest, sha256=want)
|
|
return dest
|
|
|
|
|
|
def run(cmd, **kw):
|
|
say(' ' + ' '.join(str(c) for c in cmd))
|
|
subprocess.run([str(c) for c in cmd], check=True, **kw)
|
|
|
|
|
|
def copy_tree(src, dst, skip_dirs=SKIP_DIRS):
|
|
for p in sorted(src.rglob('*')):
|
|
rel = p.relative_to(src)
|
|
if any(part in skip_dirs for part in rel.parts) or p.is_dir() or p.suffix.lower() in SKIP_SUFFIXES:
|
|
continue
|
|
(dst / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(p, dst / rel)
|
|
|
|
|
|
def version():
|
|
v = (ROOT / 'VERSION').read_text(encoding='utf-8').strip()
|
|
r = subprocess.run(['git', '-C', str(ROOT), 'rev-parse', '--short', 'HEAD'], capture_output=True, text=True)
|
|
return v, f'{v}+{r.stdout.strip()}' if r.returncode == 0 and r.stdout.strip() else v
|
|
|
|
|
|
# ---------------------------------------------------------------- python/
|
|
|
|
def tcltk_files():
|
|
"""The Tcl/Tk files of the pinned msi, unpacked into WORK/tcltk once. The stamp names the msi they came from and
|
|
is written last: an unpack from an older pin, or one stopped after _tkinter.pyd was written, is done again."""
|
|
dest = WORK / 'tcltk'
|
|
msi = fetch('tcltk')
|
|
stamp, want = dest / '.unpacked-from', f'{msi.name} {msi.stat().st_size}'
|
|
if (dest / 'DLLs' / '_tkinter.pyd').is_file() and stamp.is_file() and stamp.read_text() == want:
|
|
return dest
|
|
shutil.rmtree(dest, ignore_errors=True)
|
|
dest.mkdir(parents=True)
|
|
if os.name == 'nt': # an administrative image: copies the files, installs nothing
|
|
run(['msiexec', '/a', msi, '/qn', f'TARGETDIR={dest}'])
|
|
else:
|
|
run(['msiextract', '-C', dest, msi], stdout=subprocess.DEVNULL)
|
|
if not (dest / 'DLLs' / '_tkinter.pyd').is_file():
|
|
sys.exit(f'{dest}: no _tkinter.pyd in {msi.name}')
|
|
stamp.write_text(want)
|
|
return dest
|
|
|
|
|
|
def runtime_id():
|
|
req = (ROOT / 'tools' / 'requirements-launcher.txt').read_bytes()
|
|
return f'py{PY_VERSION}-{hashlib.sha256(req + PINS["tcltk"][1].encode()).hexdigest()[:8]}'
|
|
|
|
|
|
def build_python(py):
|
|
with zipfile.ZipFile(fetch('python-embed')) as z:
|
|
z.extractall(py)
|
|
# The embeddable Python's sys.path is exactly this list; ../pydeps holds zbic (launcher/toolchain.py).
|
|
(py / 'python314._pth').write_text('python314.zip\n.\nLib/site-packages\n../pydeps\n', encoding='utf-8')
|
|
env = dict(os.environ, UV_CACHE_DIR=str(MAIN_ROOT / 'toolchain' / 'uv-cache'))
|
|
run(['uv', 'pip', 'install', '--target', py / 'Lib' / 'site-packages', '--python', sys.executable,
|
|
'--python-platform', 'windows', '--python-version', '3.14', '--only-binary', ':all:', '-q',
|
|
'-r', ROOT / 'tools' / 'requirements-launcher.txt'], env=env)
|
|
shutil.rmtree(py / 'Lib' / 'site-packages' / 'bin', ignore_errors=True)
|
|
tk = tcltk_files()
|
|
for dll in TCLTK_FILES:
|
|
if (tk / 'DLLs' / dll).is_file():
|
|
shutil.copy2(tk / 'DLLs' / dll, py / dll)
|
|
copy_tree(tk / 'Lib' / 'tkinter', py / 'tkinter', skip_dirs={'__pycache__', 'test'})
|
|
copy_tree(tk / 'tcl', py / 'tcl', skip_dirs={'__pycache__'})
|
|
(py / 'runtime-id.txt').write_text(runtime_id() + '\n', encoding='utf-8')
|
|
|
|
|
|
# ---------------------------------------------------------------- app/<version>/
|
|
|
|
def check_xlate(xlate):
|
|
"""A release carries the translator of its own sources. 0.10.0 and 0.10.1 shipped the 0.9.2 translator (identity
|
|
4d051299) after tools/xlate/Program.cs had raised the texture pool to 40960 slots, so their translations still
|
|
clamped every pool handle to slot 32767 and the ground went see-through after some area loads (issues #569 and
|
|
#824). A missing translator, or one whose nx2navi_xlate.id differs from this checkout's sources
|
|
(build_xlate.check_built), stops the build; main runs this before anything is resolved, downloaded or deleted.
|
|
On Windows the exe itself is asked too, as the .id says nothing of the exe beside it: a translator that declares
|
|
the texture pool with another length than its sources, or does not run, stops the build (xlate_smoke.pool_check)."""
|
|
import build_xlate
|
|
reason = build_xlate.check_built(xlate, 'nx2navi_xlate.exe')
|
|
if reason:
|
|
sys.exit(reason)
|
|
if os.name == 'nt':
|
|
import xlate_smoke
|
|
reason = xlate_smoke.pool_check(xlate / 'nx2navi_xlate.exe')
|
|
if reason:
|
|
sys.exit(f'{xlate / "nx2navi_xlate.exe"}: {reason}')
|
|
have = (xlate / 'nx2navi_xlate.id').read_text(encoding='ascii').strip()
|
|
print(f'translator identity {have[:16]}: built from this checkout\'s sources')
|
|
|
|
|
|
def build_app(app, ver, xlate, feed_repo, lock, ffx=None, dlss=None, art=None, dlssg=None, pipelines=None, ffx4=None,
|
|
pgo_profile=None):
|
|
# The runtime's own shaders are source in the repository and SPIR-V headers in a release: a player's PC compiles
|
|
# runtime/ (make_game.py) and has no shader compiler. Build the missing and the stale ones before the copy.
|
|
build_shaders.build(ROOT / 'runtime')
|
|
copy_tree(ROOT / 'launcher', app / 'launcher')
|
|
for d in ('runtime', 'recomp'):
|
|
copy_tree(ROOT / d, app / d)
|
|
(app / 'tools').mkdir(parents=True)
|
|
for t in TOOLS:
|
|
shutil.copy2(ROOT / 'tools' / t, app / 'tools' / t)
|
|
for t in TOP:
|
|
if (ROOT / t).is_file():
|
|
shutil.copy2(ROOT / t, app / t)
|
|
(app / 'VERSION').write_text(ver + '\n', encoding='utf-8')
|
|
(app / 'bin').mkdir()
|
|
for n in ('nx2navi_xlate.exe', 'nx2navi_xlate.id'): # main checked them first (check_xlate)
|
|
shutil.copy2(xlate / n, app / 'bin' / n)
|
|
if ffx: # FSR 3.1 loads it from beside the game (runtime/vk_ffx.cpp); make_game.py copies it there
|
|
shutil.copy2(ffx, app / 'bin' / 'amd_fidelityfx_vk.dll')
|
|
if ffx4: # FSR 4 loads them from beside the game (runtime/vk_ffx4.cpp); make_game.py copies them there
|
|
for n in ('amd_fidelityfx_loader_vk.dll', 'amd_fidelityfx_upscaler_vk.dll'):
|
|
shutil.copy2(ffx4 / n, app / 'bin' / n)
|
|
if (ffx4 / 'amd_fidelityfx_framegeneration_vk.dll').is_file(): # its frame generation (runtime/vk_fg4.cpp)
|
|
shutil.copy2(ffx4 / 'amd_fidelityfx_framegeneration_vk.dll', app / 'bin' / 'amd_fidelityfx_framegeneration_vk.dll')
|
|
if dlss: # DLSS: NGX loads it from beside the game (runtime/vk_dlaa.c); make_game.py copies it there
|
|
dlss_pin.place(dlss, app / 'bin')
|
|
if dlssg: # DLSS Frame Generation (runtime/vk_dlssg.c): make_game.py place_dlssg copies it beside the game
|
|
streamline_pin.place(dlssg[0], dlssg[1], app / 'bin' / 'dlssg')
|
|
if art: # make_game.py draws the button prompts from it (its prompts step)
|
|
n, size = prompt_art.copy_art(art, app / prompt_art.FOLDER)
|
|
say(f'button art: {n} pictures, {size / 1e6:.1f} MB')
|
|
if pipelines: # prepare_shaders.py reads it there (BUNDLED_PIPELINES)
|
|
(app / 'tools' / 'data').mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(pipelines, app / 'tools' / 'data' / 'pipeline_list.bin')
|
|
say(f'pipeline list: {pipelines.stat().st_size / 1e6:.1f} MB')
|
|
if pgo_profile: # make_game.py compiles with it (pgo_file); here before the prebuilt runtime is built with it
|
|
placed = pgo.place(pgo_profile, app / 'data')
|
|
say(f'execution profile: {placed.stat().st_size / 1e6:.1f} MB')
|
|
else:
|
|
say('execution profile: none (--pgo): the game\'s code is built without one, at -O1')
|
|
(app / 'toolchain.lock.json').write_text(json.dumps(lock, indent=1), encoding='utf-8')
|
|
(app / 'feed.json').write_text(json.dumps({'type': 'github', 'repo': feed_repo}, indent=1), encoding='utf-8')
|
|
|
|
|
|
# ---------------------------------------------------------------- the native programs
|
|
|
|
def native_compiler(lock):
|
|
"""(cc argv prefix, windres argv prefix) for x86_64 Windows: MSYS2's own clang on Windows, else the host's clang
|
|
against the same MSYS2 packages as a sysroot."""
|
|
lay = Layout(root=MAIN_ROOT / 'toolchain' / 'launcher-msys')
|
|
if not toolchain.is_current(lay, lock):
|
|
say('MSYS2 packages for the native programs')
|
|
toolchain.install(lay, lock, log=say)
|
|
sysroot = lay.msys
|
|
if os.name == 'nt':
|
|
return [str(sysroot / 'bin' / 'clang.exe')], [str(sysroot / 'bin' / 'windres.exe')]
|
|
for tool in ('clang', 'ld.lld'):
|
|
if not shutil.which(tool):
|
|
sys.exit(f'{tool} is needed to cross-compile the launcher programs')
|
|
windres = shutil.which('llvm-windres') or shutil.which('llvm-windres-18') or '/usr/lib/llvm-18/bin/llvm-windres'
|
|
if not Path(windres).exists():
|
|
sys.exit('llvm-windres is needed to compile the launcher programs\' resources')
|
|
return (['clang', '--target=x86_64-w64-mingw32', f'--sysroot={sysroot}', '-fuse-ld=lld'],
|
|
[windres, '--target=x86_64-w64-mingw32'])
|
|
|
|
|
|
def build_runtime_lib(folder, ver, out, wine):
|
|
"""app/<version>/lib/libnx2navirt.a: the runtime compiled once here with the release's MSYS2 clang, so players
|
|
compile only the few runtime files that include per-install headers (tools/make_game.py --make-runtime-lib). It
|
|
needs MSYS2's own clang.exe: native on Windows, through Wine with --wine elsewhere; without either players compile
|
|
runtime/ themselves."""
|
|
msys = Layout(root=MAIN_ROOT / 'toolchain' / 'launcher-msys').msys
|
|
app = folder / 'app' / ver
|
|
args = ['--make-runtime-lib', app / 'lib', '--obj', out / 'native' / 'runtime-obj', '--msys', msys]
|
|
if os.name == 'nt':
|
|
run([sys.executable, app / 'tools' / 'make_game.py', *args])
|
|
elif wine:
|
|
def win(p):
|
|
return 'Z:' + str(Path(p).resolve()).replace('/', '\\')
|
|
env = dict(os.environ, WINEDEBUG='-all', NX2NAVI_AR=win(msys / 'bin' / 'ar.exe'))
|
|
run(['wine', folder / 'python' / 'python.exe', win(app / 'tools' / 'make_game.py'),
|
|
*[win(a) if isinstance(a, Path) else a for a in args]], env=env)
|
|
else:
|
|
say('note: no prebuilt runtime (it is built on Windows, or with --wine): players compile runtime/ themselves')
|
|
return
|
|
if not (app / 'lib' / 'libnx2navirt.a').is_file():
|
|
sys.exit('the prebuilt runtime was not written')
|
|
# The release's compiler is the one players build with (toolchain.lock.json): a profile it left out of the
|
|
# runtime (make_game.py pgo_profile says why in the lines above) their builds would leave out too
|
|
built = json.loads((app / 'lib' / 'runtime-lib.json').read_text(encoding='utf-8'))
|
|
if (app / 'data' / 'pgo-v111.profdata').is_file() and not built.get('pgo'):
|
|
sys.exit('the prebuilt runtime was compiled without the release\'s execution profile (the build above says '
|
|
'why): the players\' builds would leave the profile out as well')
|
|
|
|
|
|
def build_native(work, cc, windres):
|
|
from launcher.ui import art
|
|
work.mkdir(parents=True, exist_ok=True)
|
|
(work / 'nx2navi.ico').write_bytes(art.navi_ico())
|
|
shutil.copy2(ROOT / 'launcher' / 'native' / 'app.manifest', work / 'app.manifest')
|
|
shutil.copy2(ROOT / 'launcher' / 'native' / 'res.rc', work / 'res.rc')
|
|
res = (work / 'res.o').resolve()
|
|
# MSYS2's GNU windres runs its preprocessor through cmd.exe and, left to find it, names the gcc beside it by full
|
|
# path, which cmd.exe splits at a space once the .rc's path holds one too. So gcc by bare name, found on PATH (the
|
|
# toolchain's own bin first; no MSYS2 shell sets it), and the .rc by its name in the work folder, where windres runs.
|
|
# llvm-windres (Linux) starts its preprocessor without a shell.
|
|
pp = [] if 'llvm' in Path(windres[0]).name else [
|
|
'--preprocessor=gcc', '--preprocessor-arg=-E', '--preprocessor-arg=-xc', '--preprocessor-arg=-DRC_INVOKED']
|
|
env = dict(os.environ, PATH=str(Path(windres[0]).parent) + os.pathsep + os.environ.get('PATH', ''))
|
|
run([*windres, *pp, '-I', '.', 'res.rc', '-O', 'coff', '-o', res], env=env, cwd=work)
|
|
flags = ['-municode', '-mwindows', '-O2', '-Wall', '-static', '-s']
|
|
stub = work / 'NX2navi.exe'
|
|
setup = work / 'setup-stub.exe'
|
|
run([*cc, *flags, ROOT / 'launcher' / 'native' / 'nx2navi.c', res, '-o', stub, '-lshell32', '-lole32', '-luuid'])
|
|
run([*cc, *flags, ROOT / 'launcher' / 'native' / 'setup.c', res, '-o', setup, '-l:libz.a', '-lshell32',
|
|
'-lole32', '-luuid', '-ladvapi32'])
|
|
return stub, setup
|
|
|
|
|
|
# ---------------------------------------------------------------- packing
|
|
|
|
def zip_dir(src, dest, prefix=''):
|
|
with zipfile.ZipFile(dest, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as z:
|
|
for p in sorted(src.rglob('*')):
|
|
if p.is_file():
|
|
z.write(p, (Path(prefix) / p.relative_to(src)).as_posix())
|
|
return dest
|
|
|
|
|
|
def make_setup(stub, payload, dest, ver):
|
|
"""setup-stub.exe + the payload zip + the 48-byte trailer launcher/native/setup.c reads."""
|
|
data = stub.read_bytes()
|
|
off = len(data)
|
|
z = payload.read_bytes()
|
|
trailer = b'NX2NSET1' + off.to_bytes(8, 'little') + len(z).to_bytes(8, 'little') + \
|
|
ver.encode('utf-8')[:24].ljust(24, b'\0')
|
|
dest.write_bytes(data + z + trailer)
|
|
return dest
|
|
|
|
|
|
def check_folder(folder):
|
|
bad = [f'{p.relative_to(folder)}: {m}' for p in sorted(folder.rglob('*')) if p.is_file()
|
|
for m in check_content.problems_for(p, allow_large=True)]
|
|
if bad:
|
|
sys.exit('check_content refuses these release files:\n ' + '\n '.join(bad[:20]))
|
|
|
|
|
|
def entry(path):
|
|
return {'name': path.name, 'size': path.stat().st_size, 'sha256': sha256_file(path)}
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument('--xlate', type=Path, required=True, help='folder with nx2navi_xlate.exe and nx2navi_xlate.id')
|
|
ap.add_argument('--ffx', type=Path, help="AMD's signed amd_fidelityfx_vk.dll (FidelityFX SDK 1.1.4, MIT) for FSR 3.1")
|
|
ap.add_argument('--ffx4', type=Path, help="the folder with AMD's signed amd_fidelityfx_loader_vk.dll and "
|
|
'amd_fidelityfx_upscaler_vk.dll (FSR SDK 2.4.0, MIT) for FSR 4, and its '
|
|
'amd_fidelityfx_framegeneration_vk.dll when it is there')
|
|
ap.add_argument('--dlss', type=Path, help=f"NVIDIA's nvngx_dlss.dll (DLSS {dlss_pin.VERSION}) from disk instead of "
|
|
"downloading it from NVIDIA's DLSS SDK repository")
|
|
ap.add_argument('--no-dlss', action='store_true', help='leave NVIDIA DLSS out of the release')
|
|
ap.add_argument('--dlssg', type=Path, metavar='DLL',
|
|
help='nx2navi_dlssg.dll (tools/build_dlssg.py) for DLSS Frame Generation')
|
|
ap.add_argument('--streamline', type=Path, metavar='SDK',
|
|
help=f'the Streamline {streamline_pin.VERSION} SDK folder whose bin/x64 gives the Streamline and '
|
|
'nvngx_dlssg.dll files, instead of downloading the SDK release with --dlssg')
|
|
ap.add_argument('--prompt-art', type=Path, nargs='+', metavar='FOLDER',
|
|
help='the button art: folders holding XGamepad, P5Gamepad and Keyboard_Mouse, or those folders')
|
|
ap.add_argument('--pipeline-list', type=Path, metavar='FILE',
|
|
help='the shared pipeline list to carry (tools/pipeline_list.py merge writes it; it is derived from game data and stays out of the repository)')
|
|
ap.add_argument('--pgo', type=Path, metavar='FILE',
|
|
help='the execution profile to carry, with its record beside it (tools/pgo.py merge writes '
|
|
'build/pgo/pgo-v111.profdata and .json; derived from the game\'s code, they stay out of the '
|
|
'repository)')
|
|
ap.add_argument('--out', type=Path, default=MAIN_ROOT / 'build' / 'launcher-release')
|
|
ap.add_argument('--feed', default=DEFAULT_FEED, help='the GitHub repository the launcher looks for releases in')
|
|
ap.add_argument('--notes', type=Path, help='release notes (markdown) for the manifest and the GitHub release')
|
|
ap.add_argument('--lock', type=Path, help='a toolchain.lock.json to reuse instead of resolving today\'s packages')
|
|
ap.add_argument('--version', help='the release version instead of VERSION\'s (test releases)')
|
|
ap.add_argument('--wine', action='store_true', help='on Linux: build the prebuilt runtime with MSYS2\'s clang '
|
|
'through Wine')
|
|
ap.add_argument('--publish', action='store_true', help='upload as GitHub release v<version> with gh')
|
|
ap.add_argument('--draft', action='store_true', help='with --publish: as a draft')
|
|
a = ap.parse_args()
|
|
if sys.version_info < (3, 14):
|
|
sys.exit('Python 3.14 or later is needed (compression.zstd for the MSYS2 packages)')
|
|
ver, build = version()
|
|
if a.version:
|
|
ver, build = a.version, a.version + build[len(ver):]
|
|
if a.prompt_art:
|
|
prompt_art.art_files(a.prompt_art) # the art is all there before anything is downloaded or built
|
|
if a.pgo:
|
|
pgo.fitting(a.pgo) # the profile fits this checkout's recomp/ before anything is downloaded or built
|
|
check_xlate(a.xlate) # the translator is this checkout's before anything is downloaded, built or deleted
|
|
out = a.out
|
|
shutil.rmtree(out, ignore_errors=True)
|
|
folder = out / 'NX2navi'
|
|
t0 = time.time()
|
|
lock = (json.loads(a.lock.read_text(encoding='utf-8')) if a.lock
|
|
else toolchain.make_lock(fast_repo=toolchain.FAST_REPO))
|
|
say(f'release {build}: toolchain lock with {len(lock["packages"])} packages'
|
|
+ (f' (CLANG64 clang left out: {lock["fast_skipped"]})' if lock.get('fast_skipped') else ''))
|
|
dlss = None if a.no_dlss else dlss_pin.resolve(a.dlss, DL)
|
|
say(f'NVIDIA DLSS {dlss_pin.VERSION}: {dlss}' if dlss else 'NVIDIA DLSS left out (--no-dlss)')
|
|
if a.streamline and not a.dlssg:
|
|
sys.exit('--streamline goes with --dlssg')
|
|
dlssg = (a.dlssg, streamline_pin.resolve(a.streamline, DL)) if a.dlssg else None
|
|
say(f'DLSS Frame Generation: {a.dlssg}, Streamline {streamline_pin.VERSION}: {dlssg[1]}' if dlssg
|
|
else 'DLSS Frame Generation left out (no --dlssg)')
|
|
if a.pipeline_list:
|
|
import pipeline_list
|
|
if pipeline_list.unpack(a.pipeline_list.read_bytes()) is None:
|
|
sys.exit(f'{a.pipeline_list} is not a pipeline list (tools/pipeline_list.py merge writes one)')
|
|
build_app(folder / 'app' / ver, ver, a.xlate, a.feed, lock, a.ffx, dlss, a.prompt_art, dlssg, a.pipeline_list, a.ffx4,
|
|
a.pgo)
|
|
(folder / 'app' / 'current.txt').write_text(ver + '\n', encoding='utf-8')
|
|
build_python(folder / 'python')
|
|
cc, windres = native_compiler(lock)
|
|
build_runtime_lib(folder, ver, out, a.wine)
|
|
stub, setup_stub = build_native(out / 'native', cc, windres)
|
|
shutil.copy2(stub, folder / 'NX2navi.exe')
|
|
check_folder(folder)
|
|
app_zip = zip_dir(folder / 'app' / ver, out / f'NX2navi-app-{ver}.zip')
|
|
rt_zip = zip_dir(folder / 'python', out / f'NX2navi-runtime-{runtime_id()}.zip')
|
|
payload = zip_dir(folder, out / 'native' / 'payload.zip')
|
|
setup = make_setup(setup_stub, payload, out / f'NX2navi-Setup-{ver}.exe', ver)
|
|
notes = a.notes.read_text(encoding='utf-8') if a.notes else ''
|
|
manifest = {'format': 1, 'version': ver, 'build': build, 'notes': notes,
|
|
'app': entry(app_zip), 'runtime': {'id': runtime_id(), **entry(rt_zip)},
|
|
'toolchain': toolchain.lock_digest(lock), 'setup': entry(setup),
|
|
'made': time.strftime('%Y-%m-%d %H:%M')}
|
|
man = out / 'NX2navi-update.json'
|
|
man.write_text(json.dumps(manifest, indent=1), encoding='utf-8')
|
|
for p in (setup, app_zip, rt_zip, man):
|
|
say(f'{p.name}: {p.stat().st_size / 1e6:.1f} MB')
|
|
say(f'release folder {out} in {time.time() - t0:.0f} s')
|
|
if a.publish:
|
|
cmd = ['gh', 'release', 'create', f'v{ver}', setup, app_zip, rt_zip, man, '--repo', a.feed,
|
|
'--title', f'NX2navi {ver}']
|
|
cmd += ['--notes-file', a.notes] if a.notes else ['--notes', f'NX2navi {ver}']
|
|
if a.draft:
|
|
cmd.append('--draft')
|
|
run(cmd)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|