Carry the button art in releases and draw the button prompts during the game build.

make_launcher.py and make_kit.py take --prompt-art (folders holding XGamepad, P5Gamepad and Keyboard_Mouse, or those
folders) and copy only the pictures pad_prompts.py reads into the release's prompt-art folder; tools/prompt_art.py
holds the art tables and that list, with no imaging packages. make_game.py's new last step, prompts, runs
pad_prompts.py on that art into the game folder's romfs-patches-* folders. Its key names everything it reads (the
RomFS, the art, the tools, the bind_ lines of nx2navi.ini, the folders being there), so it runs again only when one of
those changed, and it makes no other step run. A kit without art skips it. The launcher's Python gets fonttools and
pillow, and its jobs show a Draw the Button Prompts row when the version has art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-09 06:56:58 -05:00
parent 3d25f00cd2
commit 8e30ad7927
14 changed files with 539 additions and 108 deletions

2
.gitignore vendored
View File

@@ -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

View File

@@ -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.

View File

@@ -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 <art pack>\Source --game <install folder>

View File

@@ -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\<current>\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/<version>/ 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/<version>/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 `<launcher>\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/<version>/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-<version>.exe`
(the setup program with the launcher folder appended as a zip), `NX2navi-app-<version>.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

View File

@@ -21,6 +21,12 @@ def command(layout, args):
'--events']
def has_prompt_art(layout):
"""True when this version holds button art (app/<version>/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):

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 (<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 <out>/romfs-patches-* (the Controls menu's Button prompts loads them), the keys as the
bind_ lines of <out>/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 <out>/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 <out>/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 (<kit>/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

View File

@@ -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 <dir with nx2navi_xlate.exe and .id> [--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

View File

@@ -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-<version>.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-<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.
--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.
"""
@@ -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/<version>/
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)

View File

@@ -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 <game>/romfs-patches-<style> (layout position) or
@@ -39,7 +40,8 @@ What changes, the rest of each file staying as it is:
game sizes the buffer it loads a file into from the table, so the font's entry is raised by as much
(button_icons.grow_restbl).
Needs fontTools, Pillow and zstandard (tools/requirements-py314.txt).
Needs fontTools, Pillow and zstandard (tools/requirements-py314.txt; the launcher's Python has them,
tools/requirements-launcher.txt).
"""
import argparse
import hashlib
@@ -56,6 +58,7 @@ from fontTools.ttLib import TTFont
sys.path.insert(0, str(Path(__file__).resolve().parent))
import button_icons # noqa: E402 (bfotf_decode, bfotf_encode, redraw_font, grow_restbl)
from prompt_art import BLANKS, KM, OPTIONS, SHARE, STYLES, overlay_folder # noqa: E402 (the art, the folders)
FONT = 'Font2/Scalable/External.Nin_Ounce_NVN2.bfotf.zs'
TEXTURES = 'UI/Layout2Archive/Common.Product.100.Nin_Ounce_NVN2.bntx.zs'
@@ -78,51 +81,6 @@ FACE = {'position': {'A': 'east', 'B': 'south', 'X': 'north', 'Y': 'west'},
'label': {'A': 'south', 'B': 'east', 'X': 'west', 'Y': 'north'}}
def _sticks(folder, stick, press, rotate):
"""The stick pictures of one style: <stick>_<direction> for L and R."""
out = {}
for side, name in (('L', stick[0]), ('R', stick[1])):
base = f'{folder}{name}'
out.update({f'{side}STICK': f'{base}.png', f'{side}STICK_UP': f'{base}_UP.png',
f'{side}STICK_DOWN': f'{base}_Down.png', f'{side}STICK_LEFT': f'{base}_Left.png',
f'{side}STICK_RIGHT': f'{base}_Right.png', f'{side}STICK_UPDOWN': f'{base}_Y.png',
f'{side}STICK_LEFTRIGHT': f'{base}_X.png', f'{side}STICK_PRESS': f'{folder}{press[side == "R"]}'})
out['RSTICK_ROTATE'] = f'{folder}{rotate}'
return out
def _dpad(folder, name, up):
return {'DPAD': f'{folder}{name}.png', 'DPAD_UP': f'{folder}{name}_{up}.png', 'DPAD_DOWN': f'{folder}{name}_Down.png',
'DPAD_LEFT': f'{folder}{name}_Left.png', 'DPAD_RIGHT': f'{folder}{name}_Right.png',
'DPAD_UPDOWN': f'{folder}{name}_Y.png', 'DPAD_LEFTRIGHT': f'{folder}{name}_X.png'}
# Per style: glyph, the dark pictures the button font and the action prompt's letter are drawn from (by Switch button,
# the face buttons by their place on the pad); note, the light ones the ocarina's note buttons are drawn from. A
# value of OPTIONS is drawn here (options_art): the art shows PlayStation's Options button as its word over a small
# pill, which is unreadable at text size, so the prompt is the Create button's pill with three bars, as on the pad.
X, XL, P, PL = 'XGamepad/Default/T_X_', 'XGamepad/Light/T_X_', 'P5Gamepad/Default/T_P5_', 'P5Gamepad/Light/T_P5_'
OPTIONS = 'options'
STYLES = {
'xbox': {
'glyph': {'south': f'{X}A_White.png', 'east': f'{X}B_White.png', 'west': f'{X}X_White.png',
'north': f'{X}Y_White.png', 'L': f'{X}LB.png', 'R': f'{X}RB.png', 'ZL': f'{X}LT.png',
'ZR': f'{X}RT.png', 'PLUS': f'{X}X.png', 'MINUS': f'{X}Share.png', # Menu (three bars), View
**_dpad(X, 'Dpad', 'Up'), **_sticks(X, ('L', 'R'), ('Left_Stick_Click.png', 'Right_Stick_Click.png'),
'R_2D.png')},
'note': {'south': f'{XL}A_Color_Light.png', 'east': f'{XL}B_Color_Light.png', 'west': f'{XL}X_Color_Light.png',
'north': f'{XL}Y_Color_Light.png', 'L': f'{XL}LB_Light.png', 'R': f'{XL}RB_Light.png'},
},
'playstation': {
'glyph': {'south': f'{P}Cross.png', 'east': f'{P}Circle.png', 'west': f'{P}Square.png',
'north': f'{P}Triangle.png', 'L': f'{P}L1.png', 'R': f'{P}R1.png', 'ZL': f'{P}L2.png',
'ZR': f'{P}R2.png', 'PLUS': OPTIONS, 'MINUS': 'XGamepad/Default/T_X_Share-1.png', # a share pill
**_dpad(P, 'Dpad', 'UP'), **_sticks(P, ('L', 'R'), ('L3.png', 'R3.png'), 'R_2D.png')},
'note': {'south': f'{PL}Cross_Color_Light.png', 'east': f'{PL}Circle_Color_Light.png',
'west': f'{PL}Square_Color_Light.png', 'north': f'{PL}Triangle_Color_Light.png',
'L': f'{PL}L1_Light.png', 'R': f'{PL}R1_Light.png'},
},
}
# The textures drawn again: the action prompt's letter masks and the ocarina's note buttons, by Switch button.
MASKS = {'KeyTexFontA_00': 'A', 'KeyTexFontB_00': 'B', 'KeyTexFontX_00': 'X', 'KeyTexFontY_00': 'Y'}
NOTES = {'OcarinaButtonA_00': 'A', 'OcarinaButtonB_00': 'B', 'OcarinaButtonX_00': 'X', 'OcarinaButtonY_00': 'Y',
@@ -155,7 +113,6 @@ KEY_ART = {'space': 'Space', 'tab': 'Tab', 'lshift': 'Shift', 'rshift': 'Shift',
# What a blank key shows for a key the art has no picture of (label_art); any other shows its name.
KEY_LABELS = {'comma': ',', 'period': '.', 'equals': '=', 'num_decimal': '.', 'backslash': '\\', 'mouse_x1': 'M4',
'mouse_x2': 'M5'}
KM = 'Keyboard_Mouse/'
def pc_keys(ini_text=None):
@@ -312,7 +269,7 @@ def grid_art(art, cells):
def label_art(art, key, shade):
"""A key the art has no picture of: its blank key with the key's name written on it."""
blank = load_art(art, f'{KM}Blanks/T_Blank_{"Black" if shade == "Dark" else "White"}_Normal.png')
blank = load_art(art, BLANKS[shade])
name = KEY_LABELS.get(key, key.upper())
d = ImageDraw.Draw(blank)
x0, y0, x1, y1 = blank.getchannel('A').point(lambda a: 255 if a > 127 else 0).getbbox()
@@ -331,7 +288,7 @@ def label_art(art, key, shade):
def options_art(art):
"""PlayStation's Options button as the art draws its Create button's neighbour on the pad: the dark pill of the
share picture with three white bars, drawn four times larger and scaled down for smooth edges."""
share = load_art(art, 'XGamepad/Default/T_X_Share-1.png')
share = load_art(art, SHARE)
x0, y0, x1, y1 = share.getchannel('A').point(lambda a: 255 if a > 127 else 0).getbbox()
k = 4
big = Image.new('RGBA', (share.width * k, share.height * k), (0, 0, 0, 0))
@@ -859,7 +816,7 @@ def main():
for style in [args.style] if args.style else sorted(STYLES) + ['pc']:
# the keyboard follows no pad layout: one folder, romfs-patches-pc
for layout in [None] if style == 'pc' else [args.layout] if args.layout else sorted(FACE):
out = args.out or args.game / f'romfs-patches-{style}{"-label" if layout == "label" else ""}'
out = args.out or args.game / overlay_folder(style, layout)
glyph, note = pc_pictures(args.art, keys) if style == 'pc' else pad_pictures(style, layout)
new_otf = draw_font(otf, glyph, traced, style != 'pc')
blob = button_icons.bfotf_encode(new_otf, key, font_blob)

143
tools/prompt_art.py Normal file
View File

@@ -0,0 +1,143 @@
"""The button art tools/pad_prompts.py draws the button prompts from: the pictures of each pad style (STYLES), the
keyboard and mouse folders (KM), and art_files, the files a release carries so the build can draw the prompts on the
player's PC (tools/make_launcher.py and tools/make_kit.py --prompt-art, tools/make_game.py's prompts step).
Standard library only, so the release tools need no imaging packages.
The art is 128x128 PNGs in the folders of button prompt art packs: XGamepad/Default and /Light, P5Gamepad/Default and
/Light, Keyboard_Mouse/Dark, /White and /Blanks. It never enters the repository: a release copies it from the folders
--prompt-art names into its prompt-art folder, which make_game.py passes to pad_prompts.py as --art.
"""
import shutil
import sys
from pathlib import Path
# The art folders, each named as in the art packs; --art holds all three.
ART_SETS = ('XGamepad', 'P5Gamepad', 'Keyboard_Mouse')
# The folder of a release that holds the art (app/<version>/prompt-art, the build kit's prompt-art).
FOLDER = 'prompt-art'
def _sticks(folder, stick, press, rotate):
"""The stick pictures of one style: <stick>_<direction> for L and R."""
out = {}
for side, name in (('L', stick[0]), ('R', stick[1])):
base = f'{folder}{name}'
out.update({f'{side}STICK': f'{base}.png', f'{side}STICK_UP': f'{base}_UP.png',
f'{side}STICK_DOWN': f'{base}_Down.png', f'{side}STICK_LEFT': f'{base}_Left.png',
f'{side}STICK_RIGHT': f'{base}_Right.png', f'{side}STICK_UPDOWN': f'{base}_Y.png',
f'{side}STICK_LEFTRIGHT': f'{base}_X.png', f'{side}STICK_PRESS': f'{folder}{press[side == "R"]}'})
out['RSTICK_ROTATE'] = f'{folder}{rotate}'
return out
def _dpad(folder, name, up):
return {'DPAD': f'{folder}{name}.png', 'DPAD_UP': f'{folder}{name}_{up}.png', 'DPAD_DOWN': f'{folder}{name}_Down.png',
'DPAD_LEFT': f'{folder}{name}_Left.png', 'DPAD_RIGHT': f'{folder}{name}_Right.png',
'DPAD_UPDOWN': f'{folder}{name}_Y.png', 'DPAD_LEFTRIGHT': f'{folder}{name}_X.png'}
# Per style: glyph, the dark pictures the button font and the action prompt's letter are drawn from (by Switch button,
# the face buttons by their place on the pad); note, the light ones the ocarina's note buttons are drawn from. A
# value of OPTIONS is drawn by pad_prompts.py (options_art) from SHARE: the art shows PlayStation's Options button as
# its word over a small pill, which is unreadable at text size, so the prompt is the Create button's pill with three
# bars, as on the pad.
X, XL, P, PL = 'XGamepad/Default/T_X_', 'XGamepad/Light/T_X_', 'P5Gamepad/Default/T_P5_', 'P5Gamepad/Light/T_P5_'
OPTIONS = 'options'
SHARE = 'XGamepad/Default/T_X_Share-1.png'
STYLES = {
'xbox': {
'glyph': {'south': f'{X}A_White.png', 'east': f'{X}B_White.png', 'west': f'{X}X_White.png',
'north': f'{X}Y_White.png', 'L': f'{X}LB.png', 'R': f'{X}RB.png', 'ZL': f'{X}LT.png',
'ZR': f'{X}RT.png', 'PLUS': f'{X}X.png', 'MINUS': f'{X}Share.png', # Menu (three bars), View
**_dpad(X, 'Dpad', 'Up'), **_sticks(X, ('L', 'R'), ('Left_Stick_Click.png', 'Right_Stick_Click.png'),
'R_2D.png')},
'note': {'south': f'{XL}A_Color_Light.png', 'east': f'{XL}B_Color_Light.png', 'west': f'{XL}X_Color_Light.png',
'north': f'{XL}Y_Color_Light.png', 'L': f'{XL}LB_Light.png', 'R': f'{XL}RB_Light.png'},
},
'playstation': {
'glyph': {'south': f'{P}Cross.png', 'east': f'{P}Circle.png', 'west': f'{P}Square.png',
'north': f'{P}Triangle.png', 'L': f'{P}L1.png', 'R': f'{P}R1.png', 'ZL': f'{P}L2.png',
'ZR': f'{P}R2.png', 'PLUS': OPTIONS, 'MINUS': SHARE, # a share pill
**_dpad(P, 'Dpad', 'UP'), **_sticks(P, ('L', 'R'), ('L3.png', 'R3.png'), 'R_2D.png')},
'note': {'south': f'{PL}Cross_Color_Light.png', 'east': f'{PL}Circle_Color_Light.png',
'west': f'{PL}Square_Color_Light.png', 'north': f'{PL}Triangle_Color_Light.png',
'L': f'{PL}L1_Light.png', 'R': f'{PL}R1_Light.png'},
},
}
# The keyboard and mouse: a key in each shade (Dark: a dark key with a white label, the button font and the action
# prompt; White: the other way round, the ocarina's notes), and the blank key a key the art has no picture of gets its
# name written on (pad_prompts.py label_art).
KM = 'Keyboard_Mouse/'
SHADES = ('Dark', 'White')
BLANKS = {'Dark': f'{KM}Blanks/T_Blank_Black_Normal.png', 'White': f'{KM}Blanks/T_Blank_White_Normal.png'}
def overlay_folder(style, layout=None):
"""The game folder's overlay folder of a style ('pc' for the keyboard and mouse) and pad layout ('position' or
'label'), as runtime/input_map.c input_prompts_folder names it."""
return f'romfs-patches-{style}' + ('-label' if layout == 'label' else '')
# Every folder pad_prompts.py writes without --style and --layout.
OVERLAYS = tuple(overlay_folder(s, layout) for s in sorted(STYLES) for layout in ('label', 'position')) + \
(overlay_folder('pc'),)
def art_roots(folders):
"""ART_SETS name -> the folder that holds it, from folders that each hold some of ART_SETS or are one of them
(for art spread over several packs). A set named itself wins over one found in a folder that holds it, so a pack's
Source folder and another pack's Keyboard_Mouse take the latter's keys. Exits naming a set that is missing, or
found twice at the same rank."""
ranked = ({}, {}) # sets named themselves, sets found inside a folder
for f in map(Path, folders):
if not f.is_dir():
sys.exit(f'{f}: no such folder')
rank, found = (0, [(f.name, f.parent)]) if f.name in ART_SETS else \
(1, [(n, f) for n in ART_SETS if (f / n).is_dir()])
if not found:
sys.exit(f'{f} holds none of {", ".join(ART_SETS)}')
for name, root in found:
seen = ranked[rank].get(name)
if seen and seen.resolve() != root.resolve():
sys.exit(f'{name} is in both {seen} and {root}: name the one to use')
ranked[rank][name] = root
roots = {**ranked[1], **ranked[0]}
missing = [n for n in ART_SETS if n not in roots]
if missing:
sys.exit(f'no {", ".join(missing)} folder in {", ".join(map(str, folders))}')
return roots
def art_files(folders):
"""Relative path (as pad_prompts.py --art reads it) -> source file, for every picture pad_prompts.py can read: the
pictures of STYLES, SHARE, the blank keys, and every PNG of Keyboard_Mouse/Dark and /White, as a player may bind
any key. Exits naming the pictures that are missing."""
roots = art_roots(folders)
wanted = {rel for style in STYLES.values() for part in style.values() for rel in part.values() if rel != OPTIONS}
wanted |= {SHARE, *BLANKS.values()}
files, missing = {}, []
for rel in sorted(wanted):
src = roots[rel.split('/')[0]] / rel
if src.is_file():
files[rel] = src
else:
missing.append(str(src))
for shade in SHADES:
keys = sorted((roots['Keyboard_Mouse'] / KM / shade).glob('*.png'))
if not keys:
missing.append(str(roots['Keyboard_Mouse'] / KM / shade / '*.png'))
files.update({f'{KM}{shade}/{p.name}': p for p in keys})
if missing:
sys.exit('button art missing:\n ' + '\n '.join(missing[:20]))
return files
def copy_art(folders, dest):
"""Copies art_files(folders) into dest (a release's prompt-art folder, emptied first); returns (files, bytes)."""
files = art_files(folders)
shutil.rmtree(dest, ignore_errors=True)
for rel, src in files.items():
(dest / rel).parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest / rel)
return len(files), sum(p.stat().st_size for p in files.values())

View File

@@ -1,9 +1,12 @@
# Packages of the launcher release's bundled Python 3.14.9 (tools/make_launcher.py): what the launcher and
# tools/make_game.py import. numpy: recomp/gen.py and the window's drawing (launcher/ui/art.py). lz4: NSO segments.
# zstandard: tools/nup.py. truststore: Windows' own certificate check for the launcher's downloads (launcher/net.py),
# pinned to its wheel's sha256 too (uv checks the hashes a line gives). zbic is not bundled: the launcher downloads
# it from PyPI on the player's PC.
# zstandard: tools/nup.py and tools/pad_prompts.py. fonttools, pillow: tools/pad_prompts.py, which make_game.py runs
# to draw the button prompts when the release holds button art (prompt-art). truststore: Windows' own certificate
# check for the launcher's downloads (launcher/net.py), pinned to its wheel's sha256 too (uv checks the hashes a line
# gives). zbic is not bundled: the launcher downloads it from PyPI on the player's PC.
numpy==2.5.4
lz4==4.4.5
zstandard==0.25.0
fonttools==4.68.0
pillow==12.3.0
truststore==0.10.5 --hash=sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c