Identify internal game versions and updates before preparation
This commit is contained in:
1 parent
7c6c83f56d
commit
26a25773a8
13 files changed
+475
-17
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user