Files
factorio-ios/platform/native/shim/boot.m
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

285 lines
14 KiB
Objective-C

// iOS application host: UIKit lifecycle, touch input, and launch of the original entry point.
#import <UIKit/UIKit.h>
#import <AVFAudio/AVFAudio.h>
#import <dlfcn.h>
#import <mach-o/dyld.h>
#import <mach-o/loader.h>
#import <stdarg.h>
#import <sys/stat.h>
#import "fx.h"
extern char **environ;
void fx_post_mouse(NSUInteger type, CGPoint point, NSInteger button, NSInteger clicks);
void fx_post_scroll(CGPoint point, CGFloat dx, CGFloat dy);
void fx_call_on_stack(void *stack_top, void (*fn)(void *), void *arg);
void fx_install_crash_reporter(void);
BOOL fx_handle_presses(NSSet<UIPress *> *presses, BOOL down);
void fx_install_controls(UIView *root);
void fx_controls_after_click(void);
void fx_patch_metal(void);
static FILE *fx_log_file;
static UIView *fx_root;
static UILabel *fx_status;
FX_EXPORT void fx_log(const char *format, ...) {
char buffer[2048];
va_list args;
va_start(args, format);
vsnprintf(buffer, sizeof buffer, format, args);
va_end(args);
fprintf(stderr, "%s\n", buffer);
if (fx_log_file) { fprintf(fx_log_file, "%s\n", buffer); fflush(fx_log_file); }
}
void fx_main_sync(void (^block)(void)) {
if (NSThread.isMainThread) block();
else dispatch_sync(dispatch_get_main_queue(), block);
}
UIView *fx_root_view(void) { return fx_root; }
static void fx_set_status(NSString *text) {
fx_log("[fx] status: %s", text.UTF8String);
fx_main_sync(^{ fx_status.text = text; fx_status.hidden = text.length == 0; });
}
// ---- Touch input: one finger = left mouse; two-finger pan = wheel; long press = right click.
@interface FXTouchView : UIView <UIGestureRecognizerDelegate>
@end
@implementation FXTouchView {
UITouch *_primary;
BOOL _rightHeld;
CGPoint _panLast;
}
- (instancetype)initWithFrame:(CGRect)frame {
if ((self = [super initWithFrame:frame])) {
self.multipleTouchEnabled = YES;
self.backgroundColor = UIColor.blackColor;
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
pan.minimumNumberOfTouches = 2;
pan.cancelsTouchesInView = NO;
pan.delegate = self;
[self addGestureRecognizer:pan];
UILongPressGestureRecognizer *hold = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(hold:)];
hold.minimumPressDuration = 0.6;
hold.cancelsTouchesInView = NO;
hold.delegate = self;
[self addGestureRecognizer:hold];
}
return self;
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
if (_primary || event.allTouches.count > 1) return;
_primary = touches.anyObject;
CGPoint p = [_primary locationInView:self];
fx_post_mouse(5, p, 0, 0); // move first so SDL knows the cursor position
fx_post_mouse(1, p, 0, _primary.tapCount ?: 1);
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
if (_primary && [touches containsObject:_primary]) fx_post_mouse(6, [_primary locationInView:self], 0, 0);
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
if (_primary && [touches containsObject:_primary]) {
fx_post_mouse(2, [_primary locationInView:self], 0, _primary.tapCount ?: 1);
_primary = nil;
fx_controls_after_click();
}
}
// Overlay buttons and the stick handle their own touches.
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)g shouldReceiveTouch:(UITouch *)touch { return touch.view == self; }
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { [self touchesEnded:touches withEvent:event]; }
- (void)pan:(UIPanGestureRecognizer *)g {
CGPoint t = [g translationInView:self], at = [g locationInView:self];
if (g.state == UIGestureRecognizerStateBegan) {
if (_primary) { fx_post_mouse(2, [_primary locationInView:self], 0, 1); _primary = nil; }
_panLast = t;
} else if (g.state == UIGestureRecognizerStateChanged) {
fx_post_scroll(at, (t.x - _panLast.x) / 10.0, (t.y - _panLast.y) / 10.0);
_panLast = t;
}
}
// Long press = hold the right mouse button until the finger lifts (mining, deconstructing,
// dragging to remove); moving while held drags with the right button.
- (void)hold:(UILongPressGestureRecognizer *)g {
CGPoint p = [g locationInView:self];
switch (g.state) {
case UIGestureRecognizerStateBegan: {
if (g.numberOfTouches != 1) return;
if (_primary) { fx_post_mouse(2, p, 0, 1); _primary = nil; }
_rightHeld = YES;
fx_post_mouse(3, p, 1, 1);
// Factorio refreshes the hovered entity on mouse motion; nudge it so mining starts
// without the finger having to move.
fx_post_mouse(7, p, 1, 0);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 80 * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{
if (self->_rightHeld) fx_post_mouse(7, p, 1, 0);
});
break;
}
case UIGestureRecognizerStateChanged:
if (_rightHeld) fx_post_mouse(7, p, 1, 0);
break;
case UIGestureRecognizerStateEnded:
case UIGestureRecognizerStateCancelled:
case UIGestureRecognizerStateFailed:
if (_rightHeld) { _rightHeld = NO; fx_post_mouse(4, p, 1, 1); fx_controls_after_click(); }
break;
default:
break;
}
}
@end
@interface FXRootViewController : UIViewController
@end
@implementation FXRootViewController
- (void)loadView { self.view = [[FXTouchView alloc] initWithFrame:UIScreen.mainScreen.bounds]; }
- (BOOL)prefersStatusBarHidden { return YES; }
- (BOOL)prefersHomeIndicatorAutoHidden { return YES; }
- (UIRectEdge)preferredScreenEdgesDeferringSystemGestures { return UIRectEdgeAll; }
- (UIInterfaceOrientationMask)supportedInterfaceOrientations { return UIInterfaceOrientationMaskLandscape; }
- (BOOL)canBecomeFirstResponder { return YES; }
- (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; [self becomeFirstResponder]; }
// Hardware keyboard (when the soft-keyboard input view is not first responder).
- (void)pressesBegan:(NSSet<UIPress *> *)p withEvent:(UIPressesEvent *)e { if (!fx_handle_presses(p, YES)) [super pressesBegan:p withEvent:e]; }
- (void)pressesEnded:(NSSet<UIPress *> *)p withEvent:(UIPressesEvent *)e { if (!fx_handle_presses(p, NO)) [super pressesEnded:p withEvent:e]; }
- (void)pressesCancelled:(NSSet<UIPress *> *)p withEvent:(UIPressesEvent *)e { if (!fx_handle_presses(p, NO)) [super pressesCancelled:p withEvent:e]; }
@end
// ---- Launch ----------------------------------------------------------------------------
typedef int (*fx_main_fn)(int, char **, char **, char **);
static struct { fx_main_fn entry; int argc; char **argv; char **apple; int result; } fx_game;
static void fx_run_main(void *unused) {
fx_game.result = fx_game.entry(fx_game.argc, fx_game.argv, environ, fx_game.apple);
}
static fx_main_fn fx_find_entry(void) {
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
const char *name = _dyld_get_image_name(i);
size_t n = strlen(name);
if (n < 15 || strcmp(name + n - 15, "/Factorio.dylib")) continue;
const struct mach_header_64 *header = (const void *)_dyld_get_image_header(i);
const struct load_command *lc = (const void *)(header + 1);
for (uint32_t c = 0; c < header->ncmds; c++) {
if (lc->cmd == LC_MAIN) {
uint64_t off = ((const struct entry_point_command *)lc)->entryoff;
fx_log("[fx] Factorio.dylib at %p slide %#lx, entry offset %#llx", header,
(long)_dyld_get_image_vmaddr_slide(i), off);
return (fx_main_fn)((const char *)header + off);
}
lc = (const void *)((const char *)lc + lc->cmdsize);
}
}
return NULL;
}
static NSString *fx_write_config(NSString *root) {
NSString *config = [root stringByAppendingPathComponent:@"config/config.ini"];
NSString *data = [root stringByAppendingPathComponent:@"data"];
NSString *write = [root stringByAppendingPathComponent:@"write"];
[NSFileManager.defaultManager createDirectoryAtPath:config.stringByDeletingLastPathComponent withIntermediateDirectories:YES attributes:nil error:nil];
[NSFileManager.defaultManager createDirectoryAtPath:write withIntermediateDirectories:YES attributes:nil error:nil];
NSString *text = [NSString stringWithContentsOfFile:config encoding:NSUTF8StringEncoding error:nil] ?: @"[path]\n";
// The container path can change between installs, so rewrite both path keys every launch.
NSMutableArray *lines = [NSMutableArray array];
for (NSString *line in [text componentsSeparatedByString:@"\n"])
if (![line hasPrefix:@"read-data="] && ![line hasPrefix:@"write-data="]) [lines addObject:line];
NSUInteger at = [lines indexOfObject:@"[path]"];
if (at == NSNotFound) { [lines insertObject:@"[path]" atIndex:0]; at = 0; }
[lines insertObject:[@"write-data=" stringByAppendingString:write] atIndex:at + 1];
[lines insertObject:[@"read-data=" stringByAppendingString:data] atIndex:at + 1];
[[lines componentsJoinedByString:@"\n"] writeToFile:config atomically:YES encoding:NSUTF8StringEncoding error:nil];
return config;
}
static void fx_launch(void) {
NSString *docs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
NSString *root = [docs stringByAppendingPathComponent:@"factorio"];
NSString *probe = [root stringByAppendingPathComponent:@"data/core/info.json"];
if (![NSFileManager.defaultManager fileExistsAtPath:probe]) {
fx_set_status(@"Game data missing.\nCopy Factorio's data folder to Documents/factorio/data\n(see tools/build_native.py --push-data)");
return;
}
NSString *config = fx_write_config(root);
fx_patch_metal();
// iOS plays nothing until the app's audio session is active. Playback + mix keeps game sound
// audible with the silent switch on, without stopping other apps' audio.
NSError *audioError = nil;
AVAudioSession *session = AVAudioSession.sharedInstance;
if (![session setCategory:AVAudioSessionCategoryPlayback withOptions:AVAudioSessionCategoryOptionMixWithOthers error:&audioError] ||
![session setActive:YES error:&audioError])
fx_log("[fx] audio session: %s", audioError.localizedDescription.UTF8String);
else
fx_log("[fx] audio session active, %.0f Hz, %ld output channels", session.sampleRate, (long)session.outputNumberOfChannels);
setenv("SDL_JOYSTICK_HIDAPI", "0", 1);
setenv("SDL_JOYSTICK_IOKIT", "0", 1);
setenv("SDL_MAC_BACKGROUND_APP", "0", 1);
NSString *dylib = [NSBundle.mainBundle.privateFrameworksPath stringByAppendingPathComponent:@"Factorio.dylib"];
fx_set_status(@"Loading Factorio…");
fx_log("[fx] dlopen %s", dylib.UTF8String);
double t0 = CACurrentMediaTime();
void *handle = dlopen(dylib.UTF8String, RTLD_NOW | RTLD_GLOBAL);
if (!handle) { fx_set_status([NSString stringWithFormat:@"dlopen failed:\n%s", dlerror()]); return; }
fx_log("[fx] dlopen ok in %.2fs", CACurrentMediaTime() - t0);
fx_game.entry = fx_find_entry();
if (!fx_game.entry) { fx_set_status(@"LC_MAIN not found in Factorio.dylib"); return; }
static char *argv[8], *apple[2];
int argc = 0;
argv[argc++] = strdup(NSBundle.mainBundle.executablePath.UTF8String);
argv[argc++] = "--config";
argv[argc++] = strdup(config.UTF8String);
argv[argc] = NULL;
apple[0] = strdup([@"executable_path=" stringByAppendingString:NSBundle.mainBundle.executablePath].UTF8String);
apple[1] = NULL;
fx_game.argc = argc; fx_game.argv = argv; fx_game.apple = apple;
fx_set_status(@"");
// macOS gives the main thread 8 MiB; iOS gives 1 MiB. Run the original main on a larger stack
// while staying on the main thread, which UIKit and SDL's Cocoa code require.
size_t size = 256u << 20;
char *stack = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
if (stack == MAP_FAILED) { fx_set_status(@"stack allocation failed"); return; }
mprotect(stack, 16384, PROT_NONE); // guard page
fx_log("[fx] calling original main");
fx_call_on_stack(stack + size, fx_run_main, NULL);
fx_log("[fx] original main returned %d", fx_game.result);
fx_set_status([NSString stringWithFormat:@"Factorio exited with code %d", fx_game.result]);
}
@interface FXAppDelegate : UIResponder <UIApplicationDelegate>
@property (strong, nonatomic) UIWindow *window;
@end
@implementation FXAppDelegate
- (BOOL)application:(UIApplication *)app didFinishLaunchingWithOptions:(NSDictionary *)options {
NSString *docs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
fx_log_file = fopen([docs stringByAppendingPathComponent:@"fx-log.txt"].UTF8String, "w");
fx_install_crash_reporter();
fx_log("[fx] host start, screen %.0fx%.0f pt @%.2fx", fx_screen_points().width, fx_screen_points().height, fx_screen_scale());
self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds];
FXRootViewController *vc = [FXRootViewController new];
self.window.rootViewController = vc;
fx_root = vc.view;
fx_status = [[UILabel alloc] initWithFrame:CGRectInset(fx_root.bounds, 40, 40)];
fx_status.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
fx_status.textColor = UIColor.whiteColor;
fx_status.numberOfLines = 0;
fx_status.textAlignment = NSTextAlignmentCenter;
[fx_root addSubview:fx_status];
fx_install_controls(fx_root);
[self.window makeKeyAndVisible];
fx_set_status(@"Starting…");
// Return from launch first; the game loop then runs inside a main-queue block and keeps
// UIKit alive by re-entering the run loop from -[NSApplication nextEventMatchingMask:…].
dispatch_async(dispatch_get_main_queue(), ^{ fx_launch(); });
return YES;
}
@end
FX_EXPORT int fx_host_main(int argc, char **argv) {
@autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass(FXAppDelegate.class)); }
}