mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
143
tools/prompt_art.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user