Files
nxnative-easy/scripts/analyze_game.py
T

106 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Export an original prepared NSO as ELF and optionally analyze it with pinned, isolated REA."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from datetime import datetime
ROOT=Path(__file__).resolve().parents[1]
REA_VERSION='6.8.0'
NODE_VERSION='24.22.1'
def prepared_module(game):
candidates=[]
for path in game.glob('*_build/extract/v*/ExeFS/main'):
if not re.fullmatch('[0-9A-Fa-f]{16}_build',path.parents[3].name):continue
if not re.fullmatch('v[0-9]+',path.parents[1].name):continue
if not path.resolve().is_relative_to(game) or path.is_symlink():continue
candidates.append((int(path.parents[1].name[1:]),path))
titles={path.parents[3].name for _,path in candidates}
if len(titles)!=1:raise ValueError('Prepare exactly one game in this folder first. Analysis uses its extracted original main NSO, never an NSP, key or save.')
return max(candidates,key=lambda entry:entry[0])[1]
def npm_command(workspace,environment):
npm=shutil.which('npm')
if not npm:
candidates=[Path('/opt/homebrew/bin/npm'),Path('/usr/local/bin/npm')]
candidates+=sorted((Path.home()/'.nvm/versions/node').glob('*/bin/npm'),reverse=True)
npm=next((str(path) for path in candidates if path.is_file()),None)
if not npm:raise ValueError('Install Node.js/npm to enable optional REA analysis. Game preparation does not require REA.')
environment['PATH']=str(Path(npm).parent)+os.pathsep+environment.get('PATH','')
# Download exact packages to this game's cache, without changing global Node or agent registrations.
return [npm,'exec','--cache',str(workspace.parent/'rea-cache'),'--yes',
'--package=node@'+NODE_VERSION,'--package=rea-agents@'+REA_VERSION,'--','rea']
def analyze(game,output,run=False,provider=None):
game=game.expanduser().resolve(strict=True);output=output.expanduser().resolve()
if game==ROOT or ROOT in game.parents or not game.is_dir():raise ValueError('Choose your game folder outside the checkout.')
if not output.is_relative_to(game/'nxnative-tools/analysis'):raise ValueError('Analysis output must be under this game\'s nxnative-tools/analysis folder.')
source=prepared_module(game)
with source.open('rb') as stream:header=stream.read(0x60)
if header[:4]!=b'NSO0' or len(header)<0x60:raise ValueError('Original extracted module is not a valid NSO.')
python=game/'nxnative-tools/venv/bin/python'
if not python.is_file():raise ValueError('Prepare this game with the app first; its analysis converter needs the game-local Python environment.')
output.mkdir(parents=True,exist_ok=False,mode=0o700)
before=hashlib.sha256(source.read_bytes()).hexdigest()
metadata=dict(schema=1,title_id=source.parents[3].name[:-6],package=source.parents[1].name,
main_build_id=header[0x40:0x60].hex(),source_sha256=before,
rea_repository='https://github.com/morluto/rea',rea_version=REA_VERSION,node_version=NODE_VERSION,
status='exporting',automatically_ports_game=False)
record=output/'analysis.json'
def save():record.write_text(json.dumps(metadata,indent=2)+'\n')
save()
try:
print('Exporting original main NSO to an AArch64 ELF for static analysis.',flush=True)
subprocess.run([str(python),str(ROOT/'dm/tools/nso2elf.py'),str(source),str(output/'main.elf')],check=True)
if hashlib.sha256(source.read_bytes()).hexdigest()!=before:raise ValueError('Source changed during export; stop the active preparation and try again.')
(output/'README.txt').write_text('REA analysis workspace\n\nOriginal game NSO exported to main.elf; no game, keys or saves uploaded.\n'
'REA is an optional analysis adapter, not a game porting engine or an AI agent.\n'
'Native pseudocode requires a compatible local Ghidra, Hopper or IDA provider.\n'
'Use a separate agent to investigate evidence; proposed patches still require build-ID gates and tests.\n'
'Do not commit ELF, evidence or game files to GitHub.\n\n'
'Official REA: https://github.com/morluto/rea\n'
'CLI: https://github.com/morluto/rea/blob/main/docs/cli.md\n')
metadata['status']='exported';save()
if run:
env=dict(os.environ);command=npm_command(output,env)
if provider not in (None,'ghidra','hopper','ida'):raise ValueError('Choose ghidra, hopper or ida.')
print('Starting optional REA '+REA_VERSION+' with isolated Node '+NODE_VERSION+'. Native analysis needs an existing compatible provider.',flush=True)
command+=['analyze',str(output/'main.elf'),'--json']
if provider:command+=['--provider',provider]
# Retain potentially large native Evidence on disk, never as one giant UI message.
with (output/'rea.json').open('wb') as evidence,(output/'rea-stderr.log').open('w') as diagnostics:
with subprocess.Popen(command,cwd=output,env=env,stdout=evidence,stderr=subprocess.PIPE,text=True,errors='replace') as child:
assert child.stderr is not None
for line in child.stderr:diagnostics.write(line);diagnostics.flush();print(line,end='',flush=True)
code=child.wait()
if code:raise ValueError('REA could not complete analysis. Review rea.json and rea-stderr.log for provider configuration. The ELF and original files were preserved.')
metadata['status']='analyzed';save()
print('Analysis workspace: '+str(output),flush=True)
return output
except BaseException as error:
metadata['status']='failed';metadata['error']=str(error);save();raise
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('game_dir',type=Path)
parser.add_argument('--output',type=Path)
parser.add_argument('--run',action='store_true',help='download exact isolated REA/Node packages and run local native analysis')
parser.add_argument('--provider',choices=['ghidra','hopper','ida'])
args=parser.parse_args();output=args.output or args.game_dir/'nxnative-tools/analysis'/datetime.now().strftime('%Y%m%d-%H%M%S-%f')
analyze(args.game_dir,output,args.run,args.provider)
if __name__=='__main__':
try:main()
except (ValueError,OSError,subprocess.SubprocessError) as error:print('Error: '+str(error),file=sys.stderr);sys.exit(1)