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.
This commit is contained in:
commit
51d085e865
89 files changed
+7999
No files matched your search
@@ -0,0 +1,284 @@
|
||||
// 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)); }
|
||||
}
|
||||
Reference in new issue
Block a user