mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 17:09:57 -04:00
Mods, on the home screen, opens the game's mods folder, adds a downloaded mod from its .zip, .7z or .rar file or its folder, switches each mod on or off, and sets the load order (mods_disabled and mods_order in nx2navi.ini, written as the game writes them). Each mod's description says what it changes, which other mod changes the same files and which of the two wins, and why the game leaves it off when it does. Before the game starts, Play puts the mods folder in order (launcher/mods.py prepare): an archive in it is unpacked into a folder of its name and kept, a mod whose files sit a folder or more down has them moved up, and a romfs, exefs or cheats folder put straight into the mods folder becomes a mod of its own. An archive that would write outside its folder is refused, and nothing is deleted. The launcher's build (tools/make_game.py) left the mods' code cheats and ExeFS patch files out, so those mods stayed off. The lift now turns them into patch sets first, as the setup does, with the files in the lift's key and build-cache/mod-code.json recording what it took up. Play builds the game again before it starts when an enabled mod brings code no build has seen. The shaders' key names the lift and the compile without the mods' code, so such a build compiles only what the patches change and leaves the shaders prepared. tools/mod_files.py is the one place the tools, the build and the launcher find a mod's parts. Tested: a zipped mod holding its own folder, put into a mods folder, was unpacked and moved up by the launcher's code, and the game served its file in place of the game's. The lifter took a code cheat and a pchtxt patch made for the game's main as patch sets. The launcher's and the setup's tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
4.4 KiB
Python
100 lines
4.4 KiB
Python
"""The player's mods folder as the game reads it (runtime/mods.c): where each mod keeps its parts, and the files of the
|
|
mods that change the game's code.
|
|
|
|
One folder per mod (a symbolic link or junction to a folder counts). A part (romfs, romfslite, exefs, cheats) is the
|
|
first of <mod>/<part>, <mod>/atmosphere/contents/<title>/<part>, <mod>/contents/<title>/<part> and
|
|
<mod>/<title>/<part> that is a folder (runtime/mods.c find_root).
|
|
|
|
The files that change the game's code are its Atmosphere cheat file for main's build (<cheats>/<build id>.txt, the
|
|
name in any case) and the ExeFS patch files (.ips, .pchtxt) of its exefs part and of
|
|
<mod>/atmosphere/exefs_patches/<name>/. The lifted game cannot be patched while it runs, so the build turns them into
|
|
patch sets (tools/cheats.py, tools/exefs_patches.py) and the runtime switches those on with their mod; a file the
|
|
build did not see waits for the next one. tools/make_game.py builds them in and records what it built
|
|
(build-cache/mod-code.json, code_record); the launcher compares that record with the folder (launcher/mods.py) to
|
|
rebuild the game before it starts when a mod brought new ones.
|
|
|
|
Standard library only: the launcher imports it from the kit's tools folder.
|
|
"""
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
TITLE = '0400486024DEC000' # the game's title id: a mod's Atmosphere folders are named after it
|
|
PLACES = ('{}', f'atmosphere/contents/{TITLE}/{{}}', f'contents/{TITLE}/{{}}', f'{TITLE}/{{}}')
|
|
PATCH_SUFFIXES = ('.ips', '.pchtxt')
|
|
|
|
|
|
def mod_folders(mods):
|
|
"""The mod folders of the mods folder, by name (as the setup's tools list them: Python's order of the names)."""
|
|
mods = Path(mods)
|
|
if not mods.is_dir():
|
|
return []
|
|
return sorted((d for d in mods.iterdir() if d.is_dir()), key=lambda d: d.name)
|
|
|
|
|
|
def part(mod, name):
|
|
"""The folder of a mod's part (romfs, romfslite, exefs, cheats): the first of its places that is a folder, or
|
|
None."""
|
|
return next((Path(mod) / w.format(name) for w in PLACES if (Path(mod) / w.format(name)).is_dir()), None)
|
|
|
|
|
|
def cheat_files(mods, bid):
|
|
"""[(mod folder name, cheat file)] of the folders of mods, by name: <cheats>/<bid>.txt, the name in any case. bid is
|
|
the first 8 bytes of main's build id in hex, as cheat files name themselves (tools/cheats.py build_id)."""
|
|
out = []
|
|
for mod in mod_folders(mods):
|
|
folder = part(mod, 'cheats')
|
|
if folder:
|
|
f = next((p for p in sorted(folder.iterdir()) if p.is_file() and p.name.lower() == f'{bid}.txt'.lower()),
|
|
None)
|
|
if f:
|
|
out.append((mod.name, f))
|
|
return out
|
|
|
|
|
|
def patch_files(mods):
|
|
"""[(mod folder name, path in the mod, file)] of the folders of mods, by name: the .ips and .pchtxt files of the
|
|
exefs part and of atmosphere/exefs_patches/<name>/."""
|
|
out = []
|
|
for mod in mod_folders(mods):
|
|
folders = []
|
|
exefs = part(mod, 'exefs')
|
|
if exefs:
|
|
folders.append(exefs)
|
|
patches = mod / 'atmosphere' / 'exefs_patches'
|
|
if patches.is_dir():
|
|
folders += sorted(d for d in patches.iterdir() if d.is_dir())
|
|
for folder in folders:
|
|
for f in sorted(folder.iterdir()):
|
|
if f.is_file() and f.suffix.lower() in PATCH_SUFFIXES:
|
|
out.append((mod.name, f.relative_to(mod).as_posix(), f))
|
|
return out
|
|
|
|
|
|
def main_build_id(main):
|
|
"""The first 8 bytes of main's build id in upper-case hex (the cheat files' name), '' when main cannot be read."""
|
|
try:
|
|
with open(main, 'rb') as f:
|
|
head = f.read(0x48)
|
|
except OSError:
|
|
return ''
|
|
return head[0x40:0x48].hex().upper() if len(head) == 0x48 and head[:4] == b'NSO0' else ''
|
|
|
|
|
|
def code_files(mods, bid):
|
|
"""[(mod folder name, path in the mod, file)] of every file of the mods folder that changes the game's code: the
|
|
cheat files for bid, then the ExeFS patch files."""
|
|
return ([(m, f.relative_to(Path(mods) / m).as_posix(), f) for m, f in cheat_files(mods, bid)]
|
|
+ patch_files(mods))
|
|
|
|
|
|
def code_record(mods, bid):
|
|
"""{"<mod>/<path in the mod>": sha256 of its bytes} of code_files: what a build that lifts them in takes. A file
|
|
that cannot be read is left out (the tools would fail on it too, and the next build tries again)."""
|
|
out = {}
|
|
for mod, rel, f in code_files(mods, bid):
|
|
try:
|
|
out[f'{mod}/{rel}'] = hashlib.sha256(f.read_bytes()).hexdigest()
|
|
except OSError:
|
|
pass
|
|
return out
|