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.
9.3 KiB
Architecture of the native port
The port runs the original macOS ARM64 machine code of Factorio on iOS. Both platforms are ARM64 Darwin; what differs is the set of system libraries and frameworks around the code. The port therefore changes nothing in the game's code and instead (1) makes iOS willing to load the executable and (2) supplies the macOS APIs that iOS does not have.
factorio (macOS, universal) ──lipo──▶ factorio.arm64 ──retarget.py──▶ Factorio.dylib
│ links
FactorioNative (host) ──▶ fx_AppKit.dylib ◀──────────────────────────┤
UIApplicationMain UIKit bridge, input, boot ├─▶ fx_CoreGraphics, fx_CoreAudio, … (shims,
calls original main() │ each re-exporting the iOS framework)
└─▶ iOS libSystem, Metal, Foundation, …
Why this works
tools/native_audit.py resolves every import of the game binary against the iOS SDK's library
stubs. Every libSystem function the game uses exists on iOS. The renderer is Metal, and its
shaders ship as .metal source that Metal compiles at runtime, so they work on iOS too. Only
about 150 symbols are missing, almost all in windowing, display and peripheral APIs (AppKit,
CoreGraphics display functions, CVDisplayLink, Carbon keyboard layout, ForceFeedback, LDAP,
OpenCL) plus the Steam API.
No code is generated at runtime. The game library is signed like any other app library.
Retargeting (tools/retarget.py)
Only Mach-O header fields change (1,153 bytes). The script refuses any change after the load commands, and refuses any input other than the pinned 2.0.77 ARM64 slice.
MH_EXECUTE→MH_DYLIB, with anLC_ID_DYLIBadded in the header padding. iOS will not load a second executable, but it will load a library.__PAGEZEROshrinks to one page directly below__TEXT, so the image can slide like a library. Segment indices stay the same, so the rebase and bind opcodes remain valid.LC_BUILD_VERSION: platform macOS → iOS.- Framework load paths: macOS
Versions/Apaths become iOS paths, and frameworks or symbols iOS lacks are redirected to@loader_path/fx_*.dylibshims. Where the iOS framework exists, the shim re-exports it and only adds the missing symbols, so the game's two-level namespace bindings still resolve.
The host reads LC_MAIN from the loaded library at runtime to find the original main.
Startup (platform/native/shim/boot.m, stack.s)
FactorioNative calls UIApplicationMain. After launch, a main-queue block dlopens
Factorio.dylib and calls the original main(argc, argv, envp, apple) on the main thread,
because UIKit and SDL's Cocoa code require it. iOS gives the main thread a 1 MiB stack, against
8 MiB on macOS, so fx_call_on_stack switches to a 256 MiB stack with a guard page for the
duration of main. Before calling main, the host:
- writes
Documents/factorio/config/config.iniwithread-dataandwrite-datapaths into the app container, which can move between installs; - activates the audio session (Playback, mixing with other apps);
- adds macOS-only
MTLDeviceproperties (metal_compat.m); - installs the crash reporter (
crash.c).
The game then runs its own loop. Each time SDL pumps events, the shim re-enters the UIKit run
loop (CFRunLoopRunInMode), the same technique SDL's own UIKit backend uses, so touches,
timers and system events keep flowing while the game owns the main thread.
AppKit on UIKit (appkit.m)
Factorio links SDL 2.24 statically with the Cocoa video driver. The shim implements the AppKit surface that driver uses, checked against the SDL 2.24.2 source:
- NSApplication: a shared instance, an event queue fed from UIKit,
nextEventMatchingMask:untilDate:inMode:dequeue:(re-entering the run loop), andsendEvent:routing to windows. It also returns a non-nil main menu, so SDL skips nib loading. - NSWindow / NSView: each view is backed by a
UIView. A view that asks for a layer gets the layer from itsmakeBackingLayer, which for SDL's Metal view is aCAMetalLayer. AppKit's bottom-left coordinates are converted to UIKit's. Windows are always fullscreen. - NSEvent: mouse, scroll, key and flags-changed events with AppKit field semantics, plus
+mouseLocation,+pressedMouseButtonsand+modifierFlags. - NSScreen and the CoreGraphics display list (
coregraphics.c): one display. Its size is the panel's native pixel size with a backing scale of 1, so the game renders at full resolution. Touches and view frames are scaled between UIKit points and game pixels.render-divisor.txtcan lower the resolution. - NSResponder: the AppKit responder chain, including
interpretKeyEvents:→insertText:for SDL's text input field. - NSBitmapImageRep: on macOS Factorio decodes every PNG through it. See below.
- NSColor: UIKitCore already contains a private
NSColorclass, so the shim's class is namedFXNSColorand exported under the AppKit symbol name with a linker alias. - Other classes (cursors, menus, alerts, pasteboard, workspace, OpenGL) are inert or mapped to UIKit equivalents. An alert's text is written to the log.
Unknown selectors on shim classes are logged once ([fx] unimplemented …) and answer zero, so
missing pieces show up in the log instead of as crashes.
Image decoding (appkit.m, pngdecode.c)
Factorio's macOS sprite loader calls +[NSBitmapImageRep imageRepWithData:] for each of about
4,700 images (~3 gigapixels) and expects AppKit's layout: tightly packed, non-premultiplied
samples with the source channel count. Factorio then premultiplies them itself.
pngdecode.cdecodes 8-bit grayscale, gray+alpha, RGB and RGBA PNGs without atRNSchunk with Wuffs v0.3.4. That covers about 87 % of the sprites.- Everything else goes through ImageIO. ImageIO on iOS returns premultiplied RGBA, which a single vImage conversion turns back into the expected layout.
The decode path is chosen from the image metadata before any pixels are read, so no image is decoded twice. Startup to the main menu dropped from 84 s (scalar conversion) to about 16 s.
Audio (audio.c)
SDL's macOS CoreAudio driver enumerates devices through AudioObject* property calls, which
answer nothing useful on iOS. fx_CoreAudio models one output device (stereo, 48 kHz, alive,
not hogged) and answers exactly the properties SDL 2.24 queries. Playback uses the real iOS
AudioQueue. fx_AudioToolbox passes AudioQueueSetProperty through, except the macOS-only
kAudioQueueProperty_CurrentDevice.
Input (boot.m, keyboard.m)
- Touch → mouse: tap = left click; a long press holds the right button (with a zero-distance motion so Factorio updates the hovered entity immediately); a two-finger pan sends wheel events.
- Keys: every source produces AppKit key events with Mac virtual key codes and
device-dependent modifier bits, which SDL maps exactly as on a Mac:
- hardware keyboards (
UIPressHID usage → Mac key code); - the iOS keyboard (characters → key code, plus Shift when needed);
- the overlay and the extra-keys bar.
- hardware keyboards (
Other shims
| Shim | Provides |
|---|---|
metal_compat.m |
isDepth24Stencil8PixelFormatSupported, isLowPower, isRemovable, location, … on the GPU's device class; logs other unknown device selectors |
small.c |
CVDisplayLink (refresh period), Carbon keyboard layout (none), ForceFeedback, OpenCL and LDAP (all unavailable), Security and CoreServices extras, Steam API (initialization reports failure; the game continues without Steam) |
frameworks.m |
NSAppleEventManager, MTLCopyAllDevicesWithObserver |
crash.c |
Interposes sigaction, so a fault is logged with registers and the frame chain before Factorio's own handler runs |
Verification
-
retarget.pychecks the input hash and confirms that no byte after the header changed. -
native_audit.pyplus a check of the shims' exports confirm that every import resolves. -
PNG parity: every game PNG decoded by
pngdecode.cis byte-identical to macOS ImageIO's exact non-premultiplied output (4,231 identical, 534 use the ImageIO path):clang -O2 -fobjc-arc -Iplatform/native/shim tests/native/png_parity.m platform/native/shim/pngdecode.c \ -framework Foundation -framework ImageIO -framework CoreGraphics -framework QuartzCore -o /tmp/png_parity /tmp/png_parity "$HOME/Library/Application Support/Steam/steamapps/common/Factorio/factorio.app/Contents/data" -
On the device, verification is by running the game: the console log, unimplemented-selector messages, and crash reports symbolized with
tools/symbolize_native.py.
Known limitations
- Only the Steam macOS build of 2.0.77 is accepted. Other versions need their imports re-audited.
- Memory: the wildcard development profile does not include the increased-memory-limit entitlement. Large factories may hit the default iOS memory limit; an explicit App ID with that capability lifts it.
- Not yet verified: loading existing saves, long sessions and thermals, mods, multiplayer, Space Age.
- iOS is told the device has no audio input, so there is no voice or capture.
- Keyboard layout handling assumes US.