Identify internal game versions and updates before preparation

This commit is contained in:
Erick Sanchez committed 2026-10-27 16:48:18 -06:00
1 parent 7c6c83f56d
commit 26a25773a8
13 files changed
+475 -17

No files matched your search

+9 -2
View File
@@ -34,7 +34,7 @@ def build_lock():
fcntl.flock(lock,fcntl.LOCK_UN)
def load_builder():
def load_builder(package_metadata=None):
spec = importlib.util.spec_from_file_location('nxnative_builder', REPO / 'scripts/build_ipa_from_nsp.py')
assert spec is not None and spec.loader is not None
builder = importlib.util.module_from_spec(spec)
@@ -42,6 +42,12 @@ def load_builder():
spec.loader.exec_module(builder)
# Preserve the venv executable path: resolving its symlink selects global Python.
builder.TOOL_PATHS.insert(0, str(Path(sys.executable).parent))
if package_metadata is not None:
metadata_spec = importlib.util.spec_from_file_location('nxnative_inspection', REPO/'scripts/inspect_game.py')
metadata = importlib.util.module_from_spec(metadata_spec)
metadata_spec.loader.exec_module(metadata)
value = json.loads(Path(package_metadata).read_text())
builder.scan_nsps = lambda folder: metadata.family_from_metadata(folder, value)
return builder
@@ -96,6 +102,7 @@ def parser():
p.add_argument('-l', '--language', help='first build defaults to es-419; later builds retain identity settings')
p.add_argument('--bundle-id', help='use the SAME effective identifier when updating an installed app')
p.add_argument('--app-name')
p.add_argument('--package-metadata',type=Path,help='verified preflight metadata; changed inputs are rejected')
p.add_argument('--package', help='explicit game update, such as v327680')
p.add_argument('--gpu', help='pipeline archive target; first build defaults to none')
p.add_argument('--show-fps', action=argparse.BooleanOptionalAction, default=None, help='retain FPS display choice across builds')
@@ -184,7 +191,7 @@ def main(argv=None):
keys = select_keys(args)
if any(p.suffix.lower() in {'.nsz','.xcz'} for p in packages):
subprocess.run([sys.executable,'-m','pip','install','nsz'],check=True)
builder = load_builder()
builder = load_builder(args.package_metadata) if args.package_metadata else load_builder()
code = builder.cli(command + ['-k',str(keys)])
if code: return code
info = verify_ipa(output)
+3 -1
View File
@@ -15,6 +15,7 @@ def main(argv=None):
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('game_dir',type=Path)
parser.add_argument('--keys-folder',type=Path,required=True)
parser.add_argument('--package-metadata',type=Path)
parser.add_argument('--dry-run',action='store_true')
args=parser.parse_args(argv)
game=args.game_dir.expanduser().resolve(strict=True)
@@ -48,7 +49,8 @@ def main(argv=None):
if any(p.suffix.lower() in {'.nsz','.xcz'} for p in packages):
import subprocess
subprocess.run([sys.executable,'-m','pip','install','nsz'],check=True)
code=load_builder().cli(command+['-k',str(keys)])
builder=load_builder(args.package_metadata) if args.package_metadata else load_builder()
code=builder.cli(command+['-k',str(keys)])
if code: return code
romfs=output/'Contents/Resources/romfs'
if not (output/'Contents/MacOS/dmhost').is_file() or not romfs.is_dir() or romfs.is_symlink():
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Read CNMT/NACP metadata without extracting or compiling a game.
Builds only the metadata tool in the user's external tool cache when necessary.
Package/key contents and tool diagnostics are never forwarded to the UI.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
REPO = Path(__file__).resolve().parents[1]
EXTENSIONS = {'.nsp', '.xci', '.nsz', '.xcz'}
def summarize(value):
if value.get('schema') != 1 or not isinstance(value.get('packages'), list):
raise ValueError('Invalid metadata response')
packages = value['packages']
errors = value.get('errors', [])
bases = [p for p in packages if p['kind'] == 'base']
ids = {p['title_id'] for p in packages if p['kind'] in ('base', 'update', 'dlc')}
if errors and not bases:
return dict(schema=1,status='keys_unreadable' if any(e.get('code')=='keys_unreadable' for e in errors) else 'metadata_unreadable',packages=packages,errors=errors)
if len(ids) != 1 or not bases:
return dict(schema=1, status='multiple_games' if len(ids) > 1 else 'base_missing', packages=packages, errors=errors)
title_id = bases[0]['title_id']
updates = sorted([p for p in packages if p['kind'] == 'update'], key=lambda p: p['version'], reverse=True)
base = max(bases, key=lambda p: p['version'])
selected = updates[0] if updates else base
name = selected.get('name') or next((p.get('name') for p in bases if p.get('name')), None)
return dict(schema=1, status='ready' if not errors else 'incomplete', title_id=title_id,
name=name, display_version=selected.get('display_version'), version=selected['version'],
selected_file=selected['file'], base_version=base.get('display_version'),
updates=updates, dlc=[p for p in packages if p['kind'] == 'dlc'], packages=packages, errors=errors)
def inspect(folder, keys, cache, setup_tools=False):
folder, keys = folder.resolve(), keys.resolve()
if folder == REPO or REPO in folder.parents or REPO in keys.parents:
return dict(schema=1, status='invalid_location')
files = sorted(p for p in folder.iterdir() if p.suffix.lower() in EXTENSIONS and p.is_file())
if not files:
return dict(schema=1, status='base_missing')
if any(p.resolve() == REPO or REPO in p.resolve().parents for p in files):
return dict(schema=1, status='invalid_location')
env = dict(os.environ)
env['PATH'] = '/opt/homebrew/bin:/usr/local/bin:/usr/local/share/dotnet:' + env.get('PATH', '')
env.update(DOTNET_CLI_TELEMETRY_OPTOUT='1', DOTNET_NOLOGO='1')
dotnet = shutil.which('dotnet', path=env['PATH'])
if setup_tools:
brew = shutil.which('brew', path=env['PATH'])
if not brew:
return dict(schema=1, status='tools_missing')
command = 'upgrade' if subprocess.run([brew, 'list', '--cask', '--versions', 'dotnet-sdk'], env=env, capture_output=True).returncode == 0 else 'install'
result = subprocess.run([brew, command, '--cask', 'dotnet-sdk'], env=env, capture_output=True, timeout=900)
if result.returncode:
return dict(schema=1, status='tools_unavailable')
dotnet = shutil.which('dotnet', path=env['PATH'])
if not dotnet:
return dict(schema=1, status='tools_missing')
source = REPO / 'dm/tools/nx_extract'
digest = hashlib.sha256(b''.join(p.read_bytes() for p in (source/'Program.cs', source/'nx_extract.csproj'))).hexdigest()
tool = cache / digest / 'nx_extract.dll'
if not tool.is_file():
cache.mkdir(parents=True, exist_ok=True)
# Both the build output and MSBuild's intermediate files stay outside the checkout.
with tempfile.TemporaryDirectory(prefix='metadata-', dir=cache) as temporary:
work = Path(temporary)
project = work/'source'
project.mkdir()
for name in ('Program.cs', 'nx_extract.csproj'):
shutil.copy2(source/name, project/name)
result = subprocess.run([dotnet, 'build', str(project), '-c', 'Release', '-o', str(work/'tool'), '-v', 'quiet', '-nologo'],
env=env, capture_output=True, timeout=240)
if result.returncode:
return dict(schema=1, status='tools_unavailable')
try:
(work/'tool').rename(tool.parent)
except FileExistsError:
pass # another read-only inspection populated the same tool cache
signatures = {p.name: [p.stat().st_size, p.stat().st_mtime_ns] for p in files}
result = subprocess.run([dotnet, str(tool), '--inspect', str(keys), *(str(p) for p in files)],
env=env, capture_output=True, timeout=180)
if len(result.stdout) > 1048576:
return dict(schema=1, status='metadata_unreadable')
try:
value = summarize(json.loads(result.stdout))
if any([p.stat().st_size, p.stat().st_mtime_ns] != signatures[p.name] for p in files):
return dict(schema=1, status='files_changed')
value['sources'] = signatures
return value
except (ValueError, KeyError, TypeError):
return dict(schema=1, status='metadata_unreadable')
def family_from_metadata(folder, value):
"""Use the exact inspected identities/versions, rejecting stale or ambiguous inputs."""
if value.get('schema') != 1 or value.get('status') != 'ready' or value.get('errors'):
raise ValueError('Game metadata is incomplete; identify the folder again.')
folder = Path(folder).resolve()
files = {p.name: p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in EXTENSIONS}
signatures = {name: [p.stat().st_size, p.stat().st_mtime_ns] for name, p in files.items()}
if signatures != value.get('sources'):
raise ValueError('Game files changed after identification; identify the folder again.')
application = int(value['title_id'], 16)
family = dict(base=None, updates=[], dlc=[])
bases = []
for package in value['packages']:
name = package['file']
if name not in files or int(package['title_id'], 16) != application:
raise ValueError('Package metadata does not match the selected game.')
path = files[name]
version = package['version']
if not isinstance(version, int) or not 0 <= version <= 0xFFFFFFFF:
raise ValueError('Invalid package version.')
if package['kind'] == 'base':
bases.append((version, path))
elif package['kind'] == 'update':
family['updates'].append((version, path))
elif package['kind'] == 'dlc':
family['dlc'].append((int(package['content_id'], 16), version, path))
if not bases:
raise ValueError('No base game in inspected packages.')
family['base'] = max(bases, key=lambda item: (item[0], item[1].suffix.lower() not in ('.nsz', '.xcz'), item[1].name))[1]
return application, family
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('folder', type=Path)
parser.add_argument('--keys', type=Path, required=True)
parser.add_argument('--setup-tools', action='store_true')
parser.add_argument('--cache', type=Path, default=Path.home()/'Library/Caches/nxnative Easy/metadata')
args = parser.parse_args()
try:
value = inspect(args.folder, args.keys, args.cache, args.setup_tools)
except (OSError, subprocess.SubprocessError):
value = dict(schema=1, status='metadata_unreadable')
print(json.dumps(value))
if __name__ == '__main__':
main()
+59
View File
@@ -0,0 +1,59 @@
"""Metadata decisions use internal identities and reject stale inputs, never filename tags."""
import copy
import importlib.util
from pathlib import Path
import tempfile
import unittest
ROOT=Path(__file__).resolve().parents[2]
spec=importlib.util.spec_from_file_location('inspection',ROOT/'scripts/inspect_game.py')
inspection=importlib.util.module_from_spec(spec);spec.loader.exec_module(inspection)
def package(file,kind,version,display=None,title='010093801237C000'):
return dict(file=file,kind=kind,version=version,display_version=display,name='Synthetic Game',title_id=title,content_id=title if kind=='base' else title[:-3]+'800')
class GameInspection(unittest.TestCase):
def test_internal_version_beats_filename(self):
value=inspection.summarize(dict(schema=1,packages=[package('base.xci','base',0,'1.0'),package('update-v999999.nsp','update',65536,'1.1'),package('no tags.nsp','update',131072,'1.2')],errors=[]))
self.assertEqual((value['status'],value['display_version'],value['selected_file']),('ready','1.2','no tags.nsp'))
self.assertEqual(value['base_version'],'1.0')
self.assertEqual(len(value['updates']),2)
def test_base_only_and_unknown_display_are_honest(self):
value=inspection.summarize(dict(schema=1,packages=[package('base.xci','base',0)],errors=[]))
self.assertEqual(value['updates'],[])
self.assertIsNone(value['display_version'])
def test_multiple_games_missing_base_and_partial_errors(self):
base=package('base.xci','base',0)
for packages,errors,status in [([package('update.nsp','update',1)],[],'base_missing'),([base,package('other.xci','base',0,title='010015100B514000')],[],'multiple_games'),([base],[dict(file='bad.nsp',code='metadata_unreadable')],'incomplete')]:
self.assertEqual(inspection.summarize(dict(schema=1,packages=packages,errors=errors))['status'],status)
def test_manifest_drives_build_and_rejects_changed_inputs(self):
with tempfile.TemporaryDirectory() as temporary:
folder=Path(temporary)
for name in ['original.xci','untagged.nsp']:(folder/name).write_bytes(b'synthetic')
value=inspection.summarize(dict(schema=1,packages=[package('original.xci','base',0,'1.0'),package('untagged.nsp','update',196608,'1.3')],errors=[]))
value['sources']={p.name:[p.stat().st_size,p.stat().st_mtime_ns] for p in folder.iterdir()}
app,family=inspection.family_from_metadata(folder,value)
self.assertEqual(app,int('010093801237C000',16))
self.assertEqual(family['updates'],[(196608,(folder/'untagged.nsp').resolve())])
self.assertEqual((folder/'untagged.nsp').read_bytes(),b'synthetic')
bad=copy.deepcopy(value);bad['packages'][1]['file']='../escape.nsp'
with self.assertRaises(ValueError):inspection.family_from_metadata(folder,bad)
(folder/'untagged.nsp').write_bytes(b'changed')
with self.assertRaises(ValueError):inspection.family_from_metadata(folder,value)
def test_key_errors_do_not_claim_base_is_missing(self):
for code,status in [('keys_unreadable','keys_unreadable'),('metadata_unreadable','metadata_unreadable')]:
self.assertEqual(inspection.summarize(dict(schema=1,packages=[],errors=[dict(file='x',code=code)]))['status'],status)
def test_added_package_invalidates_inspection(self):
with tempfile.TemporaryDirectory() as temporary:
folder=Path(temporary);(folder/'base.xci').write_bytes(b'base')
value=inspection.summarize(dict(schema=1,packages=[package('base.xci','base',0)],errors=[]))
value['sources']={p.name:[p.stat().st_size,p.stat().st_mtime_ns] for p in folder.iterdir()}
(folder/'new.nsp').write_bytes(b'update')
with self.assertRaises(ValueError):inspection.family_from_metadata(folder,value)