106 lines
6.4 KiB
Python
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)
|