75 lines
4.1 KiB
Python
75 lines
4.1 KiB
Python
"""Analysis stays outside source, pins tools and never mutates original game code."""
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import struct
|
|
import shlex
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
|
import analyze_game
|
|
|
|
|
|
def nso():
|
|
header=bytearray(0x100);header[:4]=b'NSO0'
|
|
for i in range(3):struct.pack_into('<III',header,0x10+i*0x10,0x100+i*0x100,i*0x100,0x100)
|
|
struct.pack_into('<III',header,0x60,0x100,0x100,0x100);header[0x40:0x60]=bytes(range(32))
|
|
image=bytearray(0x300);struct.pack_into('<I',image,4,0x40);image[0x40:0x44]=b'MOD0'
|
|
struct.pack_into('<iiiii',image,0x44,0xc0,0x2c0,0x2c0,0,0)
|
|
return bytes(header+image)
|
|
|
|
|
|
class GameAnalysis(unittest.TestCase):
|
|
def fixture(self,game,package='v1'):
|
|
source=game/f'010093801237C000_build/extract/{package}/ExeFS/main'
|
|
source.parent.mkdir(parents=True);source.write_bytes(nso())
|
|
python=game/'nxnative-tools/venv/bin/python'
|
|
python.parent.mkdir(parents=True,exist_ok=True)
|
|
if not python.exists():
|
|
python.write_text('#!/bin/sh\nexec '+shlex.quote(sys.executable)+' "$@"\n');python.chmod(0o700)
|
|
return source,game/'nxnative-tools/analysis/run'
|
|
|
|
@unittest.skipUnless(importlib.util.find_spec('lz4'),'NSO converter needs build dependency lz4')
|
|
def test_actual_nso_to_elf_export_is_read_only(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
game=Path(temporary);source,out=self.fixture(game);original=source.read_bytes()
|
|
analyze_game.analyze(game,out)
|
|
self.assertEqual(source.read_bytes(),original)
|
|
self.assertEqual((out/'main.elf').read_bytes()[:4],b'\x7fELF')
|
|
info=json.loads((out/'analysis.json').read_text())
|
|
self.assertEqual(info['status'],'exported');self.assertFalse(info['automatically_ports_game'])
|
|
self.assertEqual(info['main_build_id'],bytes(range(32)).hex())
|
|
self.assertEqual(info['rea_version'],'6.8.0')
|
|
with self.assertRaises(FileExistsError):analyze_game.analyze(game,out)
|
|
|
|
def test_ambiguous_titles_and_outside_destination_are_rejected(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
game=Path(temporary);_,out=self.fixture(game)
|
|
other=game/'0100000000000000_build/extract/v1/ExeFS/main';other.parent.mkdir(parents=True);other.write_bytes(nso())
|
|
with self.assertRaisesRegex(ValueError,'exactly one'):analyze_game.prepared_module(game)
|
|
with self.assertRaisesRegex(ValueError,'output must be'):analyze_game.analyze(game,game/'wrong')
|
|
self.assertFalse(out.exists())
|
|
|
|
def test_pinned_local_command_and_provider_failure_preserve_export(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
game=Path(temporary);source,out=self.fixture(game)
|
|
env=dict(os.environ)
|
|
with patch.object(analyze_game.shutil,'which',return_value='/usr/local/bin/npm'):
|
|
command=analyze_game.npm_command(out,env)
|
|
self.assertIn('--package=node@24.22.1',command);self.assertIn('--package=rea-agents@6.8.0',command)
|
|
self.assertNotIn('setup',command);self.assertNotIn('--global',command)
|
|
def convert(*args,**kwargs):(out/'main.elf').write_bytes(b'\x7fELFfixture')
|
|
class Failed:
|
|
def __init__(self,*args,**kwargs):self.stderr=iter(['provider unavailable\n'])
|
|
def __enter__(self):return self
|
|
def __exit__(self,*args):pass
|
|
def wait(self):return 1
|
|
original=source.read_bytes()
|
|
with patch.object(analyze_game.subprocess,'run',side_effect=convert),patch.object(analyze_game.subprocess,'Popen',Failed),patch.object(analyze_game,'npm_command',return_value=['npm','exec','rea']):
|
|
with self.assertRaisesRegex(ValueError,'could not complete'):analyze_game.analyze(game,out,True,'ghidra')
|
|
self.assertEqual(json.loads((out/'analysis.json').read_text())['status'],'failed')
|
|
self.assertTrue((out/'main.elf').exists());self.assertEqual(source.read_bytes(),original)
|