Files
factorio-ios/tools/build_native.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

264 lines
14 KiB
Python

#!/usr/bin/env python3
"""Build, sign and optionally install the native iOS host for the retargeted Factorio binary.
Usage:
python3 tools/build_native.py [--install] [--push-data] [--launch] [options]
--install install build/native/Factorio.app on the connected device
--push-data copy the installed game's data/ folder into the app's Documents (once, ~1.5 GB)
--launch launch with the console attached; output is also saved to build/native/console.log
--no-build skip building (install/launch the existing bundle)
Options (each also readable from the environment variable shown):
--game PATH factorio.app/Contents of a local install (FX_GAME; default: Steam location)
--team ID Apple developer team ID (FX_TEAM; default: from your provisioning profiles)
--bundle-id ID app bundle identifier (FX_BUNDLE_ID; default: local.factorio.native)
--device ID devicectl device identifier or name (FX_DEVICE; default: first paired device)
The game executable is the unmodified ARM64 slice with only Mach-O header changes (see
tools/retarget.py). Shims supply the macOS APIs iOS lacks. No game data is put in the bundle.
"""
import argparse
import hashlib
import json
import os
import plistlib
import subprocess
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
STEAM_GAME = Path.home() / "Library/Application Support/Steam/steamapps/common/Factorio/factorio.app/Contents"
OUT = ROOT / "build/native"
APP = OUT / "Factorio.app"
SRC = ROOT / "platform/native"
MIN_IOS = "17.0"
# Set from command-line options / environment in main().
GAME = STEAM_GAME
BUNDLE_ID = "local.factorio.native"
TEAM = None
SLICE = ROOT / "build/factorio.arm64"
WUFFS = ROOT / "third_party/wuffs/wuffs-v0.3.c"
WUFFS_URL = "https://raw.githubusercontent.com/google/wuffs/v0.3.4/release/c/wuffs-v0.3.c"
WUFFS_SHA256 = "a3db4bd979663423de00309d1ba07d7fa8576845223d3e02764181bd6da23f90"
SDK = subprocess.check_output(["xcrun", "--sdk", "iphoneos", "--show-sdk-path"], text=True).strip()
CC = ["xcrun", "--sdk", "iphoneos", "clang", "-target", f"arm64-apple-ios{MIN_IOS}", "-isysroot", SDK,
"-O2", "-g", "-Wall", "-Wno-unused-function", "-Wno-objc-property-no-attribute",
f"-I{SRC / 'shim'}"]
# shim name -> (sources, defines, re-exported iOS framework or None, links fx_AppKit)
SHIMS = {
"fx_CoreGraphics": (["coregraphics.c"], [], "CoreGraphics", True),
"fx_CoreVideo": (["small.c"], ["FX_LIB_COREVIDEO"], "CoreVideo", True),
"fx_CoreServices": (["small.c"], ["FX_LIB_CORESERVICES"], "CoreServices", True),
"fx_Security": (["small.c"], ["FX_LIB_SECURITY"], "Security", True),
"fx_IOKit": (["small.c"], ["FX_LIB_IOKIT"], "IOKit", True),
"fx_Foundation": (["frameworks.m"], ["FX_LIB_FOUNDATION"], "Foundation", True),
"fx_Metal": (["frameworks.m"], ["FX_LIB_METAL"], "Metal", True),
"fx_CoreAudio": (["audio.c"], ["FX_LIB_COREAUDIO"], "CoreAudio", True),
"fx_AudioToolbox": (["audio.c"], ["FX_LIB_AUDIOTOOLBOX"], "AudioToolbox", True),
"fx_Carbon": (["small.c"], ["FX_LIB_CARBON"], None, True),
"fx_OpenCL": (["small.c"], ["FX_LIB_OPENCL"], None, True),
"fx_ForceFeedback": (["small.c"], ["FX_LIB_FORCEFEEDBACK"], None, True),
"fx_LDAP": (["small.c"], ["FX_LIB_LDAP"], None, True),
"fx_ApplicationServices": (["small.c"], ["FX_LIB_APPLICATIONSERVICES"], None, True),
"fx_Cocoa": (["small.c"], ["FX_LIB_EMPTY"], None, False),
"fx_OpenGL": (["small.c"], ["FX_LIB_EMPTY"], None, False),
"libsteam_api": (["small.c"], ["FX_LIB_STEAM"], None, True),
}
def run(cmd, **kw):
print("+", " ".join(str(c) for c in cmd), flush=True)
return subprocess.run([str(c) for c in cmd], check=True, **kw)
def dylib(name, sources, defines=(), reexport=None, link_appkit=True, extra=()):
out = APP / "Frameworks" / f"{name}.dylib"
cmd = CC + ["-dynamiclib", "-o", out, "-install_name", f"@rpath/{name}.dylib",
"-compatibility_version", "1000", "-current_version", "1000",
"-Wl,-rpath,@loader_path", *[f"-D{d}" for d in defines],
*[SRC / "shim" / s for s in sources], "-framework", "CoreFoundation", *extra]
if any(s.endswith(".m") for s in sources):
cmd += ["-fobjc-arc", "-framework", "Foundation"]
if reexport:
cmd += [f"-Wl,-reexport_framework,{reexport}"]
if link_appkit:
cmd += [APP / "Frameworks/fx_AppKit.dylib"]
run(cmd)
def build_icon():
"""Compile an asset catalog with the app icon, made from the game's own icon; returns plist keys."""
work = OUT / "icon"
iconset = work / "Assets.xcassets/AppIcon.appiconset"
iconset.mkdir(parents=True, exist_ok=True)
tool = work / "make_icon"
run(["xcrun", "clang", "-O2", "-fobjc-arc", ROOT / "tools/make_icon.m", "-framework", "Foundation",
"-framework", "CoreGraphics", "-framework", "ImageIO", "-o", tool])
run([tool, GAME / "data/core/graphics/[email protected]", iconset / "AppIcon-1024.png"])
(iconset / "Contents.json").write_text(json.dumps({"images": [{"filename": "AppIcon-1024.png", "idiom": "universal",
"platform": "ios", "size": "1024x1024"}], "info": {"author": "xcode", "version": 1}}))
(work / "Assets.xcassets/Contents.json").write_text(json.dumps({"info": {"author": "xcode", "version": 1}}))
partial = work / "partial-info.plist"
run(["xcrun", "actool", work / "Assets.xcassets", "--compile", APP, "--platform", "iphoneos",
"--minimum-deployment-target", MIN_IOS, "--app-icon", "AppIcon", "--target-device", "iphone",
"--target-device", "ipad", "--output-partial-info-plist", partial], stdout=subprocess.DEVNULL)
with open(partial, "rb") as f:
return plistlib.load(f)
def prepare_inputs():
"""Extract the ARM64 slice from the local install and fetch the pinned Wuffs source."""
if not SLICE.exists():
binary = GAME / "MacOS/factorio"
if not binary.exists():
raise SystemExit(f"Factorio executable not found at {binary}; pass --game PATH/factorio.app/Contents")
SLICE.parent.mkdir(parents=True, exist_ok=True)
run(["lipo", "-thin", "arm64", binary, "-output", SLICE])
if not WUFFS.exists() or hashlib.sha256(WUFFS.read_bytes()).hexdigest() != WUFFS_SHA256:
WUFFS.parent.mkdir(parents=True, exist_ok=True)
print("+ fetch", WUFFS_URL, flush=True)
data = urllib.request.urlopen(WUFFS_URL, timeout=120).read()
if hashlib.sha256(data).hexdigest() != WUFFS_SHA256:
raise SystemExit("downloaded Wuffs source does not match the pinned SHA-256")
WUFFS.write_bytes(data)
def build():
prepare_inputs()
run([sys.executable, ROOT / "tools/retarget.py", SLICE, OUT / "Factorio.dylib"])
(APP / "Frameworks").mkdir(parents=True, exist_ok=True)
dylib("fx_AppKit", ["appkit.m", "boot.m", "crash.c", "keyboard.m", "metal_compat.m", "pngdecode.c", "stack.s"], link_appkit=False,
extra=["-fobjc-arc", "-framework", "Foundation", "-framework", "UIKit", "-framework", "QuartzCore",
"-framework", "CoreGraphics", "-framework", "Metal", "-framework", "ImageIO", "-framework", "AVFAudio", "-framework", "Accelerate",
# UIKitCore already has a private NSColor; export ours under the AppKit symbol name.
"-Wl,-alias,_OBJC_CLASS_$_FXNSColor,_OBJC_CLASS_$_NSColor"])
for name, (sources, defines, reexport, link_appkit) in SHIMS.items():
extra = ["-framework", "Metal"] if name == "fx_Metal" else []
dylib(name, sources, defines, reexport, link_appkit, extra)
run(["cp", OUT / "Factorio.dylib", APP / "Frameworks/Factorio.dylib"])
run(CC + ["-o", APP / "FactorioNative", SRC / "host/main.c", APP / "Frameworks/fx_AppKit.dylib",
"-Wl,-rpath,@executable_path/Frameworks"])
info = {
"CFBundleExecutable": "FactorioNative", "CFBundleIdentifier": BUNDLE_ID, "CFBundleName": "Factorio",
"CFBundleDisplayName": "Factorio", "CFBundlePackageType": "APPL", "CFBundleVersion": "1",
"CFBundleShortVersionString": "2.0.77", "CFBundleInfoDictionaryVersion": "6.0",
"CFBundleSupportedPlatforms": ["iPhoneOS"], "MinimumOSVersion": MIN_IOS, "UIDeviceFamily": [1, 2],
"DTPlatformName": "iphoneos", "UIRequiresFullScreen": True, "UIStatusBarHidden": True,
"UISupportedInterfaceOrientations": ["UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeRight"],
"UILaunchScreen": {"UIColorName": ""}, "UIFileSharingEnabled": True,
"LSSupportsOpeningDocumentsInPlace": True, "GCSupportsControllerUserInteraction": True,
}
info.update(build_icon())
with open(APP / "Info.plist", "wb") as f:
plistlib.dump(info, f, fmt=plistlib.FMT_BINARY)
symbols = OUT / "dSYM"
run(["rm", "-rf", symbols])
symbols.mkdir()
for d in list(APP.rglob("*.dSYM")):
d.rename(symbols / d.name)
sign()
def find_profile():
"""A development profile covering BUNDLE_ID (explicit or wildcard), optionally for TEAM."""
folder = Path.home() / "Library/Developer/Xcode/UserData/Provisioning Profiles"
candidates = []
for path in sorted(folder.glob("*.mobileprovision")):
plist = plistlib.loads(subprocess.check_output(["security", "cms", "-D", "-i", str(path)]))
team = plist["TeamIdentifier"][0]
app_id = plist["Entitlements"]["application-identifier"]
if TEAM and team != TEAM:
continue
if app_id == f"{team}.{BUNDLE_ID}":
candidates.insert(0, (path, plist, team)) # explicit App ID wins over wildcard
elif app_id == f"{team}.*":
candidates.append((path, plist, team))
if not candidates:
raise SystemExit(f"no provisioning profile covers {BUNDLE_ID}" + (f" for team {TEAM}" if TEAM else "") +
". Create one in Xcode (any project, automatic signing, same team) or pass --team.")
return candidates[0]
def signing_identity(profile):
"""The keychain identity whose certificate is embedded in the profile."""
allowed = {hashlib.sha1(cert).hexdigest().upper() for cert in profile.get("DeveloperCertificates", [])}
listing = subprocess.check_output(["security", "find-identity", "-v", "-p", "codesigning"], text=True)
for line in listing.splitlines():
parts = line.split()
if len(parts) > 2 and parts[1] in allowed:
return parts[1]
raise SystemExit("no signing identity in the keychain matches the provisioning profile")
def sign():
profile, plist, team = find_profile()
run(["cp", profile, APP / "embedded.mobileprovision"])
identity = signing_identity(plist)
entitlements = {"application-identifier": f"{team}.{BUNDLE_ID}", "com.apple.developer.team-identifier": team,
"get-task-allow": True}
if plist["Entitlements"].get("com.apple.developer.kernel.increased-memory-limit"):
entitlements["com.apple.developer.kernel.increased-memory-limit"] = True
ent = OUT / "entitlements.plist"
with open(ent, "wb") as f:
plistlib.dump(entitlements, f)
for lib in sorted((APP / "Frameworks").glob("*.dylib")):
run(["codesign", "-f", "-s", identity, "--timestamp=none", lib], stdout=subprocess.DEVNULL)
run(["codesign", "-f", "-s", identity, "--timestamp=none", "--entitlements", ent, APP])
print(json.dumps({"app": str(APP), "profile": plist["Name"], "entitlements": entitlements}, indent=2))
def device(requested=None):
if requested:
return requested
listing = OUT / "devices.json"
subprocess.run(["xcrun", "devicectl", "list", "devices", "--json-output", listing],
check=True, stdout=subprocess.DEVNULL)
data = json.loads(listing.read_text())
for d in data["result"]["devices"]:
if d.get("connectionProperties", {}).get("pairingState") == "paired":
return d["identifier"]
raise SystemExit("no paired device")
def main():
global GAME, BUNDLE_ID, TEAM
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--install", action="store_true")
parser.add_argument("--push-data", action="store_true")
parser.add_argument("--launch", action="store_true")
parser.add_argument("--no-build", action="store_true")
parser.add_argument("--game", default=os.environ.get("FX_GAME", str(STEAM_GAME)))
parser.add_argument("--team", default=os.environ.get("FX_TEAM"))
parser.add_argument("--bundle-id", default=os.environ.get("FX_BUNDLE_ID", BUNDLE_ID))
parser.add_argument("--device", default=os.environ.get("FX_DEVICE"))
args = parser.parse_args()
GAME, BUNDLE_ID, TEAM = Path(args.game), args.bundle_id, args.team
if not args.no_build:
build()
if args.install or args.push_data or args.launch:
dev = device(args.device)
if args.install:
run(["xcrun", "devicectl", "device", "install", "app", "--device", dev, APP])
if args.push_data:
run(["xcrun", "devicectl", "device", "copy", "to", "--device", dev, "--domain-type", "appDataContainer",
"--domain-identifier", BUNDLE_ID, "--source", GAME / "data", "--destination", "Documents/factorio/data"])
if args.launch:
with open(OUT / "console.log", "w") as log:
proc = subprocess.Popen(["xcrun", "devicectl", "device", "process", "launch", "--device", dev,
"--terminate-existing", "--console", BUNDLE_ID],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in proc.stdout:
sys.stdout.write(line)
log.write(line)
log.flush()
proc.wait()
if __name__ == "__main__":
main()