Files
factorio-ios/docs/native-port.md
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

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 an LC_ID_DYLIB added in the header padding. iOS will not load a second executable, but it will load a library.
  • __PAGEZERO shrinks 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/A paths become iOS paths, and frameworks or symbols iOS lacks are redirected to @loader_path/fx_*.dylib shims. 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.ini with read-data and write-data paths into the app container, which can move between installs;
  • activates the audio session (Playback, mixing with other apps);
  • adds macOS-only MTLDevice properties (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), and sendEvent: 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 its makeBackingLayer, which for SDL's Metal view is a CAMetalLayer. 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, +pressedMouseButtons and +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.txt can 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 NSColor class, so the shim's class is named FXNSColor and 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.c decodes 8-bit grayscale, gray+alpha, RGB and RGBA PNGs without a tRNS chunk 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 (UIPress HID usage → Mac key code);
    • the iOS keyboard (characters → key code, plus Shift when needed);
    • the overlay and the extra-keys bar.

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.py checks the input hash and confirms that no byte after the header changed.

  • native_audit.py plus a check of the shims' exports confirm that every import resolves.

  • PNG parity: every game PNG decoded by pngdecode.c is 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.