From dfc5b90b59c0885d52737832dc2ddfeeb4e955fd Mon Sep 17 00:00:00 2001 From: chrg6 Date: Mon, 9 Nov 2026 09:10:36 -0500 Subject: [PATCH] Fix what the 0.6.0 review found, and ship DLSS Frame Generation in the launcher release. The commit census of the perf trace walked the address space on the presenting thread outside mem_census, which brought back the hitch mem_census was added to remove; it now runs only with mem_census = 1. DLSS's output pass at the mode's share fell back to no pass at all when the scene was not at that share (the first presents, a dynamic resolution step): after such a frame the first pass wider than the scene is taken again for 120 frames. The launcher's nx2navi.ini editor no longer rewrites a file it cannot read, keeps the byte order mark and bytes that are not UTF-8, reads an empty line as the game does, and leaves no temporary file behind. The build kit carries the two modules build_game.py imports on Linux, the Linux steps list the right packages and Python, and stale comments and notes are corrected (Discord on by default, two prebuild threads, Enhanced HDR gone). DLSS Frame Generation needs nx2navi_dlssg.dll, NVIDIA Streamline's runtime and nvngx_dlssg.dll beside the game, which no release carried, so the menu offered it and the game turned it off. tools/make_launcher.py --dlssg and --streamline put them with their licences and a notice in the release's bin/dlssg/, and tools/make_game.py place_dlssg copies the whole set beside the game. THIRD_PARTY_NOTICES.md has the row; the notes and README say RTX 40 and 50. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- THIRD_PARTY_NOTICES.md | 1 + docs/README-BuildKit.md | 7 +++-- docs/releases/v0.6.0.md | 6 ++-- launcher/gameini.py | 56 +++++++++++++++++++++++-------------- launcher/ui/app.py | 12 ++++++-- nx2navi.ini.example | 7 +++-- runtime/discord_rpc.c | 11 ++++---- runtime/perf.c | 8 ++++-- runtime/vk_dlaa.c | 8 +++++- runtime/vk_pipeline.c | 2 +- tests/launcher_test.py | 50 +++++++++++++++++++++++++++++---- tools/build_dlssg.py | 3 +- tools/make_game.py | 21 ++++++++++++++ tools/make_kit.py | 3 +- tools/make_launcher.py | 61 +++++++++++++++++++++++++++++++++++++++-- 16 files changed, 207 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 83f1434c..04a6ffe7 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Report problems as issues, following [CONTRIBUTING.md](CONTRIBUTING.md). - Upscaling and anti-aliasing: NVIDIA DLSS (DLAA, Quality, Balanced, Performance, Ultra Performance; RTX GPUs) or AMD FSR 3.1 (Native AA and the same modes; any GPU), which render the 3D scene at the mode's share and keep the HUD sharp; or AMD FSR 1 or bilinear scaling to the window, with a sharpness setting; black bars or stretch -- Frame generation: NVIDIA DLSS Frame Generation (RTX GPUs, paced with Reflex) or AMD FSR 3.1 (any GPU), a generated +- Frame generation: NVIDIA DLSS Frame Generation (RTX 40 and 50 GPUs, paced with Reflex) or AMD FSR 3.1 (any GPU), a generated frame between every two the game draws, with the HUD kept out of it, for about twice the frame rate on a fast display; in a window of another shape than the picture, frames are generated inside the picture only, so the black bars stay clean. The frame limit can also be handed to RivaTuner Statistics Server diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9b7be8a9..7febcb82 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -123,6 +123,7 @@ folder's `licenses/` and in each component's own folder. | zlib 1.4 (MSYS2 `mingw-w64-ucrt-x86_64-zlib`), static library | https://zlib.net, Copyright (C) Jean-loup Gailly and Mark Adler | zlib | linked into `NX2navi-Setup.exe` (its unzip) | | mingw-w64 CRT startup code and winpthreads 14.0.0, GCC 16.2.0's `libgcc` (MSYS2 packages), static | https://www.mingw-w64.org, https://gcc.gnu.org | mingw-w64: ZPL-2.1 / MIT / public domain; libgcc: GPL-3.0 with the GCC Runtime Library Exception | linked into `NX2navi.exe` and `NX2navi-Setup.exe` | | NVIDIA DLSS 310.9.1 (`nvngx_dlss.dll`, NVIDIA's own build, unmodified; pinned with its size and sha256 in `tools/dlss_pin.py`) | https://github.com/NVIDIA/DLSS @ `v310.9.1` (`lib/Windows_x86_64/rel/nvngx_dlss.dll`), Copyright NVIDIA Corporation | NVIDIA RTX SDKs License (https://github.com/NVIDIA/DLSS/blob/v310.9.1/LICENSE.txt); licensed for systems with NVIDIA GPUs only | `app//bin/` with `nvngx_dlss-NOTICE.txt`, also in the build kit's `bin/`; `tools/make_game.py` copies both beside the built game, where NGX loads the library (`runtime/vk_dlaa.c`) | +| NVIDIA Streamline 2.14.16 runtime (`sl.interposer.dll`, `sl.common.dll`, `sl.dlss_g.dll`, `sl.pcl.dll`, `sl.reflex.dll`, NVIDIA's own builds, unmodified) and NVIDIA DLSS Frame Generation 310.9.16 (`nvngx_dlssg.dll`, as the Streamline SDK ships it) | https://github.com/NVIDIA-RTX/Streamline, SDK release 2.14.16 (`bin/x64/`), Copyright NVIDIA Corporation | Streamline: MIT; `nvngx_dlssg.dll`: NVIDIA RTX SDKs License, licensed for systems with NVIDIA GPUs only | `app//bin/dlssg/` with both licence texts and `dlssg-NOTICE.txt` (each file's sha256), when the release is made with `tools/make_launcher.py --dlssg --streamline`; `tools/make_game.py` copies them beside the built game, where `nx2navi_dlssg.dll` (this repository's `runtime/dlssg/`, built with the Streamline SDK's MIT headers) loads them (`runtime/vk_dlssg.c`) | Downloaded on the player's PC by the launcher, not distributed: the MSYS2 UCRT64 packages that `toolchain.lock.json` pins (clang and LLVM, Apache-2.0 with LLVM exception; GCC and binutils, GPL-3.0; the diff --git a/docs/README-BuildKit.md b/docs/README-BuildKit.md index 7e4b8f50..f8096007 100644 --- a/docs/README-BuildKit.md +++ b/docs/README-BuildKit.md @@ -70,11 +70,12 @@ RADV). It takes clang, clang++ and lld from `PATH` and links against the system' ```sh sudo pacman -S --needed clang lld sdl3 opus libvpx dav1d zstd vulkan-headers vulkan-icd-loader \ - python fonttools python-numpy python-lz4 python-zstandard dotnet-sdk + python python-fonttools python-pillow python-numpy python-lz4 python-zstandard dotnet-sdk ``` - Python 3.14 or later (the RomFS step and the PlayStation prompts use its `compression.zstd`), then `zbic`, which the - kit only calls (GPL-2.0): `pip install --user zbic`, or in a virtual environment with the packages above. + Python 3.10 or later (with 3.14 the RomFS step uses its own `compression.zstd`, else the `zstandard` package; the + button prompts need `fonttools` and `pillow` when the kit carries button art), then `zbic`, which the kit only calls + (GPL-2.0): `pip install --user zbic`, or in a virtual environment with the packages above. Vulkan Memory Allocator is one header: the system's (AUR `vulkan-memory-allocator`), else the build fetches the pinned 3.4.0 into `toolchain/vma/` and checks its hash. diff --git a/docs/releases/v0.6.0.md b/docs/releases/v0.6.0.md index f71cb412..f4f076cf 100644 --- a/docs/releases/v0.6.0.md +++ b/docs/releases/v0.6.0.md @@ -31,9 +31,9 @@ cards). off in the game's settings menu (Game tab) or in the launcher's Settings (Discord Status). - DualShock 3 controllers on Windows, read directly when no driver presents them (off by default: `ds3 = 1`). - A 1080p game resolution. -- NVIDIA DLSS Frame Generation is listed in the menu on NVIDIA GPUs, and is paced with Reflex so it no longer halves - the frame rate. -- DLAA runs with native HDR too. Enhanced HDR's highlights keep climbing instead of ending near white. +- NVIDIA DLSS Frame Generation on GeForce RTX 40 and 50 GPUs, with DLSS on: the setup now carries what it needs, and + it is paced with Reflex so it does not halve the frame rate. +- DLAA runs with native HDR too. - The frame limit can be handed to RivaTuner Statistics Server where it runs. - The launcher shows the shader preparation moving, with a time estimate that follows its pace, and preparation translates about twice as fast. Every one of the game's 42,177 shader programs now translates. diff --git a/launcher/gameini.py b/launcher/gameini.py index 11a3e7cb..912fab2f 100644 --- a/launcher/gameini.py +++ b/launcher/gameini.py @@ -2,14 +2,18 @@ Read the way runtime/cfg.c reads the file: a UTF-8 byte order mark is skipped, lines starting with #, ; or [ are not settings, a key is matched without regard to case and with or without its NX2NAVI_ prefix, a value in double -quotes loses them, an empty value leaves the setting at its default, and the last line for a key wins. Sections do -not matter to the game; a key the file does not have yet goes under its section, as the game writes it. -A change rewrites only the line it touches, through a temporary file, so the rest of the file stays as it was. +quotes loses them, an empty value leaves the setting as it was (its default, or an earlier line's value), and the last +line with a value wins. Sections do not matter to the game; a key the file does not have yet goes under its section, +as the game writes it. +A change rewrites only the line it touches, through a temporary file: the other lines keep their bytes (the byte +order mark, line ends, text that is not UTF-8). A file that exists but cannot be read is never rewritten, as +runtime/cfg.c cfg_save_to does not: rebuilding it from nothing would lose its lines. """ import os from pathlib import Path PREFIX = 'NX2NAVI_' +BOM = '' def _path(game_dir): @@ -21,18 +25,19 @@ def _key(text): return k[len(PREFIX):] if k.startswith(PREFIX) else k -def _lines(game_dir): - try: # newline='': the file's own line ends, which a change keeps - with open(_path(game_dir), encoding='utf-8-sig', errors='replace', newline='') as f: - text = f.read() - except OSError: - return [] - return text.splitlines(keepends=True) +def _text(game_dir): + """The file's text with its own line ends (bytes that are not UTF-8 kept as surrogates), '' when it does not exist. + Any other error is raised.""" + try: + with open(_path(game_dir), encoding='utf-8', errors='surrogateescape', newline='') as f: + return f.read() + except FileNotFoundError: + return '' def _setting(line): """(key, value) of a settings line, or None.""" - s = line.strip() + s = line.lstrip(BOM).strip() if not s or s[0] in '#;[' or '=' not in s: return None k, v = s.split('=', 1) @@ -43,22 +48,29 @@ def _setting(line): def get(game_dir, key): - """The value the file gives key ('' when it gives none: the game's default applies).""" + """The value the file gives key ('' when it gives none: the game's default applies). Raises OSError when the file + exists but cannot be read.""" want, value = _key(key), '' - for line in _lines(game_dir): + for line in _text(game_dir).splitlines(): kv = _setting(line) - if kv and kv[0] == want: + if kv and kv[0] == want and kv[1]: value = kv[1] return value def put(game_dir, section, key, value): - """Write key = value: on the key's last line when the file has one, else under [section] (made at the end when - the file has none). The game folder must exist; the file is made when it does not.""" - lines, want = _lines(game_dir), _key(key) + """Write key = value: on the key's last line that has a value (else its last line) when the file has one, else + under [section] (made at the end when the file has none). Raises OSError, the file unchanged, when it exists but + cannot be read or the new one cannot be put in place.""" + text = _text(game_dir) + bom = BOM if text.startswith(BOM) else '' + lines = text[len(bom):].splitlines(keepends=True) + want = _key(key) entry = f'{key.lower()} = {value}' nl = '\r\n' if lines and lines[0].endswith('\r\n') else '\n' - at = max((i for i, line in enumerate(lines) if (_setting(line) or ('',))[0] == want), default=-1) + mine = [i for i, line in enumerate(lines) if (_setting(line) or ('',))[0] == want] + valued = [i for i in mine if _setting(lines[i])[1]] + at = (valued or mine or [-1])[-1] if at >= 0: end = lines[at][len(lines[at].rstrip('\r\n')):] or nl lines[at] = entry + end @@ -75,5 +87,9 @@ def put(game_dir, section, key, value): lines += [f'[{section}]{nl}', entry + nl] path = _path(game_dir) tmp = path.with_name(path.name + '.tmp') - tmp.write_text(''.join(lines), encoding='utf-8', newline='') - os.replace(tmp, path) + try: + with open(tmp, 'w', encoding='utf-8', errors='surrogateescape', newline='') as f: + f.write(bom + ''.join(lines)) + os.replace(tmp, path) + finally: + tmp.unlink(missing_ok=True) diff --git a/launcher/ui/app.py b/launcher/ui/app.py index 03bccb8d..305d1578 100644 --- a/launcher/ui/app.py +++ b/launcher/ui/app.py @@ -590,14 +590,20 @@ class App: def discord_row(self, game_dir): """The game's discord_rpc setting (runtime/discord_rpc.c), kept in the game's nx2navi.ini as the game's own - menu keeps it; the game reads it when it starts. On unless the file says 0, as the game reads it (cfg_bool, default - on).""" + menu keeps it; the game reads it when it starts. On unless the file says 0, as the game reads it (cfg_bool, + default on).""" def on(v): try: gameini.put(game_dir, 'discord', 'discord_rpc', '1' if v else '0') except OSError as e: self.message = f'The game\'s settings could not be written: {e}' - return Row('toggle', 'Discord Status', on=on, get=lambda: gameini.get(game_dir, 'discord_rpc') != '0', + + def get(): + try: + return gameini.get(game_dir, 'discord_rpc') != '0' + except OSError: # nx2navi.ini there but unreadable: the game's default + return True + return Row('toggle', 'Discord Status', on=on, get=get, desc=('Show "Playing NX2navi" with your play time in your Discord status while the game runs. ' 'The game\'s menu switches it too (Game tab).',)) diff --git a/nx2navi.ini.example b/nx2navi.ini.example index 7ed61917..fed282fe 100644 --- a/nx2navi.ini.example +++ b/nx2navi.ini.example @@ -474,9 +474,10 @@ # Extra lines in the log, comma separated: svc, host, ipc, fs, gfx, perf, sched, audio, present or all. The menu's # Performance log sets perf (frame timings). Default: none. # trace = perf -# 1: with trace = perf, every 600 frames a walk of the process's address space counts its private and write-combined -# memory (the "mem: process" line). The walk holds the address-space lock for 100-240 ms and delays the next frame by -# as much, so it is off unless asked for. +# 1: with trace = perf, walks of the process's address space count its private and write-combined memory every 600 +# frames (the "mem: process" line) and where its committed memory lies every 3600 frames (the "commit census" lines). +# Each walk holds the address-space lock for 100-240 ms and delays the next frame by as much, so they are off unless +# asked for. # mem_census = 0 # Flight recorder: keeps the last frames and saves them to a flight folder next to the folder of nx2navi.exe when a frame # takes longer than flight_spike_ms (0 to 60000). diff --git a/runtime/discord_rpc.c b/runtime/discord_rpc.c index 99dfa093..1a3e31e1 100644 --- a/runtime/discord_rpc.c +++ b/runtime/discord_rpc.c @@ -1,10 +1,11 @@ // Discord Rich Presence: "Playing NX2navi" with the elapsed play time in the player's Discord status, through // the local IPC of a running Discord client (platform.h's plat_ipc_*). Nothing leaves the machine but the activity -// itself, and nothing is sent while NX2NAVI_DISCORD_RPC is off (the default: the status is information about the -// player's session). NX2NAVI_DISCORD_APP_ID names the application whose name ("Playing ") and artwork Discord -// shows, NX2NAVI_DISCORD_IMAGE the key of its uploaded art asset; both default to discord_app.h, the one place the -// NX2navi application's id is written, and nx2navi.ini's discord_app_id and discord_image override them. With no -// application id at all the thread stays idle and logs that once: there is nothing to show the status under. +// itself, and nothing is sent while NX2NAVI_DISCORD_RPC is off (it is on by default, and does nothing without a +// Discord client running on the same PC). NX2NAVI_DISCORD_APP_ID names the application whose name ("Playing ") +// and artwork Discord shows, NX2NAVI_DISCORD_IMAGE the key of its uploaded art asset; both default to discord_app.h, +// the one place the NX2navi application's id is written, and nx2navi.ini's discord_app_id and discord_image override +// them. With no application id at all the thread stays idle and logs that once: there is nothing to show the status +// under. // // One host thread owns the connection and never holds a virtual core (kernel.c: host work runs without one). It // re-reads the settings when cfg_version changes (a menu change reaches it within half a second), connects to the diff --git a/runtime/perf.c b/runtime/perf.c index 0459a09e..a39e3419 100644 --- a/runtime/perf.c +++ b/runtime/perf.c @@ -442,9 +442,13 @@ static void perf_gpu_line(uint64_t frame, double interval_ms, double pace_share, "%.0f MiB, private %.0f MiB; bound: %s", (unsigned long long)frame, gs, rw, ow, vs, ws, priv, bound); // Where the committed memory lies, every 3600 frames and whenever it grew by 1 GiB since the last census: the process - // has run out of commit in long sessions while the game's own pools stayed flat. + // has run out of commit in long sessions while the game's own pools stayed flat. Only with NX2NAVI_MEM_CENSUS=1, as + // vk_memory.c's census: the walk holds the address-space lock the game's threads take at every page fault, and here + // it ran on the presenting thread, so each one delayed a frame by 65-240 ms. + static int census_on = -1; static uint64_t census_prv; - if (frame % 3600 == 0 || prv >= census_prv + (1ull << 30)) prof_commit_census(frame), census_prv = prv; + if (census_on < 0) census_on = cfg_bool("NX2NAVI_MEM_CENSUS"); + if (census_on && (frame % 3600 == 0 || prv >= census_prv + (1ull << 30))) prof_commit_census(frame), census_prv = prv; if (g.have_gpu && g.have_idle) rt_log("perf: frame %llu: gpu idle per frame %.2f ms between timed parts: %.2f ms waiting for the next submission, " "%.2f ms with it queued (untimed work or waits); %.1f gaps over 0.5 ms, longest %.2f ms; submit to start avg %.2f ms", diff --git a/runtime/vk_dlaa.c b/runtime/vk_dlaa.c index 7bc2f2b3..04f6ac09 100644 --- a/runtime/vk_dlaa.c +++ b/runtime/vk_dlaa.c @@ -333,6 +333,7 @@ static struct { NgxParam *params; int failed, reset; uint64_t evals, frames_missed; + int share_off; // frames left that pick the output pass without the mode's share (vk_dlaa_pass, vk_dlaa_frame) // jitter (vk_dlaa_viewport) VkImage jdepth; // the scene depth of the last frame DLAA ran on: passes drawing with it are jittered float jx, jy; // the offset the next scene draws take, render pixels (window x right, y down) @@ -1587,8 +1588,11 @@ void vk_dlaa_pass(VkExec *ex) { if (one) F.one_n++, F.one_w = c->width, F.one_h = c->height, F.one_fmt = c->format; // the output pass is the picture's size, the scene over the mode's share: at Ultra Performance (854 wide) a // 960x540 post pass comes first, and DLSS wrote there, which left a 540-line picture stretched to the output + // A frame whose scene is not at that share (the first presents before the scale patch takes, a dynamic + // resolution step) has no such pass: after a frame that missed DLSS the first pass wider than the scene is + // taken for 120 frames (D.share_off, vk_dlaa_frame), so a mismatch costs one frame rather than every frame. double share = wide_dlss_share(); - int big = share <= 0 || share >= 1 || c->width >= 0.95 * D.color.width / share; + int big = D.share_off || share <= 0 || share >= 1 || c->width >= 0.95 * D.color.width / share; if (one && big && c->width > D.color.width && c->height > D.color.height) { D.ended = D.done = D.up = 1; evaluate(ex, c); @@ -1660,6 +1664,8 @@ void vk_dlaa_frame(void) { return; } if (!dl_active()) return; + if (D.share_off) D.share_off--; + if (!D.done && D.armed) D.share_off = 120; // vk_dlaa_pass: the output pass without the mode's share for a while if (!D.done) { D.frames_missed++; // the first few after DLSS first ran: what the frame drew, to tell a frame drawn another way from a loading screen diff --git a/runtime/vk_pipeline.c b/runtime/vk_pipeline.c index 76f9ed8a..c12daea7 100644 --- a/runtime/vk_pipeline.c +++ b/runtime/vk_pipeline.c @@ -4189,7 +4189,7 @@ static void lb_state(const uint32_t pr_hist[16], const uint32_t fs_hist[4]) { // Boot: compute shaders of the warm list (recorded and prepared), then the first KEEP recorded graphics variants. static void lb_start(const uint32_t pr_hist[16], const uint32_t fs_hist[4]) { const char *e = cfg_value("NX2NAVI_LIB_PREBUILD"), *ke = cfg_get("NX2NAVI_LIB_PREBUILD_KEEP"); - const char *te = cfg_get("NX2NAVI_LIB_PREBUILD_THREADS"); // no fixed default: half the CPUs when unset + const char *te = cfg_get("NX2NAVI_LIB_PREBUILD_THREADS"); // no fixed default: 2 when unset (1 under 4 CPUs) const char *be = cfg_get("NX2NAVI_LIB_PREBUILD_BIG"); // SPIR-V words; LB_BIG_WORDS when unset, 0 no limit if (!A.on || !cfg_bool("NX2NAVI_LIB_PREBUILD")) return; LB.big_words = be && *be ? strtoull(be, NULL, 10) : LB_BIG_WORDS; diff --git a/tests/launcher_test.py b/tests/launcher_test.py index a54b2284..11b4ba5a 100644 --- a/tests/launcher_test.py +++ b/tests/launcher_test.py @@ -258,18 +258,20 @@ class GameIniTest(Tmp): def test_new_file_gets_the_section(self): gameini.put(self.tmp, 'discord', 'discord_rpc', '1') - self.assertEqual((self.tmp / 'nx2navi.ini').read_text(encoding='utf-8'), '[discord]\ndiscord_rpc = 1\n') + self.assertEqual((self.tmp / 'nx2navi.ini').read_bytes(), b'[discord]\ndiscord_rpc = 1\n') self.assertEqual(gameini.get(self.tmp, 'discord_rpc'), '1') def test_only_the_key_line_changes(self): + # the byte order mark, the line ends and a line that is not UTF-8 stay as they were ini = self.tmp / 'nx2navi.ini' - ini.write_bytes('[display]\r\nfullscreen = 1\r\n# discord_rpc = 1\r\n[discord]\r\n' - 'NX2NAVI_DISCORD_RPC = "1"\r\ndiscord_app_id = 42'.encode('utf-8')) + ini.write_bytes(b'\xef\xbb\xbf[display]\r\nfullscreen = 1\r\n# caf\xe9\r\n# discord_rpc = 1\r\n[discord]\r\n' + b'NX2NAVI_DISCORD_RPC = "1"\r\ndiscord_app_id = 42') self.assertEqual(gameini.get(self.tmp, 'DISCORD_RPC'), '1') # quotes, prefix and case as the game reads them gameini.put(self.tmp, 'discord', 'discord_rpc', '0') - self.assertEqual(ini.read_bytes().decode('utf-8'), '[display]\r\nfullscreen = 1\r\n# discord_rpc = 1\r\n' - '[discord]\r\ndiscord_rpc = 0\r\ndiscord_app_id = 42') + self.assertEqual(ini.read_bytes(), b'\xef\xbb\xbf[display]\r\nfullscreen = 1\r\n# caf\xe9\r\n# discord_rpc = 1\r\n' + b'[discord]\r\ndiscord_rpc = 0\r\ndiscord_app_id = 42') self.assertEqual(gameini.get(self.tmp, 'discord_rpc'), '0') + self.assertFalse((self.tmp / 'nx2navi.ini.tmp').exists()) def test_missing_key_goes_under_its_section(self): ini = self.tmp / 'nx2navi.ini' @@ -278,6 +280,22 @@ class GameIniTest(Tmp): gameini.put(self.tmp, 'discord', 'discord_rpc', '1') self.assertEqual(ini.read_bytes(), b'[discord]\ndiscord_rpc = 1\ndiscord_app_id = 42\n[display]\nfullscreen = 1') + def test_an_empty_line_leaves_the_earlier_value(self): + # runtime/cfg.c: "key =" leaves the setting as it was, so the game keeps 0 here, and a change goes to that line + ini = self.tmp / 'nx2navi.ini' + ini.write_bytes(b'discord_rpc = 0\ndiscord_rpc =\n') + self.assertEqual(gameini.get(self.tmp, 'discord_rpc'), '0') + gameini.put(self.tmp, 'discord', 'discord_rpc', '1') + self.assertEqual(ini.read_bytes(), b'discord_rpc = 1\ndiscord_rpc =\n') + + def test_an_unreadable_file_is_not_rewritten(self): + (self.tmp / 'nx2navi.ini').mkdir() # there, but not a file that can be read + with self.assertRaises(OSError): + gameini.get(self.tmp, 'discord_rpc') + with self.assertRaises(OSError): + gameini.put(self.tmp, 'discord', 'discord_rpc', '1') + self.assertTrue((self.tmp / 'nx2navi.ini').is_dir()) + class SettingsTest(Tmp): def test_roundtrip_and_reset(self): @@ -1287,6 +1305,28 @@ class MakeGameTest(Tmp): self.assertEqual(self.mg.notice_sha256(self.tmp / 'n.txt'), dlss_pin.SHA256) self.assertEqual((dlss_pin.NAME, dlss_pin.NOTICE), (self.mg.DLSS_DLL, self.mg.DLSS_NOTICE)) + def test_dlssg_beside_the_game(self): + """DLSS Frame Generation's files go beside the game only as a whole set, with the licence texts; the release + script (tools/make_launcher.py) and the build agree on the set.""" + import make_launcher + from unittest import mock + kit, out = self.tmp / 'kit', self.tmp / 'game' + src = kit / 'bin' / 'dlssg' + src.mkdir(parents=True) + out.mkdir() + self.assertEqual(make_launcher.DLSSG_FILES, self.mg.DLSSG_FILES) + with mock.patch.object(self.mg, 'KIT', kit): + for n in self.mg.DLSSG_FILES[:-1]: + (src / n).write_bytes(n.encode()) + self.assertEqual(self.mg.place_dlssg(out), 0) # nvngx_dlssg.dll missing: nothing is placed + self.assertFalse((out / 'nx2navi_dlssg.dll').exists()) + (src / 'nvngx_dlssg.dll').write_bytes(b'ngx') + (src / 'streamline-LICENSE.txt').write_text('MIT') + self.assertEqual(self.mg.place_dlssg(out), len(self.mg.DLSSG_FILES) + 1) + for n in self.mg.DLSSG_FILES: + self.assertEqual((out / n).read_bytes(), (src / n).read_bytes()) + self.assertEqual((out / 'streamline-LICENSE.txt').read_text(), 'MIT') + @unittest.skipIf(os.name == 'nt', 'a script stands in for windres.exe') def test_compile_resources(self): # The toolchain, the kit and the game under folders with spaces, as under C:\Users\Ali Sayed. windres must diff --git a/tools/build_dlssg.py b/tools/build_dlssg.py index aab670e8..7bef0796 100644 --- a/tools/build_dlssg.py +++ b/tools/build_dlssg.py @@ -4,7 +4,8 @@ Streamline's C++ types have MSVC's ABI, so this one DLL is built with Visual Studio's compiler (the Build Tools are enough; vswhere finds them), against the SDK's headers, which stay where they are. The Vulkan headers are MSYS2's. -The build kit ships the DLL ready-made beside Streamline's own runtime DLLs (tools/make_kit.py --dlssg). +A launcher release ships the DLL ready-made beside Streamline's own runtime DLLs (tools/make_launcher.py --dlssg +and --streamline), and tools/make_game.py place_dlssg puts them beside the game. """ import argparse import os diff --git a/tools/make_game.py b/tools/make_game.py index 2781466d..e19a1d1e 100644 --- a/tools/make_game.py +++ b/tools/make_game.py @@ -527,6 +527,13 @@ def copy_if_changed(src, dst): DLSS_DLL, DLSS_NOTICE = 'nvngx_dlss.dll', 'nvngx_dlss-NOTICE.txt' # tools/dlss_pin.py, in the kit's bin/ +# DLSS Frame Generation (runtime/vk_dlssg.c) loads these from beside the game: nx2navi_dlssg.dll (runtime/dlssg/, +# tools/build_dlssg.py), NVIDIA Streamline's interposer and the plugins it uses, and NGX's frame generation library. +# A release carries them with their licence texts in bin/dlssg/ (tools/make_launcher.py --dlssg); without the whole set +# the game leaves DLSS Frame Generation off. +DLSSG_DIR = 'dlssg' +DLSSG_FILES = ('nx2navi_dlssg.dll', 'sl.interposer.dll', 'sl.common.dll', 'sl.dlss_g.dll', 'sl.pcl.dll', 'sl.reflex.dll', + 'nvngx_dlssg.dll') def notice_sha256(path): @@ -559,6 +566,18 @@ def place_dlss(out): return None +def place_dlssg(out): + """DLSS Frame Generation's libraries and their licence texts, the kit's bin/dlssg/, beside the game. Returns how + many files it holds, 0 when the kit has no complete set (nothing is placed then).""" + src = KIT / 'bin' / DLSSG_DIR + if not all((src / n).is_file() for n in DLSSG_FILES): + return 0 + files = sorted(p for p in src.iterdir() if p.is_file()) + for p in files: + copy_if_changed(p, out / p.name) + return len(files) + + def copy_dlls(exe, msys_bin, out): seen, todo, copied = set(), [exe], [] while todo: @@ -1073,6 +1092,8 @@ def step_compile(out, cache_dir, msys, jobs, romfs_job=None): kept = place_dlss(out) if WIN else None # NVIDIA DLSS (runtime/vk_dlaa.c): its library is a Windows DLL if kept: say(f' {kept}') + if WIN and place_dlssg(out): # DLSS Frame Generation (runtime/vk_dlssg.c): Windows DLLs too + say(' DLSS Frame Generation: nx2navi_dlssg.dll, Streamline and nvngx_dlssg.dll beside the game') beside = copy_dlls(exe, msys / 'bin', out) if WIN else [] say(f' {EXE_NAME} {exe.stat().st_size / 1e6:.0f} MB' + (f'; beside it: {", ".join(beside)}' if beside else '')) (out / 'profile').mkdir(exist_ok=True) diff --git a/tools/make_kit.py b/tools/make_kit.py index 3df445ca..d5c574f4 100644 --- a/tools/make_kit.py +++ b/tools/make_kit.py @@ -25,7 +25,8 @@ ROOT = Path(__file__).resolve().parents[1] NAME = 'NX2navi-BuildKit' TOOLS = ('make_game.py', 'nup.py', 'romfs_index.py', 'check_content.py', 'hostmem.py', 'prepare_shaders.py', 'shader_census.py', 'shader_list.py', 'zstd_compat.py', 'pad_prompts.py', 'prompt_art.py', 'button_icons.py', - 'build_game.py') # make_game.py imports its vma_include on Linux for the VMA header + 'build_game.py', 'check_isa.py', 'build_shaders.py') # make_game.py imports build_game's vma_include on +# Linux for the VMA header, and build_game.py imports check_isa and build_shaders TOP = ('LICENSE', 'LICENSE-ADDITIONS', 'THIRD_PARTY_NOTICES.md', 'VERSION', 'nx2navi.ini.example') SKIP_DIRS = {'__pycache__', '.pytest_cache'} SKIP_SUFFIXES = {'.o', '.obj', '.pyc', '.exe', '.dll', '.a', '.lib'} diff --git a/tools/make_launcher.py b/tools/make_launcher.py index 16552b4a..06ad2492 100644 --- a/tools/make_launcher.py +++ b/tools/make_launcher.py @@ -21,6 +21,8 @@ The release folder (--out) holds: 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 and --streamline (DLSS Frame Generation: nx2navi_dlssg.dll, + Streamline's runtime DLLs and nvngx_dlssg.dll, their licences and a notice), lib/libnx2navirt.a (the runtime, prebuilt with the release's compiler), prompt-art/ with --prompt-art (the button art make_game.py draws the Xbox, PlayStation and keyboard and mouse prompts from, tools/prompt_art.py art_files), @@ -170,7 +172,51 @@ def build_python(py): # ---------------------------------------------------------------- app// -def build_app(app, ver, xlate, feed_repo, lock, ffx=None, dlss=None, art=None): +# DLSS Frame Generation: the files tools/make_game.py DLSSG_FILES names, put beside the game from bin/dlssg/. +DLSSG_FILES = ('nx2navi_dlssg.dll', 'sl.interposer.dll', 'sl.common.dll', 'sl.dlss_g.dll', 'sl.pcl.dll', 'sl.reflex.dll', + 'nvngx_dlssg.dll') +DLSSG_NOTICE = 'dlssg-NOTICE.txt' + + +def place_dlssg(dll, sdk, dest): + """DLSS Frame Generation into dest (the release's bin/dlssg/): nx2navi_dlssg.dll (tools/build_dlssg.py), NVIDIA + Streamline's interposer and the plugins it loads, and NGX's nvngx_dlssg.dll, unmodified from the Streamline SDK's + bin/x64, with Streamline's licence (MIT), the NVIDIA RTX SDKs License that covers nvngx_dlssg.dll, and a notice + naming each file's sha256.""" + sdk_bin = sdk / 'bin' / 'x64' + srcs = {n: (dll if n == 'nx2navi_dlssg.dll' else sdk_bin / n) for n in DLSSG_FILES} + texts = {'streamline-LICENSE.txt': sdk / 'license.txt', + 'nvidia-rtx-sdks-LICENSE.txt': sdk_bin / 'nvngx_dlss.license.txt'} + missing = [str(p) for p in (*srcs.values(), *texts.values()) if not p.is_file()] + if missing: + sys.exit(f'DLSS Frame Generation: {", ".join(missing)} missing') + dest.mkdir(parents=True, exist_ok=True) + lines = ['DLSS Frame Generation for NX2navi. nx2navi_dlssg.dll is NX2navi\'s own code (runtime/dlssg/), built', + 'with the Streamline SDK\'s headers. The other files are NVIDIA\'s own builds, unmodified, from the', + f'{streamline_version(sdk)} SDK (https://github.com/NVIDIA-RTX/Streamline): the sl.*.dll files under its', + 'MIT licence (streamline-LICENSE.txt), nvngx_dlssg.dll under the NVIDIA RTX SDKs License', + '(nvidia-rtx-sdks-LICENSE.txt), which licenses it only for use on systems with NVIDIA GPUs. They are not', + 'part of NX2navi and NX2navi\'s licence does not cover them.', ''] + for n, p in srcs.items(): + shutil.copy2(p, dest / n) + lines.append(f'{n} sha256 {sha256_file(p)}') + for n, p in texts.items(): + shutil.copy2(p, dest / n) + (dest / DLSSG_NOTICE).write_text('\n'.join(lines) + '\n', encoding='utf-8') + + +def streamline_version(sdk): + """'Streamline 2.14.16' from the SDK's README title line, or 'Streamline' when it names none.""" + try: + for line in (sdk / 'README.md').read_text(encoding='utf-8', errors='replace').splitlines()[:5]: + if 'Version' in line: + return 'Streamline ' + line.rsplit('Version', 1)[1].strip() + except OSError: + pass + return 'Streamline' + + +def build_app(app, ver, xlate, feed_repo, lock, ffx=None, dlss=None, art=None, dlssg=None): copy_tree(ROOT / 'launcher', app / 'launcher') for d in ('runtime', 'recomp'): copy_tree(ROOT / d, app / d) @@ -190,6 +236,8 @@ def build_app(app, ver, xlate, feed_repo, lock, ffx=None, dlss=None, art=None): shutil.copy2(ffx, app / 'bin' / 'amd_fidelityfx_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 + place_dlssg(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') @@ -305,6 +353,10 @@ def main(): 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, with --streamline') + ap.add_argument('--streamline', type=Path, metavar='SDK', + help='the Streamline SDK folder whose bin/x64 gives the Streamline and nvngx_dlssg.dll files') 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('--out', type=Path, default=MAIN_ROOT / 'build' / 'launcher-release') @@ -334,7 +386,12 @@ def main(): + (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)') - build_app(folder / 'app' / ver, ver, a.xlate, a.feed, lock, a.ffx, dlss, a.prompt_art) + if bool(a.dlssg) != bool(a.streamline): + sys.exit('--dlssg and --streamline go together') + dlssg = (a.dlssg, a.streamline) if a.dlssg else None + say(f'DLSS Frame Generation: {a.dlssg}, {streamline_version(a.streamline)}' if dlssg + else 'DLSS Frame Generation left out (no --dlssg)') + build_app(folder / 'app' / ver, ver, a.xlate, a.feed, lock, a.ffx, dlss, a.prompt_art, dlssg) (folder / 'app' / 'current.txt').write_text(ver + '\n', encoding='utf-8') build_python(folder / 'python') cc, windres = native_compiler(lock)