#!/usr/bin/env python3 """Record exact local executable and installed data identities, without copying assets.""" import argparse import hashlib import json import plistlib import subprocess from datetime import datetime, timezone from pathlib import Path from lift_rng import arm64_image ROOT = Path(__file__).resolve().parents[1] EXPECTED_BINARY = '00b014d78df07e05ab1cee853904c8f7b3814ca652c043461a9d49fbad46cdf6' EXPECTED_SLICE = '2cb4de127b50d54c6c5666acfef2263fae16d11a3361ad39353ecf8a66a4d9c7' def digest(path): result = hashlib.sha256() with path.open('rb') as source: for block in iter(lambda: source.read(1024 * 1024), b''): result.update(block) return result.hexdigest() def command(*args): return subprocess.check_output(args, cwd=ROOT, text=True).strip() def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('app', type=Path) args = parser.parse_args() app = args.app.resolve(strict=True) binary = app / 'Contents/MacOS/factorio' source = binary.read_bytes() image, offset = arm64_image(source) binary_hash, slice_hash = hashlib.sha256(source).hexdigest(), hashlib.sha256(image).hexdigest() if binary_hash != EXPECTED_BINARY or slice_hash != EXPECTED_SLICE: raise ValueError('Target differs from the inspected Factorio 2.0.77 build') info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) assets = app / 'Contents/data' entries = [] for path in sorted(assets.rglob('*')): if path.is_symlink(): raise ValueError(f'Asset symlink requires explicit inventory policy: {path}') if path.is_file(): entries.append({'path': path.relative_to(assets).as_posix(), 'bytes': path.stat().st_size, 'sha256': digest(path)}) if not entries: raise ValueError('No installed data files found') packages = [] for path in sorted(assets.glob('*/info.json')): metadata = json.loads(path.read_text()) packages.append({'directory': path.parent.name, 'name': metadata.get('name'), 'version': metadata.get('version'), 'dependencies': metadata.get('dependencies')}) asset_manifest = {'scope': 'all regular files under installed Contents/data; no user mods or saves', 'files': entries, 'file_count': len(entries), 'total_bytes': sum(e['bytes'] for e in entries), 'packages': packages, 'gameplay_configuration': 'not selected; inventory does not prove save compatibility'} out = ROOT / 'build/target' out.mkdir(parents=True, exist_ok=True) asset_path = out / 'assets.json' asset_path.write_text(json.dumps(asset_manifest, indent=2) + '\n') report = {'recorded_at': datetime.now(timezone.utc).isoformat(), 'app': str(app), 'version': info.get('CFBundleShortVersionString'), 'binary_sha256': binary_hash, 'arm64_sha256': slice_hash, 'arm64_slice_offset': offset, 'arm64_bytes': len(image), 'binary_uuids': command('xcrun', 'dwarfdump', '--uuid', str(binary)).splitlines(), 'xcode': command('xcodebuild', '-version'), 'iphoneos_sdk': command('xcrun', '--sdk', 'iphoneos', '--show-sdk-version'), 'wabt_revision': command('git', '-C', str(ROOT / 'third_party/wabt'), 'rev-parse', 'HEAD'), 'asset_manifest_sha256': digest(asset_path), 'asset_file_count': len(entries), 'source_files': {str(p.relative_to(ROOT)): digest(p) for base in ('tools', 'runtime', 'platform/ios', 'tests') for p in sorted((ROOT / base).glob('*')) if p.is_file() and p.suffix in ('.py', '.mjs', '.c', '.h', '.m', '.yml')}} (out / 'manifest.json').write_text(json.dumps(report, indent=2) + '\n') print(f'PASS: pinned executable and ARM64 slice; recorded {len(entries)} asset files ({asset_manifest["total_bytes"]} bytes)') print(out / 'manifest.json') if __name__ == '__main__': main()