Files
factorio-ios/tools/retarget.py
T
SashaGoncharov19 51d085e865 Native iPhone port of Factorio 2.0.77
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.
2026-10-23 15:27:58 +04:00

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()