diff --git a/.gitignore b/.gitignore index 92532f2c..97ee9d21 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ __pycache__/ /game/ /build/ /generated/ +# Button art a release carries (tools/make_launcher.py --prompt-art); a checkout may hold it for a test build +/prompt-art/ *.xci *.xcz *.nsp diff --git a/docs/README-BuildKit.md b/docs/README-BuildKit.md index 16720c29..6aa7e12e 100644 --- a/docs/README-BuildKit.md +++ b/docs/README-BuildKit.md @@ -21,11 +21,12 @@ downloads included, and keeps the game up to date. This page is the same build b in the installer. Then, in a Command Prompt: ```bat - py -m pip install numpy lz4 zstandard zbic + py -m pip install numpy lz4 zstandard zbic fonttools pillow ``` `zbic` unpacks the game's compressed program files. It is a separate GPL-2.0 package you install yourself; the kit - only calls it. + only calls it. `fonttools` and `pillow` draw the Xbox, PlayStation and keyboard and mouse button prompts, when the + kit has a `prompt-art` folder. 2. **MSYS2** from [msys2.org](https://www.msys2.org/), installed to the default `C:\msys64`. Open **MSYS2 UCRT64** from the Start menu and paste: @@ -51,9 +52,10 @@ check that your dump is the right version, add `--check` (it builds nothing). The build checks the version, unpacks the program files, packs the 26 GB RomFS image (every file is checked against the dump's own checksums) while it turns the game's code into C and compiles it (about 6 minutes on a 16-thread Ryzen 7; longer on fewer cores), then prepares every one of the game's shaders for your graphics card (about 5 more -minutes), so the game does not stop to compile shaders while you play. The game it builds is for this PC's processor. -You can use your PC meanwhile. If it stops, -run the same command again: finished steps are skipped. +minutes), so the game does not stop to compile shaders while you play. When the kit has a `prompt-art` folder, it +last draws the Xbox, PlayStation and keyboard and mouse button prompts from that art (about a minute) into the +`romfs-patches-*` folders beside the game. The game it builds is for this PC's processor. You can use your PC +meanwhile. If it stops, run the same command again: finished steps are skipped. When it says `finished`, start `C:\Games\Ocarina\ocarina.exe`. You can delete `C:\Games\Ocarina\build-cache` (about 5 GB) afterwards. @@ -68,6 +70,10 @@ When it says `finished`, start `C:\Games\Ocarina\ocarina.exe`. You can delete `C - **Screen** in the menu has Upscaling (DLSS on NVIDIA RTX cards, FSR 3.1 on any card), Frame generation (FSR 3.1; it needs DLSS or FSR 3.1 upscaling on), HDR (the game's picture tone mapped to your display, with its own grading rows, or Native, the game's own HDR; turn HDR on in Windows first) and the resolution. Those marked "after restart" take effect when the game starts again. +- **Controls** in the menu has Button prompts: the game's own, or Xbox, PlayStation or keyboard and mouse ones when + the build drew them (a kit with `prompt-art`). It takes effect after a restart. The keyboard and mouse prompts show + the keys `nx2navi.ini` binds when the build runs: after rebinding keys, run + `py -I tools\make_game.py --rebuild --out "C:\Games\Ocarina"` to draw them again (it skips everything else). - The build copies NVIDIA's DLSS library `nvngx_dlss.dll` (with `nvngx_dlss-NOTICE.txt`, which names its licence) and FSR 3.1's `amd_fidelityfx_vk.dll` beside `ocarina.exe` from the kit's `bin\`. To use another DLSS version, put your own `nvngx_dlss.dll` there: later builds keep it. diff --git a/docs/controls.md b/docs/controls.md index 4edaa318..bf9c1f3c 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -35,7 +35,7 @@ A, B, X and Y show the button the *Button layout* presses: with `nintendo` (by p The game reads the font and the interface textures once at start, so a change shows after a restart (the row says so; the menu's Restart button restarts). A button or key remapped on this tab keeps its old prompt until the folders are made again. -The prompts live in overlay folders next to the exe, `romfs-patches-xbox`, `romfs-patches-xbox-label`, `romfs-patches-playstation` and `romfs-patches-playstation-label` (by position and by label) and `romfs-patches-pc`, which `runtime/romfs.c` loads in place of the game's files. `tools/pad_prompts.py` draws them on the player's machine from the player's own RomFS and a folder of button art, which the repository does not hold: +The prompts live in overlay folders next to the exe, `romfs-patches-xbox`, `romfs-patches-xbox-label`, `romfs-patches-playstation` and `romfs-patches-playstation-label` (by position and by label) and `romfs-patches-pc`, which `runtime/romfs.c` loads in place of the game's files. `tools/pad_prompts.py` draws them on the player's machine from the player's own RomFS and a folder of button art, which the repository does not hold. A launcher release or build kit made with `--prompt-art` carries that art (`prompt-art`, `tools/prompt_art.py`), and `tools/make_game.py` draws every folder in its last step, again whenever the art, the tools or the `bind_` lines change (so a rebuild after rebinding keys brings the keyboard prompts up to date). By hand: ``` python tools\pad_prompts.py --art \Source --game diff --git a/docs/launcher.md b/docs/launcher.md index ffabba99..eba1e6d1 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -51,10 +51,12 @@ for updates); an XInput gamepad (d-pad or left stick, A, B, Y). ``` NX2navi.exe launcher/native/nx2navi.c: swaps in a staged Python, starts app\\launcher\start.pyw -python/ python.org's embeddable Python 3.14.9 with tkinter, numpy, lz4, zstandard and truststore +python/ python.org's embeddable Python 3.14.9 with tkinter, numpy, lz4, zstandard, fonttools, pillow + and truststore app/current.txt the version the stub starts; app/previous.txt the one before it app// launcher/, runtime/, recomp/, tools/, bin/nx2navi_xlate.exe, VERSION, - toolchain.lock.json (the pinned build tools), feed.json (where updates come from) + toolchain.lock.json (the pinned build tools), feed.json (where updates come from), and in a + release made with --prompt-art, prompt-art/ (the button art the prompts are drawn from) pydeps/ zbic, downloaded from PyPI on this PC toolchain/msys64/ucrt64 MSYS2's clang and libraries, downloaded from the MSYS2 repository on this PC toolchain/pkgs/ the downloaded package files @@ -88,7 +90,7 @@ A path the Windows ANSI code page cannot hold breaks the MSYS2 tools, so the too (it reads UCRT64's headers; `make_game.py` `fast_clang` checks that both report the same version and target). - The lifted code is compiled for the processor's own level, x86-64-v3 (AVX2, FMA) or v2 (`cpu_level`): the game runs on the PC that builds it, and a generic build would do every fused multiply-add of the game in software. -3. **Prepare the Shaders**: make_game.py's last step translates every shader of the game, in its default bind state, +3. **Prepare the Shaders**: make_game.py translates every shader of the game, in its default bind state, into the game's shader cache and builds its stage library or compute pipeline into the driver's own cache (`tools/prepare_shaders.py`), so play stops for them far less often. Variants met in play with another bind state are still translated then, full pipelines are still linked in play (quickly, from the cached parts), and the driver's @@ -96,7 +98,14 @@ A path the Windows ANSI code page cannot hold breaks the MSYS2 tools, so the too follows the game's own counts, printed every 2 s: the translation fills the first half, the build into the driver's cache the second. An update every 10 s keeps the step's text current while nothing is printed, and after 3 minutes without a count moving it says so, and that the step is still running. -4. The game's Cetra Display is decoded for the window. +4. **Draw the Button Prompts**, only when the version holds button art (`app//prompt-art`, a release made + with `--prompt-art`; `build.has_prompt_art`): make_game.py's last step runs `tools/pad_prompts.py` on that art + and writes the Xbox, PlayStation and keyboard and mouse prompts into `romfs-patches-*` beside the game (about + 30 s), which the game's Controls > Button prompts loads. The keyboard and mouse prompts show the keys of + `nx2navi.ini`'s `bind_` lines, so Rebuild the Game after rebinding keys draws them again; the step's key names + all it reads (the RomFS, the art, the tools, those lines), so it runs again only when one changed or a folder is + gone, and runs no other step again. Without art the row is not shown and the game keeps its own prompts. +5. The game's Cetra Display is decoded for the window. ## Updating @@ -137,7 +146,9 @@ The stub swaps `python.next` in while no Python runs, then starts the new versio 6. **Swap In the New Build**: lld links `ocarina.exe.new`, which replaces `ocarina.exe` only once it is complete. A failed rebuild leaves the old build playable; Rebuild the Game tries again. 7. **Prepare the Shaders** again for the new build; shaders the translator already prepared come from the cache. -8. Every version but the current and the previous one is deleted. +8. **Draw the Button Prompts**, with button art in the new version: only when the art, the prompt tools or the key + bindings changed, or a prompt folder is missing (a first version with art draws them all). +9. Every version but the current and the previous one is deleted. When the new version fails to start (exit code 86 from `start.pyw`, or any error within six seconds), the stub sets `current.txt` back to `previous.txt`'s version and starts it with `--rolled-back`. The game is never rebuilt while it @@ -205,6 +216,15 @@ game folder is the default `\game`. The dump is never touched. ship the library; it also asks that a commercial release be announced to NVIDIA first (https://developer.nvidia.com/sw-notification) and that the use of DLSS be credited. + `--prompt-art FOLDER ...` puts the button art in the release (`app//prompt-art`): folders that hold + `XGamepad`, `P5Gamepad` and `Keyboard_Mouse` (the Source folders of button prompt art packs), or those three + folders themselves; a set named itself wins over one found inside a folder named, and the same set twice is + refused. Only the pictures `tools/pad_prompts.py` reads are copied (`tools/prompt_art.py` + `art_files`: the pad pictures it names and every key of `Keyboard_Mouse/Dark` and `/White`, about 280 PNGs and + 1 MB), and it stops first naming any that are missing. The art goes into the release only, never into the + repository (`.gitignore` has `/prompt-art/`). Without it the release has no art and the build draws no prompts. + `tools/make_kit.py` takes the same option for the build kit. + On Linux it cross-compiles the two programs with clang, lld and llvm-windres against the same MSYS2 packages and unpacks Tcl/Tk with msiextract (msitools). It writes into `build/launcher-release`: `NX2navi-Setup-.exe` (the setup program with the launcher folder appended as a zip), `NX2navi-app-.zip` (with the prebuilt @@ -223,11 +243,13 @@ local fake repository, resumed and checked downloads, the certificate checks aga certificates made with the `openssl` command), the GitHub and URL feeds, staging, switching, rollback and pruning, unsafe archives, the font decoding on a system font wrapped like the game's, `make_game.py` on a synthetic `.nup` (events, skipped steps, `--rebuild` without the dump, step keys), the RomFS step beside the lift and compile -and its failure stopping the build, the compile step's object cache with the system's C compiler and its +and its failure stopping the build, the prompts step with a stand-in prompt tool (when it runs again, and that it +makes no other step run), the button art a release copies and the packages and tools the prompts need in a release, +the compile step's object cache with the system's C compiler and its largest-first order, its windres call under folders with spaces in their names, CLANG64's clang in the lock and the install, the save import (the account folder, the save folders it recognises, backup, the commit journal, the saves folder, refusals), the install job with a fake -`make_game.py`, and the window's selftest when a display is there. +`make_game.py` (with and without the prompts row), and the window's selftest when a display is there. ## Requirements and limits diff --git a/launcher/build.py b/launcher/build.py index c1029c5b..63eb4374 100644 --- a/launcher/build.py +++ b/launcher/build.py @@ -21,6 +21,12 @@ def command(layout, args): '--events'] +def has_prompt_art(layout): + """True when this version holds button art (app//prompt-art, tools/prompt_art.py FOLDER): make_game.py + then draws the button prompts in a step of its own, and the jobs show a row for it.""" + return (layout.app / 'prompt-art').is_dir() + + def install_args(layout, dump, game_dir): dump = str(dump) if os.path.isdir(dump): diff --git a/launcher/jobs.py b/launcher/jobs.py index bcd00cd3..42b50a0f 100644 --- a/launcher/jobs.py +++ b/launcher/jobs.py @@ -24,13 +24,20 @@ STEPS = { 'change are kept from the last build.'), 'shaders': ('Prepare the Shaders', "Building the game's known shaders for your graphics card ahead of time, so " 'play stops for them far less often.'), + 'prompts': ('Draw the Button Prompts', 'Drawing the Xbox, PlayStation and keyboard and mouse button prompts the ' + "game's Controls menu offers under Button prompts."), 'ready': ('Ready to Play', 'The game is built.'), 'download': ('Download {ver}', 'Downloading the new version of NX2navi.'), 'verify': ('Verify the Download', 'Checking the download against its checksum.'), 'swap': ('Swap In the New Build', 'Linking the new build. It replaces the old one only once it is complete.'), } WEIGHTS = {'tools': 12, 'tools_update': 4, 'check': 1, 'exefs': 2, 'romfs': 14, 'lift': 14, 'compile': 55, - 'compile_changed': 55, 'shaders': 18, 'ready': 0, 'download': 8, 'verify': 1, 'swap': 6} + 'compile_changed': 55, 'shaders': 18, 'prompts': 4, 'ready': 0, 'download': 8, 'verify': 1, 'swap': 6} + + +def prompt_rows(ctx): + """The prompts row, for a version with button art (make_game.py draws the prompts after the shaders), or none.""" + return ['prompts'] if build.has_prompt_art(ctx.layout) else [] def mmss(seconds): @@ -285,15 +292,17 @@ class InstallJob(Job): title = 'Installing' def __init__(self, ctx, dump, game_dir): - super().__init__(ctx, ['tools', 'check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'ready']) + super().__init__(ctx, ['tools', 'check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', *prompt_rows(ctx), + 'ready']) self.dump, self.game_dir = str(dump), str(game_dir) def run(self): self.ensure_tools('tools') self.make_game(build.install_args(self.ctx.layout, self.dump, self.game_dir), - {s: s for s in ('check', 'exefs', 'romfs', 'lift', 'compile', 'shaders')}) - if self.step('shaders').state == PENDING: - self.skip('shaders') + {s: s for s in ('check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts')}) + for sid in ('shaders', 'prompts'): + if self.step(sid) and self.step(sid).state == PENDING: + self.skip(sid) self.finish('ready', 'Ready') try: gamefiles.extract_fonts(self.ctx.layout, self.game_dir) @@ -306,7 +315,7 @@ class RebuildJob(Job): title = 'Rebuilding' def __init__(self, ctx, game_dir, dump=''): - super().__init__(ctx, ['tools_update', 'lift', 'compile_changed', 'swap', 'shaders']) + super().__init__(ctx, ['tools_update', 'lift', 'compile_changed', 'swap', 'shaders', *prompt_rows(ctx)]) self.game_dir, self.dump = str(game_dir), dump def run(self): @@ -320,9 +329,10 @@ class RebuildJob(Job): args = build.install_args(layout, self.dump, self.game_dir) else: raise build.BuildError('the game folder has no earlier build: install the game again') - self.make_game(args, {'lift': 'lift', 'compile': 'compile_changed', 'shaders': 'shaders'}, link_step='swap') - for sid in ('lift', 'compile_changed', 'swap', 'shaders'): - if self.step(sid).state == PENDING: + self.make_game(args, {'lift': 'lift', 'compile': 'compile_changed', 'shaders': 'shaders', 'prompts': 'prompts'}, + link_step='swap') + for sid in ('lift', 'compile_changed', 'swap', 'shaders', 'prompts'): + if self.step(sid) and self.step(sid).state == PENDING: self.skip(sid) @@ -362,8 +372,8 @@ class ContinueUpdateJob(RebuildJob): kind = 'update' def __init__(self, ctx, game_dir, old, dump=''): - Job.__init__(self, ctx, ['download', 'verify', 'tools_update', 'lift', 'compile_changed', 'swap', 'shaders'], - ctx.layout.version) + Job.__init__(self, ctx, ['download', 'verify', 'tools_update', 'lift', 'compile_changed', 'swap', 'shaders', + *prompt_rows(ctx)], ctx.layout.version) self.game_dir, self.dump, self.old = str(game_dir), dump, old self.title = f'Updating to {versions.display(ctx.layout.version)}' for sid, value in (('download', 'Done'), ('verify', 'Checksum OK')): @@ -374,7 +384,7 @@ class ContinueUpdateJob(RebuildJob): RebuildJob.run(self) else: self.ensure_tools('tools_update') - for sid in ('lift', 'compile_changed', 'swap', 'shaders'): + for sid in ('lift', 'compile_changed', 'swap', 'shaders', 'prompts'): self.skip(sid, 'No game yet') updater.prune(self.ctx.layout) diff --git a/launcher/ui/app.py b/launcher/ui/app.py index bfc5c7b7..d9eeb46c 100644 --- a/launcher/ui/app.py +++ b/launcher/ui/app.py @@ -951,7 +951,7 @@ class App: return {'tools': 'Gathering Build Tools', 'tools_update': 'Updating Build Tools', 'check': 'Inspecting Your Game', 'exefs': 'Unpacking the Program', 'romfs': 'Packing the Game Data', 'lift': 'Translating the Code', 'compile': 'Compiling', 'compile_changed': 'Compiling Changed Files', 'shaders': 'Preparing the Shaders', - 'ready': 'Ready to Play', + 'prompts': 'Drawing the Button Prompts', 'ready': 'Ready to Play', 'download': 'Downloading', 'verify': 'Verifying', 'swap': 'Swapping In the New Build'}.get(st.id, st.label) diff --git a/tests/launcher_test.py b/tests/launcher_test.py index fbebeb22..163633e0 100644 --- a/tests/launcher_test.py +++ b/tests/launcher_test.py @@ -9,10 +9,11 @@ Run: python tests/launcher_test.py (Python 3.14 for the zstd tests; the window - feed: GitHub releases (stable, preview, private with a token) and a plain manifest URL, served locally - updater: staging a release, switching, rollback, pruning, refusing unsafe archives - gamefiles: the .bfotf font wrapping and zstd dictionaries, on a font of this system made into a fake RomFS +- the button art of a release: the files tools/prompt_art.py copies, and the packages and tools the prompts need - make_game.py: a synthetic .nup through the check, exefs and romfs steps with --events, skipped steps on a second - run, --rebuild without the game files, the object cache of the compile step with the system's C compiler, and its - windres call under folders with spaces (a script in windres's place) -- jobs: an install driven by a fake make_game.py + run, --rebuild without the game files, the prompts step with a stand-in prompt tool, the object cache of the compile + step with the system's C compiler, and its windres call under folders with spaces (a script in windres's place) +- jobs: an install driven by a fake make_game.py, with and without the prompts row - the window: --selftest draws every screen (skipped without a display) """ import contextlib @@ -702,6 +703,77 @@ class FontTest(Tmp): self.assertEqual(gamefiles.cached_fonts(self.layout)['title'][1], 'DejaVu Sans') +# ---------------------------------------------------------------- the button art of a release + +class PromptArtTest(Tmp): + def make_art(self): + """Every picture tools/prompt_art.py names, the pads in one pack's folder and the keys in another's, with two + keys and two pictures the tool never reads. Returns (prompt_art, pads, keys, the files a release takes).""" + import prompt_art as pa + pads, keys = self.tmp / 'pack' / 'Source', self.tmp / 'keys' / 'Source' + wanted = {rel for style in pa.STYLES.values() for part in style.values() for rel in part.values() + if rel != pa.OPTIONS} + wanted |= {pa.SHARE, *pa.BLANKS.values(), f'{pa.KM}Dark/T_E_Key_Dark.png', f'{pa.KM}White/T_E_Key_White.png', + f'{pa.KM}Dark/T_F12_Key_Dark.png'} + for rel in wanted | {'XGamepad/Default/T_X_Unused.png', f'{pa.KM}Retro/T_E_Key_Retro.png'}: + path = (keys if rel.startswith(pa.KM) else pads) / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(rel.encode()) + return pa, pads, keys, wanted + + def test_art_files(self): + """A release takes the pictures pad_prompts.py reads and every key, from folders that hold the art's sets or + are those sets; a set named twice, a set missing or a picture missing stops it.""" + pa, pads, keys, wanted = self.make_art() + for folders in ([pads, keys], [pads, keys / 'Keyboard_Mouse'], [pads / 'XGamepad', pads / 'P5Gamepad', keys]): + self.assertEqual(set(pa.art_files(folders)), wanted) + dest = self.tmp / 'release' / pa.FOLDER + n, size = pa.copy_art([pads, keys], dest) + copied = {p.relative_to(dest).as_posix(): p.read_bytes() for p in dest.rglob('*') if p.is_file()} + self.assertEqual(copied, {rel: rel.encode() for rel in wanted}) + self.assertEqual((n, size), (len(wanted), sum(len(rel) for rel in wanted))) + # a set named itself wins over one inside a folder named; the same set twice at one rank is refused + other = self.tmp / 'other' / 'Keyboard_Mouse' + other.mkdir(parents=True) + self.assertEqual(pa.art_roots([pads, keys, other])['Keyboard_Mouse'], other.parent) + self.assertEqual(pa.art_roots([pads, keys])['Keyboard_Mouse'], keys) + for folders, says in (([pads, keys, other.parent], 'in both'), + ([pads, keys / 'Keyboard_Mouse', other], 'in both'), + ([pads], 'no Keyboard_Mouse folder'), ([self.tmp / 'release'], 'holds none of')): + with self.assertRaises(SystemExit) as e: + pa.art_files(folders) + self.assertIn(says, str(e.exception.code)) + (pads / pa.SHARE).unlink() + with self.assertRaises(SystemExit) as e: + pa.art_files([pads, keys]) + self.assertIn('T_X_Share-1.png', str(e.exception.code)) + + def test_release_carries_what_the_prompts_need(self): + """The release's Python has every package tools/pad_prompts.py imports, and the launcher release and the build + kit carry every tool it imports.""" + import ast + import make_kit + import make_launcher + pip = {'PIL': 'pillow', 'fontTools': 'fonttools'} + local, packages = set(), set() + for tool in ('pad_prompts.py', 'button_icons.py', 'prompt_art.py'): + for node in ast.walk(ast.parse((ROOT / 'tools' / tool).read_text(encoding='utf-8'))): + names = [a.name for a in node.names] if isinstance(node, ast.Import) else \ + [node.module] if isinstance(node, ast.ImportFrom) and node.module else [] + for root in (n.split('.')[0] for n in names): + if (ROOT / 'tools' / f'{root}.py').is_file(): + local.add(f'{root}.py') + elif root not in sys.stdlib_module_names: + packages.add(pip.get(root, root)) + self.assertEqual(packages, {'zstandard', 'pillow', 'fonttools'}) + reqs = (ROOT / 'tools' / 'requirements-launcher.txt').read_text(encoding='utf-8') + pinned = {ln.split('==')[0].strip().lower() for ln in reqs.splitlines() + if '==' in ln and not ln.startswith('#')} + self.assertLessEqual(packages, pinned) + for tools in (make_launcher.TOOLS, make_kit.TOOLS): + self.assertLessEqual(local | {'pad_prompts.py'}, set(tools)) + + # ---------------------------------------------------------------- make_game.py def nso(build_id=b'\0' * 16, size=0x300): @@ -811,6 +883,24 @@ for name in re.findall(r'^\s*\w+\s+\w+\s+"([^"]+)"', text, re.M): open(a[a.index('-o') + 1], 'wb').write(b'coff') ''' +# Stands in for tools/pad_prompts.py: counts its runs in the game folder and writes every overlay folder's manifest, +# printing the line the real tool prints for each and its closing report. +FAKE_PAD_PROMPTS = r''' +import json, sys +from pathlib import Path +import prompt_art +a = sys.argv[1:] +art, game = Path(a[a.index('--art') + 1]), Path(a[a.index('--game') + 1]) +assert (art / 'XGamepad').is_dir(), art +with open(game / 'prompt-runs.txt', 'a') as f: + f.write('run\n') +for name in prompt_art.OVERLAYS: + (game / name).mkdir(exist_ok=True) + (game / name / 'manifest.tsv').write_text('# path\n') + print(f'{game / name}: 3 files, the font grew by 12 bytes', flush=True) +print(json.dumps({'status': 'pass', 'folders': []})) +''' + class MakeGameTest(Tmp): def setUp(self): @@ -1001,6 +1091,81 @@ class MakeGameTest(Tmp): steps = json.loads((self.out / 'build-cache' / 'state.json').read_text())['steps'] self.assertNotIn('romfs', steps) + def test_prompts_step(self): + """With button art in the kit, the last step draws the button prompts (a fake tools/pad_prompts.py), one + progress event per folder. It runs again only when the art, the bind_ lines or a folder changed, and then no + other step runs; a step before it that runs does not make it run; a kit without art skips it.""" + from unittest import mock + kit = self.tmp / 'kit' + (kit / 'tools').mkdir(parents=True) + (kit / 'bin').mkdir() + (kit / 'bin' / 'nx2navi_xlate.id').write_text('xlate 1\n') + (kit / 'tools' / 'prepare_shaders.py').write_text('') + for name in ('prompt_art.py', 'button_icons.py'): + shutil.copy2(ROOT / 'tools' / name, kit / 'tools' / name) + (kit / 'tools' / 'pad_prompts.py').write_text(FAKE_PAD_PROMPTS) + art = kit / 'prompt-art' / 'XGamepad' / 'Default' + art.mkdir(parents=True) + (art / 'T_X_A_White.png').write_bytes(b'picture one') + ran = [] + + def fake(name): + def step(*args, **kw): + ran.append(name) + return step + + def build(): + """One build; (the other steps that ran, the prompts step's events, the prompt tool's runs so far).""" + ran.clear() + buf = io.StringIO() + argv = ['make_game.py', '--nup', str(self.nup), '--out', str(self.out), '--msys', str(self.msys), + '--events'] + with mock.patch.object(sys, 'argv', argv), contextlib.redirect_stdout(buf), \ + contextlib.redirect_stderr(io.StringIO()): + self.mg.main() + events = [json.loads(ln[2:]) for ln in buf.getvalue().splitlines() if ln.startswith('@@')] + log = self.out / 'prompt-runs.txt' + runs = log.read_text().count('run') if log.exists() else 0 + return sorted(ran), [e for e in events if e.get('step') == 'prompts'], runs + + def states(events): + return [e['state'] for e in events if e['ev'] == 'step'] + + sys.path.insert(0, str(self.layout.pydeps)) # the fake zbic + self.addCleanup(sys.path.remove, str(self.layout.pydeps)) + with mock.patch.multiple(self.mg, KIT=kit, PROMPT_PACKAGES={}, EVENTS=False, step_romfs=fake('romfs'), + step_lift=fake('lift'), step_compile=fake('compile'), step_shaders=fake('shaders')): + others, ev, runs = build() + self.assertEqual((others, runs), (['compile', 'lift', 'romfs', 'shaders'], 1)) + self.assertEqual(states(ev), ['start', 'done']) + fractions = [e['fraction'] for e in ev if e['ev'] == 'progress'] + self.assertEqual(fractions, [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]) + details = [e['detail'] for e in ev if e['ev'] == 'progress'] + self.assertEqual(details[-1], 'Drew 5 of 5 sets of button prompts') + self.assertIn('prompts', json.loads((self.out / 'build-cache' / 'state.json').read_text())['steps']) + self.assertEqual(build(), ([], [{'ev': 'step', 'step': 'prompts', 'state': 'skip'}], 1)) + # a setting that is no key binding changes nothing; a binding draws the prompts again, and only them + (self.out / 'nx2navi.ini').write_text('vsync = on\n') + self.assertEqual(build()[2], 1) + (self.out / 'nx2navi.ini').write_text('vsync = on\nbind_a = key:f # jump\n') + others, ev, runs = build() + self.assertEqual((others, states(ev), runs), ([], ['start', 'done'], 2)) + # a folder gone, or new art: drawn again + shutil.rmtree(self.out / 'romfs-patches-pc') + self.assertEqual(build()[2], 3) + (art / 'T_X_B_White.png').write_bytes(b'picture two') + self.assertEqual(build()[2], 4) + # the lift running again makes the compile and the shaders run, not the prompts + st = json.loads((self.out / 'build-cache' / 'state.json').read_text()) + del st['steps']['lift'] + (self.out / 'build-cache' / 'state.json').write_text(json.dumps(st)) + others, ev, runs = build() + self.assertEqual((others, states(ev), runs), (['compile', 'lift', 'shaders'], ['skip'], 4)) + # a kit without art: skipped, the folders an earlier build drew stay + shutil.rmtree(kit / 'prompt-art') + self.assertEqual(build(), ([], [{'ev': 'step', 'step': 'prompts', 'state': 'skip'}], 4)) + self.assertTrue((self.out / 'romfs-patches-xbox' / 'manifest.tsv').is_file()) + @unittest.skipIf(os.name == 'nt', 'uses a POSIX script as the compiler') def test_largest_source_compiles_first(self): src, obj = self.tmp / 'src', self.tmp / 'obj' @@ -1146,7 +1311,7 @@ import json, sys, time def ev(**k): print('@@' + json.dumps(k), flush=True) if '--fail' in open(sys.argv[0] + '.mode').read(): ev(ev='step', step='check', state='start'); ev(ev='error', message='this is not game version 1.1.1'); sys.exit(1) -for s in ('check', 'exefs', 'romfs', 'lift', 'compile', 'shaders'): +for s in ('check', 'exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts'): ev(ev='step', step=s, state='start') if s == 'compile': ev(ev='progress', step=s, done=5, total=10, unit='files'); ev(ev='phase', step=s, phase='link') @@ -1184,6 +1349,18 @@ class JobTest(Tmp): self.assertEqual(j.step('check').value, 'Version 1.1.1') self.assertTrue(any('working on lift' in ln for ln in j.lines)) self.assertEqual(j.overall(), 1.0) + self.assertIsNone(j.step('prompts')) # a version without button art draws no prompts: no row + + def test_install_job_with_button_art(self): + """A version with button art shows the prompts step as a row of its own, after the shaders.""" + self.patch_tools() + ctx = self.make_ctx('ok') + (ctx.layout.app / 'prompt-art').mkdir() + j = jobs.InstallJob(ctx, self.tmp / 'g.nup', self.tmp / 'game').run_inline() + self.assertEqual(j.result, 'ok', j.error) + self.assertEqual([s.id for s in j.steps][-3:], ['shaders', 'prompts', 'ready']) + self.assertEqual(j.step('prompts').state, jobs.DONE) + self.assertEqual([s.id for s in jobs.RebuildJob(ctx, self.tmp / 'game').steps][-2:], ['shaders', 'prompts']) def test_failed_job(self): self.patch_tools() diff --git a/tools/make_game.py b/tools/make_game.py index d9dbbdf3..24a67203 100644 --- a/tools/make_game.py +++ b/tools/make_game.py @@ -19,6 +19,13 @@ Steps, each skipped when a run before finished it for the same inputs (state in GPU driver build done once into the driver's own cache, so play stops for them far less often (`ocarina.exe --prepare-shaders`); variants met in play with another bind state are still translated then, and the driver cache is per executable and is lost when the driver clears it + 7. prompts only with button art in the kit (/prompt-art, tools/make_launcher.py and make_kit.py + --prompt-art): tools/pad_prompts.py draws the Xbox, PlayStation and keyboard and mouse button prompts + from it into /romfs-patches-* (the Controls menu's Button prompts loads them), the keys as the + bind_ lines of /nx2navi.ini give them. Its key names everything it reads (the RomFS, the art, the + tools, those lines), so it runs again only when one of them changed or a folder is gone, and running + it makes no other step run again. It is last: a failure here leaves the game and its shaders complete. + Without art the step is skipped and the game shows its own prompts, as before The exefs and romfs steps depend only on the game files; lift and compile depend on this kit's code too. A newer kit therefore lifts and compiles again but keeps the unpacked game files, and --rebuild needs no game files at all. The @@ -36,9 +43,9 @@ version (fast_clang: the same objects, about a fifth faster). Nothing is written outside --out (but for --make-runtime-lib DIR, which writes DIR). The game folder then holds only what this PC built from the player's files: never -share it. Needs Python 3.10+ with numpy, lz4, zstandard and zbic (pip), and MSYS2 UCRT64's clang and libraries -(README-BuildKit.md lists the commands). Delete /build-cache afterwards to free about 5 GB; the next build then -compiles everything again. +share it. Needs Python 3.10+ with numpy, lz4, zstandard and zbic (pip), with fonttools and pillow too when the kit has +button art, and MSYS2 UCRT64's clang and libraries (README-BuildKit.md lists the commands). Delete /build-cache +afterwards to free about 5 GB; the next build then compiles everything again. --events adds one machine-readable line per step and progress change to stdout, each starting with "@@" and followed by a JSON object (launcher/build.py reads them): {"ev": "step", "step": ..., "state": "start" | "done" | "skip"}, @@ -62,6 +69,7 @@ from pathlib import Path KIT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(KIT / 'tools')) import nup # noqa: E402 +import prompt_art # noqa: E402 TITLE_ID = 0x0400486024DEC000 MAIN_BUILD_ID = 'e0f3ccfaf50065ae4ea01eb5aeb13b1a' # main of game 1.1.1 @@ -69,7 +77,14 @@ EXEFS_FILES = ('main', 'main.npdm', 'sdk', 'subsdk0', 'rtld') MODULES = ('main', 'sdk', 'subsdk0', 'rtld') ALIGN = 16 STATE_VERSION = 2 -STEPS = ('exefs', 'romfs', 'lift', 'compile', 'shaders') +STEPS = ('exefs', 'romfs', 'lift', 'compile', 'shaders', 'prompts') +# Steps whose key names every input they read rather than the step before them: a step before them that runs does +# not make them run again, and they make no step after them run again. +OWN_KEY_STEPS = ('prompts',) +# The Python packages tools/pad_prompts.py imports (pip name: import name), checked before a build with button art. +PROMPT_PACKAGES = {'zstandard': 'zstandard', 'fonttools': 'fontTools', 'pillow': 'PIL'} +# The line tools/pad_prompts.py prints for each overlay folder it wrote. +PROMPTS_WRITTEN = re.compile(r'^.+: \d+ files, the font grew by -?\d+ bytes$') # Bring-up fixes in the lifted SDK, each anchored on exact generated text that must occur once. LIFT_FIXES = [ @@ -1099,6 +1114,52 @@ def step_shaders(out): (out / seed).unlink(missing_ok=True) +def prompt_art_dir(): + """The kit's button art (/prompt-art, tools/prompt_art.py), or None when the kit has none.""" + art = KIT / prompt_art.FOLDER + return art if art.is_dir() else None + + +def bind_lines(out): + """The bind_ lines of the game folder's nx2navi.ini, comments cut off: the keys the keyboard and mouse prompts + show (tools/pad_prompts.py pc_keys reads the same lines).""" + try: + text = (out / 'nx2navi.ini').read_text(encoding='utf-8', errors='replace') + except OSError: + return [] + lines = (ln.split('#')[0].split(';')[0].strip() for ln in text.splitlines()) + return [ln for ln in lines if ln.lower().startswith('bind_')] + + +def step_prompts(out): + """The button prompts drawn from the kit's art into the game folder: tools/pad_prompts.py writes one overlay + folder per style and layout (prompt_art.OVERLAYS), which runtime/romfs.c loads in place of the game's button font + and prompt textures when the Controls menu's Button prompts is Xbox, PlayStation or Keyboard and mouse. Its lines + go on to the log, each folder written is a progress event.""" + total = len(prompt_art.OVERLAYS) + proc = subprocess.Popen(py_cmd(KIT / 'tools' / 'pad_prompts.py', '--art', prompt_art_dir(), '--game', out), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', + errors='replace', bufsize=1) + done = 0 + emit('progress', step='prompts', fraction=0.0, detail=f'Drawing {total} sets of button prompts') + try: + for line in proc.stdout: + line = line.rstrip('\n') + if line.startswith('{'): # its closing report, one long JSON line + continue + say(f' {line}') + if PROMPTS_WRITTEN.match(line): + done += 1 + emit('progress', step='prompts', fraction=round(done / total, 4), + detail=f'Drew {done} of {total} sets of button prompts') + finally: + rc = proc.wait() + proc.stdout.close() + if rc: + fail('drawing the button prompts failed (the lines above say why); the game still runs. Run the build again ' + 'to retry this step') + + def keep_awake(): """Hold off sleep for this process's life (Windows): the build runs for many minutes with no input, and a PC that sleeps in the middle stops the compiles. The hold ends with the process.""" @@ -1143,9 +1204,17 @@ def cpu_tag(): def step_key(step, identity, out=None): """What a step's output depends on. exefs and romfs: the game files only; lift: the unpacked ExeFS and the - lifter; compile: the lift, the runtime sources, this script and the processor (cpu_tag).""" + lifter; compile: the lift, the runtime sources, this script and the processor (cpu_tag); prompts: the game files, + the kit's button art, the tools that draw it, the bind_ lines of nx2navi.ini, and whether every folder it writes + is still there.""" if step in ('exefs', 'romfs'): return f'{step}-1|{identity}' + if step == 'prompts': + tools = tree_digest(KIT / 'tools', ('pad_prompts.py', 'prompt_art.py', 'button_icons.py')) + binds = sha256_bytes(json.dumps(bind_lines(out)).encode()) + written = all((out / f / 'manifest.tsv').is_file() for f in prompt_art.OVERLAYS) + return (f'prompts-1|{identity}|{tree_digest(prompt_art_dir(), ("**/*.png",))}|{tools}|{binds}' + f'|{"written" if written else "missing"}') lift = f'lift|{exefs_digest(out)}|{lift_digest()}' if step == 'lift': return lift @@ -1202,11 +1271,14 @@ def main(): for need in ('bin/clang.exe', 'bin/clang++.exe', 'bin/SDL3.dll', 'lib/libSDL3.dll.a', 'include/vma'): if not (msys / need).exists(): fail(f'{msys / need} is missing: install MSYS2 and the packages README-BuildKit.md lists') - for mod in ('numpy', 'lz4') + (() if a.rebuild else ('zbic',)): + packages = {'numpy': 'numpy', 'lz4': 'lz4', **({} if a.rebuild else {'zbic': 'zbic'}), + **(PROMPT_PACKAGES if prompt_art_dir() else {})} + for pip_name, mod in packages.items(): try: __import__(mod) except ImportError: - fail(f'the Python package {mod} is missing: pip install numpy lz4 zstandard zbic') + extra = ' fonttools pillow' if prompt_art_dir() else '' + fail(f'the Python package {pip_name} is missing: pip install numpy lz4 zstandard zbic{extra}') cache.mkdir(parents=True, exist_ok=True) reset = state.get('version') != STATE_VERSION or state.get('input') != src.identity if reset: @@ -1217,7 +1289,7 @@ def main(): romfs_job = None runs = {'exefs': lambda: step_exefs(src, out), 'romfs': lambda: step_romfs(src, out, min(a.jobs, 8)), 'lift': lambda: step_lift(out, cache), 'compile': lambda: step_compile(out, cache, msys, a.jobs, romfs_job), - 'shaders': lambda: step_shaders(out)} + 'shaders': lambda: step_shaders(out), 'prompts': lambda: step_prompts(out)} state_lock = threading.Lock() # the RomFS step records itself from its own thread def save(): @@ -1244,18 +1316,22 @@ def main(): try: for name in STEPS: - key = step_key(name, src.identity, out) - if state['steps'].get(name) == key: + if name == 'prompts' and not prompt_art_dir(): + say('prompts: none to draw, the kit has no button art') + emit('step', step=name, state='skip') + elif state['steps'].get(name) == step_key(name, src.identity, out): say(f'{name}: done before') emit('step', step=name, state='skip') else: - # a step that runs makes every step after it run too: their keys hash its output, not its key + # a step that runs makes every step after it run too: their keys hash its output, not its key (but + # for OWN_KEY_STEPS, whose keys name their inputs) with state_lock: for later in STEPS[STEPS.index(name):]: - state['steps'].pop(later, None) + if later == name or not {name, later} & set(OWN_KEY_STEPS): + state['steps'].pop(later, None) say(f'{name}:') emit('step', step=name, state='start') - if name == 'shaders' and romfs_job: # the shaders come out of the RomFS image + if name in ('shaders', 'prompts') and romfs_job: # they read the RomFS image romfs_job.join() if name == 'romfs' and a.until != 'romfs': # The RomFS image is mostly disk work, the lift and the compile are processor work: they run side diff --git a/tools/make_kit.py b/tools/make_kit.py index 7822ebd3..9c875dfa 100644 --- a/tools/make_kit.py +++ b/tools/make_kit.py @@ -1,12 +1,19 @@ """Assemble the build kit: what a player needs to build Ocarina of Time on their own PC with tools/make_game.py. python tools/make_kit.py --xlate [--ffx amd_fidelityfx_vk.dll] - [--dlss nvngx_dlss.dll | --no-dlss] [--out build/kit] [--zip] + [--dlss nvngx_dlss.dll | --no-dlss] [--prompt-art FOLDER [FOLDER ...]] + [--out build/kit] [--zip] The kit holds this repository's runtime/, recomp/, the build tools, the shader translator built from tools/xlate, NVIDIA's DLSS library with its notice (tools/dlss_pin.py: downloaded from NVIDIA's DLSS SDK repository and checked, -or --dlss; not with --no-dlss), AMD's FSR 3.1 library with --ffx, and README-BuildKit.md; no game code, data or -anything generated from the game. check_content runs over the folder before it is zipped. +or --dlss; not with --no-dlss), AMD's FSR 3.1 library with --ffx, the button art with --prompt-art, and +README-BuildKit.md; no game code, data or anything generated from the game. check_content runs over the folder before +it is zipped. + +--prompt-art names folders that hold XGamepad, P5Gamepad and Keyboard_Mouse, or those three folders themselves: the +pictures tools/pad_prompts.py reads go into the kit's prompt-art folder (tools/prompt_art.py art_files), and +make_game.py draws the Xbox, PlayStation and keyboard and mouse prompts from them. Without it the kit builds the game +with the game's own prompts only. """ import argparse import shutil @@ -17,7 +24,7 @@ from pathlib import Path 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') + 'shader_census.py', 'shader_list.py', 'zstd_compat.py', 'pad_prompts.py', 'prompt_art.py', 'button_icons.py') 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'} @@ -39,11 +46,16 @@ def main(): ap.add_argument('--dlss', type=Path, help="NVIDIA's nvngx_dlss.dll 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 kit') + 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=ROOT / 'build' / 'kit') ap.add_argument('--zip', action='store_true') a = ap.parse_args() sys.path.insert(0, str(ROOT / 'tools')) import dlss_pin + import prompt_art + if a.prompt_art: + prompt_art.art_files(a.prompt_art) # the art is all there before anything is downloaded dlss = None if a.no_dlss else dlss_pin.resolve(a.dlss, ROOT / 'build' / 'dl') kit = a.out / NAME if kit.exists(): @@ -65,6 +77,9 @@ def main(): shutil.copy2(a.ffx, kit / '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, kit / 'bin') + if a.prompt_art: # make_game.py draws the button prompts from it (its prompts step) + n, size = prompt_art.copy_art(a.prompt_art, kit / prompt_art.FOLDER) + print(f'button art: {n} pictures, {size / 1e6:.1f} MB') import check_content bad = [f'{p.relative_to(kit)}: {m}' for p in sorted(kit.rglob('*')) if p.is_file() for m in check_content.problems_for(p, allow_large=p.suffix in ('.exe', '.dll'))] # the translator, the DLLs diff --git a/tools/make_launcher.py b/tools/make_launcher.py index 83974d58..16552b4a 100644 --- a/tools/make_launcher.py +++ b/tools/make_launcher.py @@ -1,7 +1,7 @@ """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] - [--out build/launcher-release] + [--prompt-art FOLDER [FOLDER ...]] [--out build/launcher-release] [--feed owner/name] [--notes notes.md] [--publish] @@ -22,6 +22,8 @@ The release folder (--out) holds: pins, downloaded from NVIDIA's DLSS SDK repository and checked, or --dlss; not with --no-dlss), 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), toolchain.lock.json (the MSYS2 packages of today, pinned), feed.json, VERSION, the licence files NX2navi-Setup-.exe launcher/native/setup.c with the folder above appended as a zip: the one file @@ -30,6 +32,10 @@ The release folder (--out) holds: NX2navi-runtime-.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. + --publish uploads the four files as GitHub release v 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. """ @@ -49,6 +55,7 @@ sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / 'tools')) import check_content # noqa: E402 import dlss_pin # noqa: E402 +import prompt_art # noqa: E402 from launcher import toolchain # noqa: E402 from launcher.paths import Layout # noqa: E402 @@ -64,7 +71,7 @@ PINS = { 'ac87609db3f715404dd41a989c2f1706ea1512f3d86217f938d11c63af8eb505'), } 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') + 'shader_census.py', 'shader_list.py', 'zstd_compat.py', 'pad_prompts.py', 'prompt_art.py', 'button_icons.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'} @@ -163,7 +170,7 @@ def build_python(py): # ---------------------------------------------------------------- app// -def build_app(app, ver, xlate, feed_repo, lock, ffx=None, dlss=None): +def build_app(app, ver, xlate, feed_repo, lock, ffx=None, dlss=None, art=None): copy_tree(ROOT / 'launcher', app / 'launcher') for d in ('runtime', 'recomp'): copy_tree(ROOT / d, app / d) @@ -183,6 +190,9 @@ def build_app(app, ver, xlate, feed_repo, lock, ffx=None, dlss=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 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') (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') @@ -295,6 +305,8 @@ 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('--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') 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') @@ -310,6 +322,8 @@ def main(): 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 out = a.out shutil.rmtree(out, ignore_errors=True) folder = out / 'NX2navi' @@ -320,7 +334,7 @@ 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) + build_app(folder / 'app' / ver, ver, a.xlate, a.feed, lock, a.ffx, dlss, a.prompt_art) (folder / 'app' / 'current.txt').write_text(ver + '\n', encoding='utf-8') build_python(folder / 'python') cc, windres = native_compiler(lock) diff --git a/tools/pad_prompts.py b/tools/pad_prompts.py index 1148afd7..84f14516 100644 --- a/tools/pad_prompts.py +++ b/tools/pad_prompts.py @@ -4,10 +4,11 @@ player's machine. python tools/pad_prompts.py --art FOLDER --game FOLDER [--style xbox|playstation|pc] [--layout position|label] [--binds nx2navi.ini] ---art is a folder of button art with the file names of STYLES and KEY_ART below: the Source folder of a button prompt -art pack, which holds XGamepad/Default, XGamepad/Light, P5Gamepad/Default, P5Gamepad/Light, Keyboard_Mouse/Dark and -Keyboard_Mouse/White (128x128 PNGs: a dark button or key with a white label, or a light one with a dark or coloured -label). The art is read where it lies and never enters the repository. +--art is a folder of button art with the file names of STYLES (tools/prompt_art.py) and KEY_ART below: the Source +folder of a button prompt art pack, which holds XGamepad/Default, XGamepad/Light, P5Gamepad/Default, P5Gamepad/Light, +Keyboard_Mouse/Dark and Keyboard_Mouse/White (128x128 PNGs: a dark button or key with a white label, or a light one +with a dark or coloured label), or a release's prompt-art folder, which holds the same files (tools/make_game.py runs +this tool on it). The art is read where it lies and never enters the repository. --game is the folder of the port's exe and romfs-index.bin. The RomFS is read from its romfs folder (index.json and romfs.bin, as the setup writes them); --romfs names another, which may also be a folder of extracted files. Each pad style and layout becomes the overlay folder /romfs-patches-