#!/usr/bin/env python3 """Retarget the unmodified macOS ARM64 slice into an iOS-loadable dylib. Usage: python3 tools/retarget.py build/factorio.arm64 build/native/Factorio.dylib Only Mach-O metadata changes; every instruction and data byte stays the same: * MH_EXECUTE -> MH_DYLIB, plus an LC_ID_DYLIB in the header padding. * __PAGEZERO shrinks to one page just below __TEXT, so dyld can slide the image. Segment indices stay unchanged, so rebase/bind opcodes remain valid. * LC_BUILD_VERSION platform macOS -> iOS. * macOS framework paths -> iOS paths, or to @loader_path shims for frameworks (or individual symbols) that iOS lacks. Shims re-export the real framework when one exists. The host app reads LC_MAIN at runtime to find the original entry point. """ import hashlib import json import struct import sys from pathlib import Path EXPECTED_SHA256 = "2cb4de127b50d54c6c5666acfef2263fae16d11a3361ad39353ecf8a66a4d9c7" MH_MAGIC_64, MH_EXECUTE, MH_DYLIB = 0xFEEDFACF, 2, 6 MH_NO_REEXPORTED_DYLIBS = 0x100000 LC_SEGMENT_64, LC_ID_DYLIB, LC_LOAD_DYLIB, LC_LOAD_WEAK_DYLIB = 0x19, 0xD, 0xC, 0x80000018 LC_BUILD_VERSION, LC_MAIN = 0x32, 0x80000028 PLATFORM_IOS = 2 MIN_IOS = (17 << 16) SDK_IOS = (26 << 16) | (5 << 8) INSTALL_NAME = "@rpath/Factorio.dylib" # Framework name -> replacement install name. Absent: use the iOS path of the same framework. SHIMS = { "AppKit": "@loader_path/fx_AppKit.dylib", "Cocoa": "@loader_path/fx_Cocoa.dylib", "Carbon": "@loader_path/fx_Carbon.dylib", "OpenGL": "@loader_path/fx_OpenGL.dylib", "OpenCL": "@loader_path/fx_OpenCL.dylib", "ForceFeedback": "@loader_path/fx_ForceFeedback.dylib", "LDAP": "@loader_path/fx_LDAP.dylib", "ApplicationServices": "@loader_path/fx_ApplicationServices.dylib", # Present on iOS but missing some imported symbols: shim re-exports the real framework. "CoreGraphics": "@loader_path/fx_CoreGraphics.dylib", "CoreVideo": "@loader_path/fx_CoreVideo.dylib", "CoreServices": "@loader_path/fx_CoreServices.dylib", "Security": "@loader_path/fx_Security.dylib", "Metal": "@loader_path/fx_Metal.dylib", "Foundation": "@loader_path/fx_Foundation.dylib", "IOKit": "@loader_path/fx_IOKit.dylib", "CoreAudio": "@loader_path/fx_CoreAudio.dylib", "AudioToolbox": "@loader_path/fx_AudioToolbox.dylib", } def cstr(data, offset): return data[offset:data.index(b"\0", offset)].decode() def main(): source, output = Path(sys.argv[1]), Path(sys.argv[2]) data = bytearray(source.read_bytes()) digest = hashlib.sha256(data).hexdigest() if digest != EXPECTED_SHA256: raise SystemExit(f"unexpected input {digest}; only the inspected 2.0.77 ARM64 slice is supported") magic, _cpu, _sub, filetype, ncmds, sizeofcmds, flags, _ = struct.unpack_from(" room: raise SystemExit(f"{new} does not fit in {room} bytes") data[offset + name_offset:offset + cmdsize] = encoded.ljust(room, b"\0") report["dylibs"].append({"from": old, "to": new, "weak": cmd == LC_LOAD_WEAK_DYLIB}) offset += cmdsize if text_vmaddr is None or first_section is None: raise SystemExit("__TEXT segment not found") # Shrink __PAGEZERO to a single 16 KiB page directly below __TEXT. struct.pack_into(" first_section: raise SystemExit("no header padding for LC_ID_DYLIB") if any(data[end:end + id_size]): raise SystemExit("header padding is not empty") struct.pack_into("MH_DYLIB", "install_name": INSTALL_NAME}) output.parent.mkdir(parents=True, exist_ok=True) output.write_bytes(data) report["output_sha256"] = hashlib.sha256(data).hexdigest() changed = sum(1 for a, b in zip(data, source.read_bytes()) if a != b) report["changed_bytes"] = changed report["changed_bytes_outside_header"] = sum( 1 for i, (a, b) in enumerate(zip(data[first_section:], source.read_bytes()[first_section:])) if a != b) if report["changed_bytes_outside_header"]: raise SystemExit("retarget changed bytes outside the Mach-O header") (output.parent / "retarget.json").write_text(json.dumps(report, indent=2) + "\n") print(json.dumps({k: v for k, v in report.items() if k != "dylibs"}, indent=2)) if __name__ == "__main__": main()