Run the original macOS ARM64 game code on iOS: - tools/retarget.py: header-only Mach-O retargeting to an iOS dylib - platform/native: UIKit host and macOS API shims (AppKit for SDL's Cocoa backend, CoreGraphics displays, CoreAudio device, Metal device properties, Wuffs PNG decoding, Steam and peripheral stubs) - touch, overlay, iOS keyboard and hardware keyboard input - tools/build_native.py: build, sign, install, data copy, launch - docs: build guide, controls, architecture Also includes the earlier static-translation research (ARM64 -> WASM -> native) under docs/research, runtime/, tests/ and tools/. No game files are included; the build reads them from a local install.
150 lines
7.1 KiB
Python
150 lines
7.1 KiB
Python
#!/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("<IiiIIIII", data, 0)
|
|
if magic != MH_MAGIC_64 or filetype != MH_EXECUTE:
|
|
raise SystemExit("input is not a 64-bit Mach-O executable")
|
|
|
|
report = {"input_sha256": digest, "dylibs": [], "changes": []}
|
|
offset, first_section = 32, None
|
|
text_vmaddr = None
|
|
for _ in range(ncmds):
|
|
cmd, cmdsize = struct.unpack_from("<II", data, offset)
|
|
if cmd == LC_SEGMENT_64:
|
|
segname = data[offset + 8:offset + 24].rstrip(b"\0").decode()
|
|
vmaddr, vmsize, fileoff, filesize = struct.unpack_from("<QQQQ", data, offset + 24)
|
|
nsects = struct.unpack_from("<I", data, offset + 64)[0]
|
|
if segname == "__TEXT":
|
|
text_vmaddr = vmaddr
|
|
for s in range(nsects):
|
|
sect_offset = struct.unpack_from("<I", data, offset + 72 + s * 80 + 48)[0]
|
|
if sect_offset and (first_section is None or sect_offset < first_section):
|
|
first_section = sect_offset
|
|
if segname == "__PAGEZERO":
|
|
pagezero = offset
|
|
elif cmd == LC_BUILD_VERSION:
|
|
platform, minos, sdk = struct.unpack_from("<III", data, offset + 8)
|
|
struct.pack_into("<III", data, offset + 8, PLATFORM_IOS, MIN_IOS, SDK_IOS)
|
|
report["changes"].append({"build_version": [platform, hex(minos), hex(sdk)],
|
|
"to": [PLATFORM_IOS, hex(MIN_IOS), hex(SDK_IOS)]})
|
|
elif cmd == LC_MAIN:
|
|
report["entryoff"] = struct.unpack_from("<Q", data, offset + 8)[0]
|
|
elif cmd in (LC_LOAD_DYLIB, LC_LOAD_WEAK_DYLIB):
|
|
name_offset = struct.unpack_from("<I", data, offset + 8)[0]
|
|
old = cstr(data, offset + name_offset)
|
|
framework = None
|
|
if old.startswith("/System/Library/Frameworks/"):
|
|
framework = old.split("/")[4].removesuffix(".framework")
|
|
if framework in SHIMS:
|
|
new = SHIMS[framework]
|
|
elif framework == "IOKit":
|
|
new = old
|
|
elif framework:
|
|
new = f"/System/Library/Frameworks/{framework}.framework/{framework}"
|
|
else:
|
|
new = old # libSystem, libobjc, libresolv, @loader_path/libsteam_api.dylib
|
|
room = cmdsize - name_offset
|
|
encoded = new.encode() + b"\0"
|
|
if len(encoded) > 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("<QQ", data, pagezero + 24, text_vmaddr - 0x4000, 0x4000)
|
|
report["changes"].append({"pagezero": [hex(text_vmaddr - 0x4000), "0x4000"]})
|
|
|
|
# Append LC_ID_DYLIB in the padding before the first section.
|
|
name = INSTALL_NAME.encode() + b"\0"
|
|
id_size = (24 + len(name) + 7) & ~7
|
|
end = 32 + sizeofcmds
|
|
if end + id_size > 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("<IIIIII", data, end, LC_ID_DYLIB, id_size, 24, 2, 0x10000, 0x10000)
|
|
data[end + 24:end + 24 + len(name)] = name
|
|
struct.pack_into("<IIIII", data, 12, MH_DYLIB, ncmds + 1, sizeofcmds + id_size,
|
|
flags | MH_NO_REEXPORTED_DYLIBS, 0)
|
|
report["changes"].append({"filetype": "MH_EXECUTE->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()
|