#!/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/factorio-icon@2x.png", 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()