From b00f7335db6db7d9eff88cf398002604397717b2 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:27:07 +0100 Subject: [PATCH 01/29] Add initial macOS build support Add a Darwin POSIX and WebGPU/Metal build path targeting x86-64 under Rosetta, with an Apple Silicon CI smoke check and focused tests. Also retry Builder downloads when a successful HTTP response has a truncated body. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 29 ++++ builder/setup/setup.c | 4 +- docs/development.md | 30 +++- gpu/render/rx_core.cpp | 18 ++ gpu/render/rx_internal.h | 5 + host/hos_main.c | 2 + host/posix/display_sdl.c | 45 ++++- host/posix/macos_stubs.c | 13 ++ host/posix/win32_posix.c | 224 +++++++++++++++++++++++-- host/posix/windows.h | 24 ++- host/rx_backend.c | 4 + nx2/build.py | 99 +++++++---- nx2/make_game.py | 66 +++++++- tests/test_macos_build_path.py | 57 +++++++ third_party/a64-kit/tools/toolchain.py | 10 +- 15 files changed, 569 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/macos-platform.yml create mode 100644 host/posix/macos_stubs.c create mode 100644 tests/test_macos_build_path.py diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml new file mode 100644 index 00000000..81c1e6eb --- /dev/null +++ b/.github/workflows/macos-platform.yml @@ -0,0 +1,29 @@ +name: macOS platform + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + macos-posix: + # Apple Silicon runner; Hookshot's current native context-switching assembly requires x86-64. + runs-on: macos-14-xlarge + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build configuration test + run: python -I -B tests/test_macos_build_path.py + + - name: Compile macOS POSIX host shims + run: | + clang -target x86_64-apple-macos10.15 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ + -Ihost/posix -Ihost -Ithird_party/a64-kit/rt host/posix/win32_posix.c host/hos_main.c host/rx_backend.c + clang -target x86_64-apple-macos10.15 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ + -Ihost/posix -Ihost -Ithird_party/a64-kit/rt host/posix/display_sdl.c host/posix/macos_stubs.c diff --git a/builder/setup/setup.c b/builder/setup/setup.c index f7eef7d2..7e6d082e 100644 --- a/builder/setup/setup.c +++ b/builder/setup/setup.c @@ -376,7 +376,7 @@ static DWORD WINAPI work(LPVOID unused) { ok = http_get(api, mem_sink, &man, 0, L"application/octet-stream") && man.p; if (!ok) g_http_status = first; } - if (!ok && g_http_status && g_http_status < 500) break; /* (404 and the like: trying again changes nothing) */ + if (!ok && g_http_status >= 400 && g_http_status < 500) break; /* (a 200 response can still have a truncated body) */ } if (!ok) fail(g_http_status == 404 @@ -451,7 +451,7 @@ static DWORD WINAPI work(LPVOID unused) { BCryptCloseAlgorithmProvider(a, 0); InterlockedExchange(&g_percent, -1); hex(d, 32, have); - if (!got && g_http_status && g_http_status < 500) break; + if (!got && g_http_status >= 400 && g_http_status < 500) break; } if (!got) DeleteFileW(part), fail(g_http_status >= 500 ? L"Downloading part %d of %d failed: GitHub's download servers are failing right now (tried 4 times). This is on " diff --git a/docs/development.md b/docs/development.md index e26b14d9..4033e726 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,6 +45,34 @@ background when the existing "Controller in background" option or `HOS_INPUT_BAC `HOS_INPUT_BACKGROUND=0` overrides that option. `HOS_INPUT_EXCLUSIVE=1` controls merging the player's devices while the script's existing exclusive interval is active; it is not a focus override. +## macOS (Apple Silicon, first build path) + +The current runtime has x86-64 context-switch assembly and x86-specific synchronization code, so this first macOS +target is an x86-64 executable run under Rosetta 2, not a native arm64 executable. It uses SDL2 for the window and +input, and the existing WebGPU renderer through `wgpu-native`'s Metal backend; the Vulkan backend is not built on +macOS. This provides a real Apple Silicon/Metal path while leaving native arm64 as follow-up work. + +Install Xcode Command Line Tools, Rosetta 2, and x86-64 builds of Python 3 (with the Python modules listed above), +SDL2 2.26 or newer, FFmpeg and `wgpu-native`. On Apple Silicon, the SDL2, FFmpeg, and `wgpu-native` binaries must all be x86-64; +Homebrew packages from the native arm64 prefix cannot be linked into this build. `wgpu-native` must include its +matching `include/webgpu/webgpu.h` and `lib/libwgpu_native.dylib`, built with Metal support. Then build from an +x86-64 shell, for example: + +```sh +export WGPU_NATIVE=/path/to/x86_64-wgpu-native +export HOOKSHOT_SDL2=/path/to/x86_64/libSDL2-2.0.0.dylib +export PKG_CONFIG_PATH=/path/to/x86_64/ffmpeg/lib/pkgconfig +arch -x86_64 python3 -I nx2/make_game.py --nup /path/to/game.nup --out /path/to/game-folder --jobs 4 +``` + +The normal builder command validates the x86-64 compiler, SDL2, FFmpeg and `wgpu-native` before lifting the game, +then places `hookshot`, `libSDL2-2.0.0.dylib` and `libwgpu_native.dylib` beside `image.bin` in the output folder. +For lower-level development, `nx2/lift.py --standalone --write` followed by `nx2/build.py --standalone --jobs 4` +writes the executable and libraries under `work/sa/build/`. Set `RX_BACKEND=metal` to make the renderer choice +explicit (Metal is the macOS default). As on other platforms, the local game dump is required to generate and build +the game. The `macOS platform` workflow checks the x86-64 POSIX shims and build selection on an Apple Silicon runner; +it is a compile smoke test, not a game build or a downloadable/notarized app release. + ## Layout | Path | What | @@ -53,7 +81,7 @@ the script's existing exclusive interval is active; it is not a focus override. | `nx2/` | the lift pipeline and its tests (mkw-recomp's, made game-neutral; `kit.py` has the paths) | | `host/` | the standalone Horizon runtime (TOTKPort's `hos/` as adapted by mkw-recomp), with the native NVN2 layer `nvn_native.c` | | `gpu/vk/` | the Vulkan back end (`hookshot_vk.dll`), with Eden's shader recompiler and sirit | -| `gpu/render/`, `gpu/third_party/` | the WebGPU renderer the exe falls back to, its op stream (`rx_nvn.h`), suyu's decoders | +| `gpu/render/`, `gpu/third_party/` | the WebGPU renderer (Metal on macOS), its op stream (`rx_nvn.h`), suyu's decoders | | `scripts/` | test runs and the profile reader | | `third_party/` | a64-kit (unmodified), libopus, zbic (Atmosphere's zstd with ZBIC, for unpacking only) | diff --git a/gpu/render/rx_core.cpp b/gpu/render/rx_core.cpp index dde25884..e7ff62a2 100644 --- a/gpu/render/rx_core.cpp +++ b/gpu/render/rx_core.cpp @@ -1,6 +1,10 @@ // The renderer's core: the WebGPU device and the window's surface, batches (one command encoder and its upload // memory per GPU submission), render targets, clears, the 3D engine's draw methods (suyu's DrawManager), present. +#if defined(_MSC_VER) #include +#elif defined(__x86_64__) +#include +#endif #include #include #include @@ -340,12 +344,17 @@ bool init_device(HWND hwnd) { #else WGPUInstanceExtras extras{}; extras.chain.sType = (WGPUSType)WGPUSType_InstanceExtras; +#ifdef __APPLE__ + extras.backends = WGPUInstanceBackend_Metal; +#else const char* be = getenv("RX_BACKEND"); extras.backends = !be ? WGPUInstanceBackend_Primary : !_stricmp(be, "vulkan") ? WGPUInstanceBackend_Vulkan : !_stricmp(be, "dx12") ? WGPUInstanceBackend_DX12 : !_stricmp(be, "gl") ? WGPUInstanceBackend_GL + : !_stricmp(be, "metal") ? WGPUInstanceBackend_Metal : WGPUInstanceBackend_Primary; +#endif if (getenv("RX_VALIDATION")) extras.flags = WGPUInstanceFlag_Validation | WGPUInstanceFlag_Debug; // RX_SHADER_NAMES=1: the debug flag alone (no validation layers): naga names SPIR-V variables after the WGSL ones, // so RenderDoc's shader debugger shows f_0, u_12, in_attr3... of the dumped WGSL (RX_DUMP_WGSL) @@ -383,6 +392,14 @@ bool init_device(HWND hwnd) { return false; } if (hwnd) { +#ifdef __APPLE__ + WGPUSurfaceSourceMetalLayer src{}; + src.chain.sType = WGPUSType_SurfaceSourceMetalLayer; + src.layer = hwnd; + WGPUSurfaceDescriptor sd{}; + sd.nextInChain = &src.chain; + surface = wgpuInstanceCreateSurface(dev.instance, &sd); +#else WGPUSurfaceSourceWindowsHWND src{}; src.chain.sType = WGPUSType_SurfaceSourceWindowsHWND; src.hinstance = GetModuleHandleA(nullptr); @@ -390,6 +407,7 @@ bool init_device(HWND hwnd) { WGPUSurfaceDescriptor sd{}; sd.nextInChain = &src.chain; surface = wgpuInstanceCreateSurface(dev.instance, &sd); +#endif } #endif diff --git a/gpu/render/rx_internal.h b/gpu/render/rx_internal.h index 18c7f091..1230099c 100644 --- a/gpu/render/rx_internal.h +++ b/gpu/render/rx_internal.h @@ -11,6 +11,11 @@ #include #include #include +#if defined(_MSC_VER) +#include +#elif defined(__x86_64__) +#include +#endif #include #ifdef __EMSCRIPTEN__ diff --git a/host/hos_main.c b/host/hos_main.c index df7a6278..2f93e6ae 100644 --- a/host/hos_main.c +++ b/host/hos_main.c @@ -135,7 +135,9 @@ static int reserve_guest(void) { /* (issue 312) no transparent huge pages in the guest window: the renderer imports its pools as userptr memory, and every collapse khugepaged makes in them changes their mappings, which the kernel answers by pinning the pages again at the next submission: ~250 ms a submit on RADV while the GPU sat idle. HOS_THP=1: as before */ +#ifndef __APPLE__ if (!getenv("HOS_THP")) madvise((void *)A64_HOST_BASE, GUEST_SPACE, MADV_NOHUGEPAGE); +#endif return 1; #endif } diff --git a/host/posix/display_sdl.c b/host/posix/display_sdl.c index 13d35a0b..ad96ab59 100644 --- a/host/posix/display_sdl.c +++ b/host/posix/display_sdl.c @@ -14,7 +14,9 @@ #include #include #include +#ifndef __APPLE__ #include "../../gpu/vk/vk_api.h" +#endif int rx_start(void *hwnd); void rx_resize(int width, int height); @@ -47,12 +49,23 @@ static struct { uint32_t (*GetMouseState)(int *, int *); int (*SetRelativeMouseMode)(int); int (*GetDesktopDisplayMode)(int, SdlDisplayMode *); +#ifdef __APPLE__ + void (*GetWindowSizeInPixels)(void *, int *, int *); + void *(*Metal_CreateView)(void *); + void *(*Metal_GetLayer)(void *); +#else int (*Vulkan_CreateSurface)(void *, void *, void *); void (*Vulkan_GetDrawableSize)(void *, int *, int *); +#endif void (*SetWindowTitle)(void *, const char *); } sdl; enum { SDL_INIT_VIDEO = 0x20, SDL_INIT_EVENTS = 0x4000 }; -enum { WIN_SHOWN = 0x4, WIN_RESIZABLE = 0x20, WIN_HIGHDPI = 0x2000, WIN_FULLSCREEN_DESKTOP = 0x1001, WIN_VULKAN = 0x10000000 }; +enum { WIN_SHOWN = 0x4, WIN_RESIZABLE = 0x20, WIN_HIGHDPI = 0x2000, WIN_FULLSCREEN_DESKTOP = 0x1001 }; +#ifdef __APPLE__ +enum { WIN_METAL = 0x20000000 }; +#else +enum { WIN_VULKAN = 0x10000000 }; +#endif enum { EV_QUIT = 0x100, EV_WINDOW = 0x200, EV_KEYDOWN = 0x300, EV_MOUSEMOTION = 0x400, EV_MOUSEDOWN = 0x401 }; enum { WE_SIZE_CHANGED = 6, WE_LEAVE = 11, WE_FOCUS_GAINED = 12, WE_FOCUS_LOST = 13, WE_CLOSE = 14 }; @@ -80,8 +93,14 @@ static int load_sdl(void) { GET(GetMouseState, "SDL_GetMouseState"); GET(SetRelativeMouseMode, "SDL_SetRelativeMouseMode"); GET(GetDesktopDisplayMode, "SDL_GetDesktopDisplayMode"); +#ifdef __APPLE__ + GET(GetWindowSizeInPixels, "SDL_GetWindowSizeInPixels"); + GET(Metal_CreateView, "SDL_Metal_CreateView"); + GET(Metal_GetLayer, "SDL_Metal_GetLayer"); +#else GET(Vulkan_CreateSurface, "SDL_Vulkan_CreateSurface"); GET(Vulkan_GetDrawableSize, "SDL_Vulkan_GetDrawableSize"); +#endif GET(SetWindowTitle, "SDL_SetWindowTitle"); #undef GET return 1; @@ -126,9 +145,18 @@ static volatile int renderer; /* the renderer presents into the window */ static HANDLE renderer_up; /* (set once rx_start has returned, ready or not: hos_renderer_wait) */ static int fullscreen; +#ifndef __APPLE__ static int create_surface(void *w, void *instance, void *surface) { return sdl.Vulkan_CreateSurface(w, instance, surface); } static void drawable_size(void *w, int *width, int *height) { sdl.Vulkan_GetDrawableSize(w, width, height); } static MkwVkWindow vk_window = {MKWVK_WINDOW_VERSION, create_surface, drawable_size, NULL}; +#endif +static void window_pixel_size(void *w, int *width, int *height) { +#ifdef __APPLE__ + sdl.GetWindowSizeInPixels(w, width, height); +#else + sdl.Vulkan_GetDrawableSize(w, width, height); +#endif +} static void mouse_capture(int on) { if (on == (int)mouse_captured) return; @@ -147,12 +175,19 @@ static void set_fullscreen(int on) { static DWORD WINAPI renderer_main(void *p) { (void)p; +#ifdef __APPLE__ + void *view = sdl.Metal_CreateView(window); + void *layer = view ? sdl.Metal_GetLayer(view) : NULL; + if (!layer) hos_log("display: SDL_Metal_CreateView did not provide a Metal layer"); + const int ok = layer ? rx_start(layer) : 0; +#else const int ok = rx_start(&vk_window); +#endif if (!ok) hos_log("display: the renderer did not start: the window stays black"); renderer = ok; if (ok) { /* (a size change while it started) */ int w = 0, h = 0; - sdl.Vulkan_GetDrawableSize(window, &w, &h); + window_pixel_size(window, &w, &h); if (w > 0 && h > 0) rx_resize(w, h); } SetEvent(renderer_up); @@ -217,7 +252,11 @@ void hos_display_loop(void) { if (sscanf(getenv("HOS_WINDOW_SIZE"), "%dx%d", &w, &h) == 2 && w >= 64 && h >= 64) win_w = w, win_h = h; } window = sdl.CreateWindow(HOS_GAME_TITLE, 0x2FFF0000 /* centred */, 0x2FFF0000, win_w, win_h, +#ifdef __APPLE__ + WIN_SHOWN | WIN_RESIZABLE | WIN_HIGHDPI | WIN_METAL); +#else WIN_SHOWN | WIN_RESIZABLE | WIN_HIGHDPI | WIN_VULKAN); +#endif if (!window) { hos_log("display: SDL_CreateWindow: %s: no window", sdl.GetError()); rx_start(NULL); @@ -241,7 +280,7 @@ void hos_display_loop(void) { const uint8_t what = e.raw[12]; if (what == WE_SIZE_CHANGED && renderer) { int w = 0, h = 0; - sdl.Vulkan_GetDrawableSize(window, &w, &h); + window_pixel_size(window, &w, &h); rx_resize(w, h); } else if (what == WE_FOCUS_GAINED) { hos_posix_window[0] = 1; diff --git a/host/posix/macos_stubs.c b/host/posix/macos_stubs.c new file mode 100644 index 00000000..e9fb5439 --- /dev/null +++ b/host/posix/macos_stubs.c @@ -0,0 +1,13 @@ +#include +#include + +void hos_prof_start(void) {} + +typedef void (*mf_av1_picture_fn)(void *user, const uint8_t *y, const uint8_t *uv, int w, int h, int stride, int64_t pts); + +void *mf_av1_open(int w, int h) { (void)w, (void)h; return NULL; } +void mf_av1_decode(void *m, const uint8_t *data, size_t n, int64_t pts, mf_av1_picture_fn fn, void *user) { + (void)m, (void)data, (void)n, (void)pts, (void)fn, (void)user; +} +void mf_av1_flush(void *m) { (void)m; } +void mf_av1_close(void *m) { (void)m; } diff --git a/host/posix/win32_posix.c b/host/posix/win32_posix.c index 8284b9a0..8b79ec72 100644 --- a/host/posix/win32_posix.c +++ b/host/posix/win32_posix.c @@ -1,4 +1,4 @@ -/* (linux) The Win32 calls of host/posix/windows.h, on POSIX. Built into the executable and, on its own, into +/* The Win32 calls of host/posix/windows.h, on POSIX. Built into the executable and, on its own, into hookshot_vk.so (each its own copy, -Bsymbolic: the executable exports nothing of it). */ #define _GNU_SOURCE #include @@ -9,10 +9,17 @@ #include #include #include +#ifdef __APPLE__ +#include +#include +#include +#include +#else #include +#include +#endif #include #include -#include #include #include @@ -50,9 +57,22 @@ static int64_t now_ns(void) { clock_gettime(CLOCK_MONOTONIC, &ts); return (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec; } +static uint64_t current_tid(void) { +#ifdef __APPLE__ + uint64_t tid = 0; + pthread_threadid_np(NULL, &tid); + return tid; +#else + return (uint64_t)syscall(SYS_gettid); +#endif +} static struct timespec abs_after_ms(DWORD ms) { struct timespec ts; +#ifdef __APPLE__ + clock_gettime(CLOCK_REALTIME, &ts); +#else clock_gettime(CLOCK_MONOTONIC, &ts); +#endif ts.tv_sec += ms / 1000; ts.tv_nsec += (long)(ms % 1000) * 1000000L; if (ts.tv_nsec >= 1000000000L) ts.tv_sec++, ts.tv_nsec -= 1000000000L; @@ -61,7 +81,9 @@ static struct timespec abs_after_ms(DWORD ms) { static void monitor_init(pthread_mutex_t *mu, pthread_cond_t *cv) { pthread_condattr_t a; pthread_condattr_init(&a); +#ifndef __APPLE__ pthread_condattr_setclock(&a, CLOCK_MONOTONIC); +#endif pthread_cond_init(cv, &a); pthread_condattr_destroy(&a); pthread_mutex_init(mu, NULL); @@ -77,7 +99,79 @@ static void monitor_init(pthread_mutex_t *mu, pthread_cond_t *cv) { } \ } while (0) -/* ---- futexes: condition variables, WaitOnAddress ---- */ +/* ---- address waits: futexes on Linux, condition buckets on macOS ---- */ +#ifdef __APPLE__ +typedef struct { pthread_mutex_t mu; pthread_cond_t cv; } WaitBucket; +static WaitBucket wait_buckets[64]; +static pthread_once_t wait_buckets_once = PTHREAD_ONCE_INIT; +static void wait_buckets_init(void) { + for (size_t i = 0; i < sizeof wait_buckets / sizeof wait_buckets[0]; i++) { + pthread_mutex_init(&wait_buckets[i].mu, NULL); + pthread_cond_init(&wait_buckets[i].cv, NULL); + } +} +static WaitBucket *wait_bucket(const volatile void *addr) { + pthread_once(&wait_buckets_once, wait_buckets_init); + return &wait_buckets[((uintptr_t)addr >> 2) & 63]; +} +static BOOL bucket_wait(WaitBucket *b, DWORD ms, int (*changed)(void *), void *arg) { + int r = 0; + if (ms == INFINITE) { + while (!changed(arg)) pthread_cond_wait(&b->cv, &b->mu); + } else { + const struct timespec until = abs_after_ms(ms); + while (!changed(arg) && r != ETIMEDOUT) r = pthread_cond_timedwait(&b->cv, &b->mu, &until); + } + if (!changed(arg) && r == ETIMEDOUT) { last_error = ERROR_TIMEOUT; return FALSE; } + return TRUE; +} +typedef struct { CONDITION_VARIABLE *cv; uint32_t seq; } CvWait; +static int cv_changed(void *p) { + CvWait *w = (CvWait *)p; + return __atomic_load_n(&w->cv->seq, __ATOMIC_SEQ_CST) != w->seq; +} +static void wake_cv(CONDITION_VARIABLE *cv, int all) { + WaitBucket *b = wait_bucket(cv); + __atomic_add_fetch(&cv->seq, 1, __ATOMIC_SEQ_CST); + pthread_mutex_lock(&b->mu); + if (all) pthread_cond_broadcast(&b->cv); else pthread_cond_signal(&b->cv); + pthread_mutex_unlock(&b->mu); +} +void WakeConditionVariable(CONDITION_VARIABLE *cv) { wake_cv(cv, 0); } +void WakeAllConditionVariable(CONDITION_VARIABLE *cv) { wake_cv(cv, 1); } +static BOOL cv_wait(CONDITION_VARIABLE *cv, DWORD ms, void (*unlock)(void *), void (*relock)(void *, ULONG), void *l, ULONG flags) { + WaitBucket *b = wait_bucket(cv); + CvWait wait = {cv, __atomic_load_n(&cv->seq, __ATOMIC_SEQ_CST)}; + pthread_mutex_lock(&b->mu); + unlock(l); + const BOOL result = bucket_wait(b, ms, cv_changed, &wait); + pthread_mutex_unlock(&b->mu); + relock(l, flags); + return result; +} +typedef struct { volatile void *addr; void *compare; SIZE_T size; } AddressWait; +static int address_changed(void *p) { + AddressWait *w = (AddressWait *)p; + return memcmp((const void *)w->addr, w->compare, w->size) != 0; +} +BOOL WaitOnAddress(volatile void *addr, void *compare, SIZE_T size, DWORD ms) { + WaitBucket *b = wait_bucket(addr); + AddressWait wait = {addr, compare, size}; + pthread_mutex_lock(&b->mu); + if (address_changed(&wait)) { pthread_mutex_unlock(&b->mu); return TRUE; } + const BOOL result = bucket_wait(b, ms, address_changed, &wait); + pthread_mutex_unlock(&b->mu); + return result; +} +static void wake_address(void *addr) { + WaitBucket *b = wait_bucket(addr); + pthread_mutex_lock(&b->mu); + pthread_cond_broadcast(&b->cv); + pthread_mutex_unlock(&b->mu); +} +void WakeByAddressSingle(void *addr) { wake_address(addr); } +void WakeByAddressAll(void *addr) { wake_address(addr); } +#else static long futex(volatile uint32_t *a, int op, uint32_t v, const struct timespec *ts) { return syscall(SYS_futex, a, op, v, ts, NULL, 0); } @@ -135,6 +229,7 @@ BOOL WaitOnAddress(volatile void *addr, void *compare, SIZE_T size, DWORD ms) { } void WakeByAddressSingle(void *addr) { futex((volatile uint32_t *)((uintptr_t)addr & ~(uintptr_t)3), FUTEX_WAKE_PRIVATE, INT_MAX, NULL); } void WakeByAddressAll(void *addr) { futex((volatile uint32_t *)((uintptr_t)addr & ~(uintptr_t)3), FUTEX_WAKE_PRIVATE, INT_MAX, NULL); } +#endif /* ---- time ---- */ void Sleep(DWORD ms) { @@ -195,7 +290,7 @@ static HThread *thread_self(void) { if (!self_thread) { /* (a thread this file did not start: the main thread, a library's) */ HThread *t = thread_new(); t->th = pthread_self(); - t->tid = (pid_t)syscall(SYS_gettid); + t->tid = (pid_t)current_tid(); t->refs = 1; /* (kept for the thread's life) */ self_thread = t; } @@ -219,7 +314,7 @@ static void thread_finish(DWORD code) { static void *thread_entry(void *p) { HThread *t = (HThread *)p; self_thread = t; - t->tid = (pid_t)syscall(SYS_gettid); + t->tid = (pid_t)current_tid(); thread_finish(t->fn(t->arg)); return NULL; } @@ -274,7 +369,11 @@ BOOL SetThreadPriority(HANDLE h, int p) { if (!t) { last_error = ERROR_INVALID_HANDLE; return FALSE; } t->priority = p; const int nice = p <= THREAD_PRIORITY_IDLE ? 19 : p <= THREAD_PRIORITY_LOWEST ? 10 : p == THREAD_PRIORITY_BELOW_NORMAL ? 5 : 0; +#ifndef __APPLE__ if (nice && t->tid) setpriority(PRIO_PROCESS, (id_t)t->tid, nice); +#else + (void)nice; +#endif return TRUE; } int GetThreadPriority(HANDLE h) { @@ -282,6 +381,11 @@ int GetThreadPriority(HANDLE h) { return t ? t->priority : 0; } DWORD_PTR SetThreadAffinityMask(HANDLE h, DWORD_PTR mask) { +#ifdef __APPLE__ + (void)h, (void)mask; + last_error = ERROR_NOT_SUPPORTED; + return 0; +#else HThread *t = thread_of(h); if (!t || !mask) { last_error = ERROR_INVALID_PARAMETER; return 0; } cpu_set_t old, want; @@ -293,8 +397,17 @@ DWORD_PTR SetThreadAffinityMask(HANDLE h, DWORD_PTR mask) { DWORD_PTR r = 0; for (int i = 0; i < 64; i++) if (CPU_ISSET(i, &old)) r |= (DWORD_PTR)1 << i; return r ? r : 1; +#endif } BOOL GetProcessAffinityMask(HANDLE p, DWORD_PTR *proc, DWORD_PTR *sys) { +#ifdef __APPLE__ + (void)p; + long n = sysconf(_SC_NPROCESSORS_ONLN); + if (n < 1) n = 1; + if (n > (long)(sizeof(DWORD_PTR) * 8)) n = sizeof(DWORD_PTR) * 8; + *proc = *sys = n == (long)(sizeof(DWORD_PTR) * 8) ? ~(DWORD_PTR)0 : (((DWORD_PTR)1 << n) - 1); + return TRUE; +#else (void)p; cpu_set_t s; CPU_ZERO(&s); @@ -305,6 +418,7 @@ BOOL GetProcessAffinityMask(HANDLE p, DWORD_PTR *proc, DWORD_PTR *sys) { *proc = m; *sys = n >= 64 ? ~(DWORD_PTR)0 : (((DWORD_PTR)1 << n) - 1); return TRUE; +#endif } int WideCharToMultiByte(UINT cp, DWORD flags, LPCWSTR w, int n, LPSTR s, int cap, LPCSTR def, BOOL *used) { (void)cp, (void)flags, (void)def; @@ -354,7 +468,11 @@ HRESULT SetThreadDescription(HANDLE h, PCWSTR name) { t->name[sizeof t->name - 1] = 0; char shortname[16]; snprintf(shortname, sizeof shortname, "%s", t->name); /* (the kernel keeps 15 characters) */ +#ifdef __APPLE__ + if (pthread_equal(pthread_self(), t->th)) pthread_setname_np(shortname); +#else pthread_setname_np(t->th, shortname); +#endif return S_OK; } HRESULT GetThreadDescription(HANDLE h, PWSTR *name) { @@ -371,6 +489,11 @@ static void filetime_ns(FILETIME *ft, int64_t ns) { ft->dwLowDateTime = (DWORD)t, ft->dwHighDateTime = (DWORD)(t >> 32); } BOOL GetThreadTimes(HANDLE h, FILETIME *create, FILETIME *exit_, FILETIME *kernel, FILETIME *user) { +#ifdef __APPLE__ + (void)h, (void)create, (void)exit_, (void)kernel, (void)user; + last_error = ERROR_NOT_SUPPORTED; + return FALSE; +#else HThread *t = thread_of(h); clockid_t clk; struct timespec ts; @@ -378,6 +501,7 @@ BOOL GetThreadTimes(HANDLE h, FILETIME *create, FILETIME *exit_, FILETIME *kerne filetime_ns(create, 0), filetime_ns(exit_, 0), filetime_ns(kernel, 0); filetime_ns(user, (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec); return TRUE; +#endif } /* (no suspending another thread: the samplers that do it find nothing to sample) */ DWORD SuspendThread(HANDLE h) { (void)h; last_error = ERROR_NOT_SUPPORTED; return (DWORD)-1; } @@ -477,8 +601,11 @@ DWORD WaitForSingleObject(HANDLE h, DWORD ms) { pthread_mutex_unlock(&t->mu); } else if (is(h, H_TIMER)) { const int64_t due = ((HTimer *)h)->due; - struct timespec ts = {(time_t)(due / 1000000000LL), (long)(due % 1000000000LL)}; - while (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &ts, NULL) == EINTR) {} + while (now_ns() < due) { + const int64_t left = due - now_ns(); + struct timespec ts = {(time_t)(left / 1000000000LL), (long)(left % 1000000000LL)}; + while (nanosleep(&ts, &ts) && errno == EINTR) {} + } r = WAIT_OBJECT_0; } else { last_error = ERROR_INVALID_HANDLE; @@ -635,7 +762,12 @@ void *VirtualAlloc(void *addr, SIZE_T size, DWORD type, DWORD protect) { const uintptr_t page = 4096; if (type & MEM_RESET) { /* (the contents are not wanted any more: they read as zero after this) */ const uintptr_t lo = ((uintptr_t)addr + page - 1) & ~(page - 1), hi = ((uintptr_t)addr + size) & ~(page - 1); - if (hi > lo) madvise((void *)lo, hi - lo, MADV_DONTNEED); + if (hi > lo) { +#ifdef __APPLE__ + memset((void *)lo, 0, hi - lo); +#endif + madvise((void *)lo, hi - lo, MADV_DONTNEED); + } return addr; } if (addr && (type & MEM_COMMIT) && !(type & MEM_RESERVE)) { @@ -661,6 +793,9 @@ void *VirtualAlloc(void *addr, SIZE_T size, DWORD type, DWORD protect) { BOOL VirtualFree(void *addr, SIZE_T size, DWORD type) { if (type & MEM_DECOMMIT) { /* (kept mapped: decommitted memory reads as zero when touched again) */ const uintptr_t lo = (uintptr_t)addr & ~(uintptr_t)4095, hi = ((uintptr_t)addr + size + 4095) & ~(uintptr_t)4095; +#ifdef __APPLE__ + if (hi > lo) memset((void *)lo, 0, hi - lo); +#endif return madvise((void *)lo, hi - lo, MADV_DONTNEED) == 0 ? TRUE : fail_errno(); } if (type & MEM_RELEASE) { @@ -695,6 +830,26 @@ SIZE_T VirtualQuery(const void *addr, MEMORY_BASIC_INFORMATION *mbi, SIZE_T size return sizeof *mbi; } /* (anything else: the kernel's map of the process) */ +#ifdef __APPLE__ + mach_vm_address_t region = page; + mach_vm_size_t region_size = 0; + vm_region_basic_info_data_64_t info; + mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64; + mach_port_t object = MACH_PORT_NULL; + const kern_return_t kr = mach_vm_region(mach_task_self(), ®ion, ®ion_size, VM_REGION_BASIC_INFO_64, + (vm_region_info_t)&info, &count, &object); + if (object != MACH_PORT_NULL) mach_port_deallocate(mach_task_self(), object); + if (kr != KERN_SUCCESS || page < region || page >= region + region_size) return sizeof *mbi; + mbi->AllocationBase = (void *)(uintptr_t)region; + mbi->RegionSize = (SIZE_T)(region + region_size - page); + const vm_prot_t prot = info.protection; + mbi->State = prot ? MEM_COMMIT : MEM_RESERVE; + mbi->Protect = mbi->AllocationProtect = !(prot & VM_PROT_READ) ? PAGE_NOACCESS + : prot & VM_PROT_EXECUTE ? ((prot & VM_PROT_WRITE) ? PAGE_EXECUTE_READWRITE : PAGE_EXECUTE_READ) + : (prot & VM_PROT_WRITE) ? PAGE_READWRITE : PAGE_READONLY; + mbi->Type = 0x20000; + return sizeof *mbi; +#else FILE *f = fopen("/proc/self/maps", "r"); char line[512]; uintptr_t prev_end = 0; @@ -720,16 +875,31 @@ SIZE_T VirtualQuery(const void *addr, MEMORY_BASIC_INFORMATION *mbi, SIZE_T size } if (f) fclose(f); return sizeof *mbi; +#endif } /* ---- modules ---- */ static void exe_path(char *out, size_t cap) { +#ifdef __APPLE__ + char path[PATH_MAX], resolved[PATH_MAX]; + uint32_t n = sizeof path; + if (_NSGetExecutablePath(path, &n) != 0 || !realpath(path, resolved)) { + if (cap) out[0] = 0; + return; + } + if (cap) snprintf(out, cap, "%s", resolved); +#else const ssize_t n = readlink("/proc/self/exe", out, cap - 1); out[n > 0 ? n : 0] = 0; +#endif } -/* a Windows library name, as the Linux one: SDL2.dll -> libSDL2-2.0.so.0, vulkan-1.dll -> libvulkan.so.1, x.dll -> x.so */ -static const char *linux_name(const char *base) { +/* Windows library names mapped to their POSIX equivalents. */ +static const char *posix_name(const char *base) { +#ifdef __APPLE__ + static const char *map[][2] = {{"sdl2.dll", "libSDL2-2.0.0.dylib"}, {"vulkan-1.dll", "libvulkan.1.dylib"}}; +#else static const char *map[][2] = {{"sdl2.dll", "libSDL2-2.0.so.0"}, {"vulkan-1.dll", "libvulkan.so.1"}}; +#endif for (size_t i = 0; i < sizeof map / sizeof map[0]; i++) if (!strcasecmp(base, map[i][0])) return map[i][1]; return NULL; @@ -741,14 +911,18 @@ HMODULE LoadLibraryA(LPCSTR name) { for (char *p = path; *p; p++) if (*p == '\\') *p = '/'; char *slash = strrchr(path, '/'); char *base = slash ? slash + 1 : path; - const char *mapped = linux_name(base); + const char *mapped = posix_name(base); if (mapped) { snprintf(base, sizeof path - (size_t)(base - path), "%s", mapped); } else { const size_t n = strlen(path); if (n > 4 && !strcasecmp(path + n - 4, ".dll")) { if (!slash) return NULL; /* (a Windows system library: kernel32, xinput, imm32 ...) */ +#ifdef __APPLE__ + strcpy(path + n - 4, ".dylib"); +#else strcpy(path + n - 4, ".so"); +#endif } } HMODULE m = dlopen(path, RTLD_NOW | RTLD_LOCAL); @@ -787,7 +961,9 @@ DWORD GetModuleFileNameA(HMODULE m, LPSTR out, DWORD cap) { if (!path[0]) exe_path(path, sizeof path); } else { void *lm = NULL; /* (a handle from LoadLibraryA) */ +#ifndef __APPLE__ if (dlinfo(m, RTLD_DI_LINKMAP, &lm) == 0 && lm) snprintf(path, sizeof path, "%s", ((const char **)lm)[1]); +#endif } if (!cap) return 0; const size_t n = strlen(path); @@ -1070,20 +1246,38 @@ static struct sigaction previous[32]; static pthread_mutex_t handlers_mu = PTHREAD_MUTEX_INITIALIZER; static void context_from(CONTEXT *c, const ucontext_t *uc) { +#ifdef __APPLE__ + const x86_thread_state64_t *g = &uc->uc_mcontext->__ss; + c->Rax = g->__rax, c->Rcx = g->__rcx, c->Rdx = g->__rdx, c->Rbx = g->__rbx; + c->Rsp = g->__rsp, c->Rbp = g->__rbp, c->Rsi = g->__rsi, c->Rdi = g->__rdi; + c->R8 = g->__r8, c->R9 = g->__r9, c->R10 = g->__r10, c->R11 = g->__r11; + c->R12 = g->__r12, c->R13 = g->__r13, c->R14 = g->__r14, c->R15 = g->__r15; + c->Rip = g->__rip, c->EFlags = g->__rflags, c->ContextFlags = CONTEXT_FULL; +#else const greg_t *g = uc->uc_mcontext.gregs; c->Rax = (DWORD64)g[REG_RAX], c->Rcx = (DWORD64)g[REG_RCX], c->Rdx = (DWORD64)g[REG_RDX], c->Rbx = (DWORD64)g[REG_RBX]; c->Rsp = (DWORD64)g[REG_RSP], c->Rbp = (DWORD64)g[REG_RBP], c->Rsi = (DWORD64)g[REG_RSI], c->Rdi = (DWORD64)g[REG_RDI]; c->R8 = (DWORD64)g[REG_R8], c->R9 = (DWORD64)g[REG_R9], c->R10 = (DWORD64)g[REG_R10], c->R11 = (DWORD64)g[REG_R11]; c->R12 = (DWORD64)g[REG_R12], c->R13 = (DWORD64)g[REG_R13], c->R14 = (DWORD64)g[REG_R14], c->R15 = (DWORD64)g[REG_R15]; c->Rip = (DWORD64)g[REG_RIP], c->EFlags = (DWORD)g[REG_EFL], c->ContextFlags = CONTEXT_FULL; +#endif } static void context_to(ucontext_t *uc, const CONTEXT *c) { +#ifdef __APPLE__ + x86_thread_state64_t *g = &uc->uc_mcontext->__ss; + g->__rax = c->Rax, g->__rcx = c->Rcx, g->__rdx = c->Rdx, g->__rbx = c->Rbx; + g->__rsp = c->Rsp, g->__rbp = c->Rbp, g->__rsi = c->Rsi, g->__rdi = c->Rdi; + g->__r8 = c->R8, g->__r9 = c->R9, g->__r10 = c->R10, g->__r11 = c->R11; + g->__r12 = c->R12, g->__r13 = c->R13, g->__r14 = c->R14, g->__r15 = c->R15; + g->__rip = c->Rip; +#else greg_t *g = uc->uc_mcontext.gregs; g[REG_RAX] = (greg_t)c->Rax, g[REG_RCX] = (greg_t)c->Rcx, g[REG_RDX] = (greg_t)c->Rdx, g[REG_RBX] = (greg_t)c->Rbx; g[REG_RSP] = (greg_t)c->Rsp, g[REG_RBP] = (greg_t)c->Rbp, g[REG_RSI] = (greg_t)c->Rsi, g[REG_RDI] = (greg_t)c->Rdi; g[REG_R8] = (greg_t)c->R8, g[REG_R9] = (greg_t)c->R9, g[REG_R10] = (greg_t)c->R10, g[REG_R11] = (greg_t)c->R11; g[REG_R12] = (greg_t)c->R12, g[REG_R13] = (greg_t)c->R13, g[REG_R14] = (greg_t)c->R14, g[REG_R15] = (greg_t)c->R15; g[REG_RIP] = (greg_t)c->Rip; +#endif } static void on_signal(int sig, siginfo_t *si, void *uctx) { ucontext_t *uc = (ucontext_t *)uctx; @@ -1097,7 +1291,11 @@ static void on_signal(int sig, siginfo_t *si, void *uctx) { rec.ExceptionAddress = (void *)(uintptr_t)ctx.Rip; if (sig == SIGSEGV || sig == SIGBUS) { rec.NumberParameters = 2; +#ifdef __APPLE__ + rec.ExceptionInformation[0] = (uc->uc_mcontext->__es.__err & 2) ? 1 : 0; +#else rec.ExceptionInformation[0] = (uc->uc_mcontext.gregs[REG_ERR] & 2) ? 1 : 0; /* (1: a write) */ +#endif rec.ExceptionInformation[1] = (ULONG_PTR)si->si_addr; } EXCEPTION_POINTERS ep = {&rec, &ctx}; @@ -1220,7 +1418,11 @@ int MessageBoxA(HWND w, LPCSTR text, LPCSTR caption, UINT type) { (void)w, (void)type; fprintf(stderr, "%s: %s\n", caption ? caption : "Hookshot", text ? text : ""); typedef int (*ShowFn)(uint32_t, const char *, const char *, void *); +#ifdef __APPLE__ + void *sdl = dlopen("libSDL2-2.0.0.dylib", RTLD_NOW | RTLD_NOLOAD); +#else void *sdl = dlopen("libSDL2-2.0.so.0", RTLD_NOW | RTLD_NOLOAD); +#endif ShowFn show = sdl ? (ShowFn)dlsym(sdl, "SDL_ShowSimpleMessageBox") : NULL; if (show) show(0x10, caption ? caption : "Hookshot", text ? text : "", NULL); return 1; diff --git a/host/posix/windows.h b/host/posix/windows.h index 85d262f0..b5f04c43 100644 --- a/host/posix/windows.h +++ b/host/posix/windows.h @@ -1,5 +1,5 @@ -/* (linux) The part of the Win32 API Hookshot's runtime and Vulkan back end use, on POSIX: host/posix is on the include - path of Linux builds only (nx2/build.py), so `#include ` finds this file and the Windows code paths stay +/* The part of the Win32 API Hookshot's runtime and Vulkan back end use, on POSIX: host/posix is on the include + path of Linux and macOS builds (nx2/build.py), so `#include ` finds this file and the Windows code paths stay as they are. Implemented in win32_posix.c (threads, events, fibers, files, memory, modules, exceptions, waveOut), gdi_posix.c (the bit of GDI the Insert menu draws its text with) and display_sdl.c (the window, keyboard, mouse). @@ -25,6 +25,22 @@ #include #include #include +#ifndef MAP_NORESERVE +#define MAP_NORESERVE 0 +#endif +#ifndef MAP_STACK +#define MAP_STACK 0 +#endif +#ifndef MAP_FIXED_NOREPLACE +#define MAP_FIXED_NOREPLACE 0 +#endif +#ifndef MADV_DONTNEED +#ifdef MADV_FREE_REUSABLE +#define MADV_DONTNEED MADV_FREE_REUSABLE +#else +#define MADV_DONTNEED 4 +#endif +#endif #ifdef __cplusplus extern "C" { #endif @@ -148,7 +164,11 @@ void SetLastError(DWORD e); #define InterlockedCompareExchangePointer InterlockedCompareExchange #define MemoryBarrier() __atomic_thread_fence(__ATOMIC_SEQ_CST) #define _ReadWriteBarrier() __atomic_signal_fence(__ATOMIC_SEQ_CST) +#if defined(__aarch64__) +#define YieldProcessor() __builtin_arm_yield() +#else #define YieldProcessor() __builtin_ia32_pause() +#endif /* ---- locks: SRW locks are POSIX rwlocks (zero-initialised is valid, as on Windows); condition variables a futex word, so they wait with either an SRW lock or a critical section ---- */ diff --git a/host/rx_backend.c b/host/rx_backend.c index 02d735cc..50bc9c1c 100644 --- a/host/rx_backend.c +++ b/host/rx_backend.c @@ -212,6 +212,9 @@ static const MkwVkRenderer *load_vulkan(const char *dll) { int rx_start(void *hwnd) { const char *b = getenv("RX_BACKEND"); +#ifdef __APPLE__ + if (b && _stricmp(b, "metal")) hos_log("rx: RX_BACKEND=%s is not supported on macOS; using Metal via WebGPU", b); +#else const int eden = b && !_stricmp(b, "eden"); if (!b || !_stricmp(b, "vulkan") || eden) { /* (hookshot: Vulkan unless told otherwise; a started game has no RX_BACKEND) */ const char *dll = eden ? "mkw_eden.dll" : "hookshot_vk.dll"; @@ -224,6 +227,7 @@ int rx_start(void *hwnd) { } if (r) hos_log("rx: the Vulkan back end could not start on the window: WebGPU"); } +#endif const int ok = wgr_start(hwnd); flush_early(); return ok; diff --git a/nx2/build.py b/nx2/build.py index 51ca7174..340bd252 100644 --- a/nx2/build.py +++ b/nx2/build.py @@ -44,20 +44,21 @@ PGO = os.environ.get("RX_PGO", "") -# (linux) Linux builds: an ELF executable and hookshot_vk.so, the Win32 calls of host/ and gpu/vk on POSIX -# (host/posix: its windows.h is found first), the window and sound on SDL2. HOOKSHOT_SYSROOT=: compile and link -# against that root (the AppImage's: glibc 2.35, so the game runs on older systems than the one that built it). -# HOOKSHOT_VK_PREBUILT=: the Vulkan back end as built already (the AppImage's), not compiled here. -LINUX = os.name != "nt" +WINDOWS = sys.platform == "win32" +LINUX = sys.platform.startswith("linux") +MACOS = sys.platform == "darwin" +POSIX_HOST = LINUX or MACOS +# Linux uses Vulkan. macOS targets x86-64 on Apple Silicon through Rosetta and uses WebGPU's Metal backend. POSIX = os.path.join(kit.HERE, "..", "host", "posix") SYSROOT = os.environ.get("HOOKSHOT_SYSROOT", "") -EXE = "hookshot" if LINUX else "hookshot.exe" +EXE = "hookshot" if POSIX_HOST else "hookshot.exe" VK_LIB = "hookshot_vk.so" if LINUX else "hookshot_vk.dll" def target(): """toolchain.native_target(), with the sysroot on Linux""" - return toolchain.native_target() + (["--sysroot=" + SYSROOT] if LINUX and SYSROOT else []) + target = ["--target=x86_64-apple-macos10.15"] if MACOS else toolchain.native_target() + return target + (["--sysroot=" + SYSROOT] if LINUX and SYSROOT else []) def no_throttle(handle=None): @@ -83,18 +84,20 @@ def HOT_ORDER(): return [] if LINUX: return ["-Wl,--symbol-ordering-file=" + p, "-Wl,--no-warn-symbol-ordering"] + if MACOS: + return [] return [toolchain.link_opt("/order:@" + p.replace("\\", "/")), toolchain.link_opt("/ignore:4037")] -HOST_LIBS = ["-lpthread", "-ldl", "-lm"] if LINUX else \ - ["-lwinhttp", "-luser32", "-lwinmm", "-ladvapi32", "-lole32", "-lshell32", "-lgdi32", "-ldbghelp"] +HOST_LIBS = (["-lpthread", "-ldl", "-lm"] if LINUX else ["-pthread", "-lm"] if MACOS else + ["-lwinhttp", "-luser32", "-lwinmm", "-ladvapi32", "-lole32", "-lshell32", "-lgdi32", "-ldbghelp"]) GPU = os.path.join(kit.HERE, "..", "gpu") # FFmpeg (libavcodec's VP8/VP9 decoders, swscale) for nn::codec's video decoders (host/nn_video.c): the static build # in the Eglantine clone's package cache FFMPEG = None # (found in the CPM caches below, once cpm_dirs is defined) # the renderer's WebGPU: a wgpu-native release (include/, lib/); TOTKPort's build of it by default -WGPU = os.environ.get("WGPU_NATIVE", os.path.join(os.path.expanduser("~"), "Desktop", "TOTKPort", "third_party", - "wgpu-native")) +WGPU = os.environ.get("WGPU_NATIVE", os.path.join(os.path.expanduser("~"), "wgpu-native") if MACOS else + os.path.join(os.path.expanduser("~"), "Desktop", "TOTKPort", "third_party", "wgpu-native")) def gpu_sources(): @@ -198,7 +201,7 @@ def ffmpeg_linux(): return None -if LINUX: +if POSIX_HOST: FFMPEG_LX = ffmpeg_linux() FFMPEG = None @@ -209,7 +212,7 @@ def ffmpeg_version_key(): incs = [] if FFMPEG: incs.append(os.path.join(FFMPEG, "include")) - if LINUX and FFMPEG_LX: + if POSIX_HOST and FFMPEG_LX: incs += [f[2:] for f in FFMPEG_LX[0] if f.startswith("-I")] incs.append("/usr/include") for inc in incs: @@ -253,8 +256,8 @@ def host_march(): return "x86-64-v3" -MARCH = os.environ.get("RX_MARCH") or host_march() -if MARCH != "x86-64-v3": +MARCH = os.environ.get("RX_MARCH") or ("x86-64" if MACOS else host_march()) +if not MACOS and MARCH != "x86-64-v3": print("build: this CPU has no AVX2: the game is built for %s (a little slower)" % MARCH, flush=True) CPU = ["-march=" + MARCH, "-ffp-contract=off"] @@ -385,9 +388,11 @@ def main(): # its own stand-ins, host/posix/linux_stubs.c) have_wgpu = os.path.isfile(os.path.join(WGPU, "include", "webgpu", "webgpu.h")) host_srcs = sorted(os.path.join(HOST, f) for f in os.listdir(HOST) if f.endswith(".c") and (f != "pgo_rt.c" or PGO == "gen") - and not (LINUX and f in ("display.c", "mf_av1.c", "rx_nowgpu.c")) and (f != "rx_nowgpu.c" or not have_wgpu)) + and not (POSIX_HOST and f in ("display.c", "mf_av1.c", "rx_nowgpu.c")) and (f != "rx_nowgpu.c" or not have_wgpu)) if LINUX: # (the window on SDL2, the Win32 calls on POSIX, no Media Foundation and no WebGPU renderer) host_srcs += [os.path.join(POSIX, f) for f in ("win32_posix.c", "display_sdl.c", "linux_stubs.c", "prof.c")] + elif MACOS: + host_srcs += [os.path.join(POSIX, f) for f in ("win32_posix.c", "display_sdl.c", "macos_stubs.c")] else: srcs += [os.path.join(NX2_RT, "eden_shim.c"), write_info(out)] if os.environ.get("RX_VK_ONLY"): # (no lift needed: the back end alone) @@ -410,18 +415,19 @@ def main(): hot_fl if os.path.basename(s).startswith("code_hot") else fl, shim_deps if os.path.dirname(s) == NX2_RT else deps) for s in srcs] # the runtime: ordinary C at -O2, with its own headers (any host/*.h change rebuilds all of it) - host_fl = target() + CPU + (["-I" + POSIX, "-D_GNU_SOURCE"] if LINUX else []) + \ + host_fl = target() + CPU + (["-I" + POSIX, "-D_GNU_SOURCE"] if POSIX_HOST else []) + \ ["-I" + HOST, "-I" + rt64, "-I" + gen, "-Wno-everything", "-O2", "-g0", "-mcx16", "-DA64_POLLING"] + WRITE_TRACK host_deps = deps + b"".join(open(os.path.join(HOST, f), "rb").read() for f in sorted(os.listdir(HOST)) if f.endswith(".h")) - if LINUX: + if POSIX_HOST: host_deps += b"".join(open(os.path.join(POSIX, f), "rb").read() for f in sorted(os.listdir(POSIX)) if f.endswith(".h")) + if LINUX: host_deps += open(os.path.join(GPU, "vk", "vk_api.h"), "rb").read() host_deps += ffmpeg_version_key() jobs += [(cc, s, os.path.join(obj_dir, "host_" + os.path.basename(s)[:-2] + ".o"), host_fl + ["-I" + os.path.join(OPUS, "include")] + (["-I" + os.path.join(FFMPEG, "include")] if FFMPEG else []) + - (FFMPEG_LX[0] if LINUX and FFMPEG_LX else []) + + (FFMPEG_LX[0] if POSIX_HOST and FFMPEG_LX else []) + ([stamp_def] if os.path.basename(s) == "hos_main.c" else []), host_deps) for s in host_srcs] # libopus 1.5.2 (third_party/opus, from TOTKPort): nn::codec's hardware Opus decoder (host/nn_codec.c) if host_srcs: @@ -433,13 +439,17 @@ def main(): # the renderer (C++20, NDEBUG: no asserts in it or suyu), when a wgpu-native release is there wgpu = a.standalone and not a.only and not LINUX and os.path.isfile(os.path.join(WGPU, "include", "webgpu", "webgpu.h")) if a.standalone and not a.only and not wgpu and not LINUX: + if MACOS: + raise SystemExit("macOS requires wgpu-native with Metal support; set WGPU_NATIVE to its package directory") print("no wgpu-native at %s (WGPU_NATIVE): building without the renderer" % WGPU) if wgpu: cxx = toolchain.need(toolchain.find("clang++")) gflags = target() + ["-std=c++20", "-O2", "-g0", "-DNDEBUG", "-Wno-everything", "-I" + os.path.join(GPU, "compat"), "-I" + os.path.join(GPU, "third_party", "suyu"), - "-I" + os.path.join(WGPU, "include"), "-I" + HOST, "-I" + rt64] + WRITE_TRACK + ["-include", os.path.join(GPU, "render", "rx_wgpu_names.h")] # (rx_* -> wgr_*: host/rx_backend.c) + "-I" + os.path.join(WGPU, "include"), "-I" + HOST, "-I" + rt64] + WRITE_TRACK + \ + (["-I" + POSIX] if MACOS else []) + \ + ["-include", os.path.join(GPU, "render", "rx_wgpu_names.h")] # (rx_* -> wgr_*: host/rx_backend.c) gdeps = tree_headers(GPU, HOST, skip=("vk",)) + open(os.path.join(WGPU, "include", "webgpu", "webgpu.h"), "rb").read() for src in gpu_sources(): name = os.path.relpath(src, GPU).replace(os.sep, "_")[:-4] @@ -450,7 +460,7 @@ def main(): vk_prebuilt = os.environ.get("HOOKSHOT_VK_PREBUILT", "") if a.standalone and not a.only and vk_prebuilt: print("the Vulkan back end: %s, as built" % vk_prebuilt) - elif a.standalone and not a.only and vk_headers and spirv_headers: + elif a.standalone and not a.only and not MACOS and vk_headers and spirv_headers: cxx = toolchain.need(toolchain.find("clang++")) vk = os.path.join(GPU, "vk") # (what the shader caches are good for: what translates shaders, not the whole DLL, so a build that changes something else @@ -534,25 +544,38 @@ def main(): lib = os.path.join(out, "hookshot_game.lib") subprocess.run([toolchain.lld_link(), "/lib", "/out:" + lib, "@" + rsp], check=True) print("wrote %s (%d objects, %d MB)" % (lib, len(objs), os.path.getsize(lib) >> 20)) - if a.standalone and not a.only and not vk_only and LINUX: - exe = os.path.join(out, EXE) # (+ hookshot.map: crash offsets -> f_) + if a.standalone and not a.only and not vk_only and POSIX_HOST: + exe = os.path.join(out, EXE) t = time.time() - # (--export-dynamic-symbol: the window's state, which the Vulkan back end's own Win32 calls read by name) - r = subprocess.run([cc] + target() + ["-fuse-ld=lld", "-o", exe, "-Wl,-Map=" + exe + ".map", "-Wl,--gc-sections", - "-Wl,--export-dynamic-symbol=hos_posix_*", "@" + rsp] + HOT_ORDER() + - (["-fprofile-instr-generate"] if PGO == "gen" else []) + - (FFMPEG_LX[1] if FFMPEG_LX else []) + HOST_LIBS, - capture_output=True, text=True) + if MACOS: + wgpu_lib = os.path.join(WGPU, "lib", "libwgpu_native.dylib") + if not os.path.isfile(wgpu_lib): + raise SystemExit("macOS requires %s" % wgpu_lib) + link = [cc] + target() + ["-o", exe, "-Wl,-map," + exe + ".map", "-Wl,-dead_strip", + "-Wl,-export_dynamic", + "-Wl,-rpath,@executable_path", "@" + rsp] + HOT_ORDER() + \ + (["-fprofile-instr-generate"] if PGO == "gen" else []) + \ + (FFMPEG_LX[1] if FFMPEG_LX else []) + HOST_LIBS + [wgpu_lib, + "-framework", "Metal", "-framework", "Foundation", "-framework", "QuartzCore", "-framework", "Cocoa"] + r = subprocess.run(link, capture_output=True, text=True) + else: + # (--export-dynamic-symbol: Vulkan backend's window state on Linux) + r = subprocess.run([cc] + target() + ["-fuse-ld=lld", "-o", exe, "-Wl,-Map=" + exe + ".map", "-Wl,--gc-sections", + "-Wl,--export-dynamic-symbol=hos_posix_*", "@" + rsp] + HOT_ORDER() + + (["-fprofile-instr-generate"] if PGO == "gen" else []) + + (FFMPEG_LX[1] if FFMPEG_LX else []) + HOST_LIBS, + capture_output=True, text=True) undefined = sorted({l.strip() for l in (r.stdout + r.stderr).splitlines() if "undefined symbol" in l}) if r.returncode: print("link FAILED (%.0fs), %d undefined symbols" % (time.time() - t, len(undefined))) print("\n".join(undefined[:60]) if undefined else (r.stdout + r.stderr)[-3000:]) return 1 print("linked %s: %d MB (%.0fs)" % (exe, os.path.getsize(exe) >> 20, time.time() - t)) - sdl = os.environ.get("HOOKSHOT_SDL2", "") # (libSDL2-2.0.so.0 beside it, when the AppImage brings one) + sdl = os.environ.get("HOOKSHOT_SDL2", "") if sdl and os.path.exists(sdl): import shutil - shutil.copyfile(sdl, os.path.join(out, "libSDL2-2.0.so.0")) + name = "libSDL2-2.0.0.dylib" if MACOS else "libSDL2-2.0.so.0" + shutil.copyfile(sdl, os.path.join(out, name)) elif a.standalone and not a.only and not vk_only: exe = os.path.join(out, "hookshot.exe") # (+ hookshot.map: crash offsets -> f_) t = time.time() @@ -572,11 +595,15 @@ def main(): print("\n".join(undefined[:60]) if undefined else (r.stdout + r.stderr)[-3000:]) return 1 print("linked %s: %d MB (%.0fs)" % (exe, os.path.getsize(exe) >> 20, time.time() - t)) - if wgpu: # (wgpu_native.dll beside it; in use while the game runs) + if wgpu: import shutil - dll, dst = os.path.join(WGPU, "lib", "wgpu_native.dll"), os.path.join(out, "wgpu_native.dll") - if not os.path.exists(dst) or os.path.getsize(dst) != os.path.getsize(dll): - shutil.copyfile(dll, dst) + if MACOS: + shutil.copyfile(os.path.join(WGPU, "lib", "libwgpu_native.dylib"), + os.path.join(out, "libwgpu_native.dylib")) + else: + dll, dst = os.path.join(WGPU, "lib", "wgpu_native.dll"), os.path.join(out, "wgpu_native.dll") + if not os.path.exists(dst) or os.path.getsize(dst) != os.path.getsize(dll): + shutil.copyfile(dll, dst) # (hookshot) SDL2.dll beside it: host/pad_sdl.c's controllers (DualSense, DualShock 4, Switch Pro ...) sdl = os.path.join(kit.HERE, "..", "third_party", "sdl2", "SDL2.dll") sdl_dst = os.path.join(out, "SDL2.dll") @@ -590,7 +617,7 @@ def main(): if os.path.exists(sdl3) and (not os.path.exists(sdl3_dst) or os.path.getsize(sdl3_dst) != os.path.getsize(sdl3)): import shutil shutil.copyfile(sdl3, sdl3_dst) - if vk_prebuilt and a.standalone and not a.only: + if vk_prebuilt and a.standalone and not a.only and not MACOS: import shutil shutil.copyfile(vk_prebuilt, os.path.join(out, VK_LIB)) if vk_jobs: # (hookshot_vk.dll beside the exe: loaded by host/rx_backend.c with RX_BACKEND=vulkan) diff --git a/nx2/make_game.py b/nx2/make_game.py index 4fb2ab6f..86b2fb0e 100644 --- a/nx2/make_game.py +++ b/nx2/make_game.py @@ -29,8 +29,11 @@ ROOT = os.path.dirname(HERE) PY = [sys.executable, "-I", "-u", "-X", "utf8"] # (utf8: every step's output in UTF-8 whatever the console's code page: lift.py crashed on a letter, #180) # (-u: the steps' output as it comes, not in 8 KB blocks minutes apart) BUILD_ID = "e0f3ccfa" # (the version the patches, the hot list and the profile were made from) -LINUX = os.name != "nt" -BESIDE = ["hookshot", "hookshot_vk.so", "libSDL2-2.0.so.0"] if LINUX else ["hookshot.exe", "hookshot_vk.dll", "SDL2.dll", "SDL3.dll", "wgpu_native.dll"] +LINUX = sys.platform.startswith("linux") +MACOS = sys.platform == "darwin" +BESIDE = (["hookshot", "hookshot_vk.so", "libSDL2-2.0.so.0"] if LINUX else + ["hookshot", "libSDL2-2.0.0.dylib", "libwgpu_native.dylib"] if MACOS else + ["hookshot.exe", "hookshot_vk.dll", "SDL2.dll", "SDL3.dll", "wgpu_native.dll"]) LANGUAGES = [("en-US", "English (US)"), ("en-GB", "English (UK)"), ("fr", "French (France)"), ("fr-CA", "French (Canada)"), ("de", "German"), ("it", "Italian"), ("es", "Spanish (Spain)"), ("es-419", "Spanish (Latin America)"), ("pt-BR", "Portuguese (Brazil)"), ("nl", "Dutch"), ("pl", "Polish"), ("ru", "Russian"), ("ja", "Japanese"), ("ko", "Korean"), ("zh-Hans", "Chinese (Simplified)"), @@ -282,6 +285,45 @@ def check_linux_compiler(): "libc6-dev on Debian and Ubuntu, glibc-devel on Fedora)" % (errors[0] if errors else "no message")) +def check_macos_toolchain(): + """Validate the x86-64 Rosetta compiler and graphics/audio libraries before the long lift.""" + wgpu = os.environ.get("WGPU_NATIVE", os.path.join(os.path.expanduser("~"), "wgpu-native")) + sdl = os.environ.get("HOOKSHOT_SDL2", "") + if not os.path.isfile(os.path.join(wgpu, "include", "webgpu", "webgpu.h")): + fail("macOS needs x86-64 wgpu-native with Metal support: set WGPU_NATIVE to its package directory.") + if not os.path.isfile(os.path.join(wgpu, "lib", "libwgpu_native.dylib")): + fail("macOS needs libwgpu_native.dylib in WGPU_NATIVE/lib.") + if not sdl or not os.path.isfile(sdl): + fail("macOS needs an x86-64 SDL2 dylib: set HOOKSHOT_SDL2 to libSDL2-2.0.0.dylib.") + if not shutil.which("lipo"): + fail("Install Xcode Command Line Tools to verify macOS dependency architectures.") + for library in (sdl, os.path.join(wgpu, "lib", "libwgpu_native.dylib")): + r = subprocess.run(["lipo", "-verify_arch", "x86_64", library], + capture_output=True, text=True, encoding="utf-8", errors="replace") + if r.returncode: + fail("macOS dependency must contain x86_64 code for Rosetta: %s" % library) + sys.path.insert(0, os.path.join(ROOT, "third_party", "a64-kit", "tools")) + import toolchain + cc = toolchain.find("clang") + if not os.path.isfile(cc): + fail("Install Xcode Command Line Tools to build Hookshot for macOS.") + with tempfile.TemporaryDirectory() as d: + src, exe = os.path.join(d, "probe.c"), os.path.join(d, "probe") + with open(src, "w", encoding="utf-8") as f: + f.write("int main(void) { return 0; }\n") + r = subprocess.run([cc, "--target=x86_64-apple-macos10.15", src, "-o", exe], + capture_output=True, text=True, encoding="utf-8", errors="replace") + if r.returncode: + fail("Xcode Command Line Tools could not link an x86-64 macOS test program: %s" + % failure_reason((r.stdout + r.stderr).splitlines())) + if not shutil.which("pkg-config"): + fail("Install pkg-config and x86-64 FFmpeg development libraries for the macOS build.") + r = subprocess.run(["pkg-config", "--exists", "libavcodec", "libswscale", "libswresample", "libavutil"], + capture_output=True, text=True, encoding="utf-8", errors="replace") + if r.returncode: + fail("macOS needs x86-64 FFmpeg development libraries visible through pkg-config (set PKG_CONFIG_PATH).") + + def free_memory_gb(): """The memory free for the compilers now, in GB (None: not known)""" try: @@ -300,6 +342,17 @@ class MS(ctypes.Structure): for line in open("/proc/meminfo"): if line.startswith("MemAvailable:"): return int(line.split()[1]) / (1 << 20) + except FileNotFoundError: + if sys.platform == "darwin": + try: + r = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5, check=True) + page_size = re.search(r"page size of (\d+) bytes", r.stdout) + pages = sum(int(value.replace(".", "")) for name, value in + re.findall(r"^Pages (free|inactive|speculative|purgeable):\s+([\d.]+)", r.stdout, re.M)) + if page_size and pages: + return pages * int(page_size.group(1)) / (1 << 30) + except (OSError, subprocess.SubprocessError, ValueError): + return None except Exception: return None return None @@ -374,6 +427,8 @@ def check_mingw(): def check_windows_sdk(): """The compiler is bundled; Microsoft's C library and Windows SDK may not be, by their licences. Found now, not 40 minutes in.""" + if MACOS: + return check_macos_toolchain() if LINUX: return check_linux_compiler() if bundled_mingw(): @@ -434,7 +489,7 @@ def stop_stray_compilers(work, default_work): update mid-build) keeps its files open: the next build stopped at once with 'OSError: [Errno 22]' writing sa/gen/a64_fpx.h, or WinError 1224 on the play profile. Only that build goes: an earlier make_game on this work folder, the build and lift steps whose make_game is gone, and compilers whose command line names this work folder. Never this build or what started it.""" - if LINUX: + if os.name != "nt": return ps = ("Get-CimInstance Win32_Process | Where-Object { $_.Name -like 'clang*' -or $_.Name -like 'ld.lld*' -or $_.Name -like 'python*' } " "| Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress") @@ -477,7 +532,7 @@ def drop_stale_objects(work): """(issue 231) Objects compiled against other system headers are not reused. A first build that compiled with an old Visual Studio's stdio.h (before 2015) left its objects in the cache; once the PC was fixed every later build reused them and the link failed on __iob_func. The headers the compiler finds are noted, and when they change the compiled objects go""" - if LINUX: + if os.name != "nt": return sys.path.insert(0, os.path.join(ROOT, "third_party", "a64-kit", "tools")) import hashlib @@ -588,7 +643,8 @@ def main(): p.add_argument("--romfs") p.add_argument("--exefs") p.add_argument("--out") - p.add_argument("--work", default=os.path.join(os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"), "hookshot-builder") + p.add_argument("--work", default=os.path.join(os.path.expanduser("~/Library/Caches"), "HookshotBuilder") + if MACOS else os.path.join(os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"), "hookshot-builder") if LINUX else os.path.join(os.environ.get("LOCALAPPDATA", ROOT), "HookshotBuilder")) # (HOOKSHOT_JOBS: fewer compilers on a PC short of memory; the AppImage's builds pass no --jobs) p.add_argument("--jobs", type=int, default=int(os.environ.get("HOOKSHOT_JOBS") or 0) or default_jobs()) diff --git a/tests/test_macos_build_path.py b/tests/test_macos_build_path.py new file mode 100644 index 00000000..80a6a690 --- /dev/null +++ b/tests/test_macos_build_path.py @@ -0,0 +1,57 @@ +"""Verify the macOS branch of the production build configuration without a game dump.""" +import json +import subprocess +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class MacOSBuildPathTests(unittest.TestCase): + def test_selects_x86_64_posix_and_metal_build_target(self): + source = ROOT / "nx2" / "build.py" + script = ( + "import json, runpy, sys; " + "sys.platform = 'darwin'; " + "b = runpy.run_path(sys.argv[1]); " + "print(json.dumps({k: b[k] for k in ('WINDOWS', 'LINUX', 'MACOS', 'POSIX_HOST', 'EXE', 'MARCH')} " + "| {'target': b['target']()}))" + ) + result = subprocess.run([sys.executable, "-I", "-c", script, str(source)], + check=True, capture_output=True, text=True) + config = json.loads(result.stdout.splitlines()[-1]) + self.assertEqual(config, { + "WINDOWS": False, + "LINUX": False, + "MACOS": True, + "POSIX_HOST": True, + "EXE": "hookshot", + "MARCH": "x86-64", + "target": ["--target=x86_64-apple-macos10.15"], + }) + + def test_game_builder_selects_macos_bundle_files(self): + source = ROOT / "nx2" / "make_game.py" + script = ( + "import json, runpy, sys; " + "sys.platform = 'darwin'; " + "b = runpy.run_path(sys.argv[1]); " + "print(json.dumps({k: b[k] for k in ('LINUX', 'MACOS', 'BESIDE')}))" + ) + result = subprocess.run([sys.executable, "-I", "-c", script, str(source)], + check=True, capture_output=True, text=True) + config = json.loads(result.stdout.splitlines()[-1]) + self.assertEqual(config, { + "LINUX": False, + "MACOS": True, + "BESIDE": ["hookshot", "libSDL2-2.0.0.dylib", "libwgpu_native.dylib"], + }) + + def test_macos_runtime_supplies_non_linux_profiler_entry_point(self): + source = (ROOT / "host" / "posix" / "macos_stubs.c").read_text(encoding="utf-8") + self.assertIn("void hos_prof_start(void)", source) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/third_party/a64-kit/tools/toolchain.py b/third_party/a64-kit/tools/toolchain.py index b282c57c..0144c342 100644 --- a/third_party/a64-kit/tools/toolchain.py +++ b/third_party/a64-kit/tools/toolchain.py @@ -1,4 +1,4 @@ -import os, shutil +import os, shutil, subprocess, sys HERE = os.path.dirname(os.path.abspath(__file__)) @@ -14,6 +14,14 @@ def mingw(): def find(tool): + if sys.platform == "darwin" and tool in ("clang", "clang++"): + xcrun = shutil.which("xcrun") + if xcrun: + result = subprocess.run([xcrun, "--find", tool], capture_output=True, text=True) + if result.returncode == 0: + path = result.stdout.strip() + if path: + return path if mingw() and (tool in ("clang", "clang++", "lld") or tool.startswith("llvm-")): path = os.path.join(mingw(), "bin", ("ld.lld" if tool == "lld" else tool) + ".exe") if os.path.isfile(path): From ec1ee06291391991b3b294060ba59220728edce9 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:32:37 +0100 Subject: [PATCH 02/29] Update macOS runner version in workflow --- .github/workflows/macos-platform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index 81c1e6eb..d249b75d 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -10,7 +10,7 @@ permissions: jobs: macos-posix: # Apple Silicon runner; Hookshot's current native context-switching assembly requires x86-64. - runs-on: macos-14-xlarge + runs-on: macos‑26‑xlarge steps: - uses: actions/checkout@v4 From 8322747989841b0bf926eb2ccfe9602f626ef7ac Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:38:57 +0100 Subject: [PATCH 03/29] Update macos-platform.yml --- .github/workflows/macos-platform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index d249b75d..22dc63c1 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -10,7 +10,7 @@ permissions: jobs: macos-posix: # Apple Silicon runner; Hookshot's current native context-switching assembly requires x86-64. - runs-on: macos‑26‑xlarge + runs-on: macos-latest-xlarge steps: - uses: actions/checkout@v4 From 3f949d8c877cc23acfbcae33fb14063666e7e90f Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:39:23 +0100 Subject: [PATCH 04/29] Change macOS runner to macos-latest --- .github/workflows/macos-platform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index 22dc63c1..e86a992b 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -10,7 +10,7 @@ permissions: jobs: macos-posix: # Apple Silicon runner; Hookshot's current native context-switching assembly requires x86-64. - runs-on: macos-latest-xlarge + runs-on: macos-latest steps: - uses: actions/checkout@v4 From 3833f36b1ecd58a372f6df27abc944c2157db5a6 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:40:36 +0100 Subject: [PATCH 05/29] Change macOS runner to macos-latest-intel --- .github/workflows/macos-platform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index e86a992b..100d1ced 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -10,7 +10,7 @@ permissions: jobs: macos-posix: # Apple Silicon runner; Hookshot's current native context-switching assembly requires x86-64. - runs-on: macos-latest + runs-on: macos-latest-intel steps: - uses: actions/checkout@v4 From 84f16dd0aad25d614edf17424986e743d3c963c8 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:41:27 +0100 Subject: [PATCH 06/29] Update macOS runner to use latest version --- .github/workflows/macos-platform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index 100d1ced..e86a992b 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -10,7 +10,7 @@ permissions: jobs: macos-posix: # Apple Silicon runner; Hookshot's current native context-switching assembly requires x86-64. - runs-on: macos-latest-intel + runs-on: macos-latest steps: - uses: actions/checkout@v4 From 40213a0bb2aea6435258f81a820a3b22717602ee Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:44:04 +0100 Subject: [PATCH 07/29] Fix macOS runtime compilation Enable Darwin ucontext declarations and make the relative sleep helper available on all POSIX targets. Generate the a64 runtime header for the macOS smoke compile and verify the runner can link an x86-64 executable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 10 +++++++++- host/posix/win32_posix.c | 12 ++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index e86a992b..94f6a416 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -21,9 +21,17 @@ jobs: - name: Build configuration test run: python -I -B tests/test_macos_build_path.py + - name: Link x86-64 macOS executable + run: | + printf 'int main(void) { return 0; }\n' > "$RUNNER_TEMP/macos-probe.c" + clang --target=x86_64-apple-macos10.15 "$RUNNER_TEMP/macos-probe.c" -o "$RUNNER_TEMP/macos-probe" + lipo -verify_arch x86_64 "$RUNNER_TEMP/macos-probe" + - name: Compile macOS POSIX host shims run: | + python third_party/a64-kit/lift/a64_rtgen.py "$RUNNER_TEMP/a64-generated" clang -target x86_64-apple-macos10.15 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ - -Ihost/posix -Ihost -Ithird_party/a64-kit/rt host/posix/win32_posix.c host/hos_main.c host/rx_backend.c + -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ + host/posix/win32_posix.c host/hos_main.c host/rx_backend.c clang -target x86_64-apple-macos10.15 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ -Ihost/posix -Ihost -Ithird_party/a64-kit/rt host/posix/display_sdl.c host/posix/macos_stubs.c diff --git a/host/posix/win32_posix.c b/host/posix/win32_posix.c index 8b79ec72..aac158b3 100644 --- a/host/posix/win32_posix.c +++ b/host/posix/win32_posix.c @@ -1,6 +1,9 @@ /* The Win32 calls of host/posix/windows.h, on POSIX. Built into the executable and, on its own, into hookshot_vk.so (each its own copy, -Bsymbolic: the executable exports nothing of it). */ #define _GNU_SOURCE +#ifdef __APPLE__ +#define _XOPEN_SOURCE 700 +#endif #include #include @@ -99,6 +102,11 @@ static void monitor_init(pthread_mutex_t *mu, pthread_cond_t *cv) { } \ } while (0) +static struct timespec rel_ms(DWORD ms) { + struct timespec ts = {(time_t)(ms / 1000), (long)(ms % 1000) * 1000000L}; + return ts; +} + /* ---- address waits: futexes on Linux, condition buckets on macOS ---- */ #ifdef __APPLE__ typedef struct { pthread_mutex_t mu; pthread_cond_t cv; } WaitBucket; @@ -175,10 +183,6 @@ void WakeByAddressAll(void *addr) { wake_address(addr); } static long futex(volatile uint32_t *a, int op, uint32_t v, const struct timespec *ts) { return syscall(SYS_futex, a, op, v, ts, NULL, 0); } -static struct timespec rel_ms(DWORD ms) { - struct timespec ts = {(time_t)(ms / 1000), (long)(ms % 1000) * 1000000L}; - return ts; -} void WakeConditionVariable(CONDITION_VARIABLE *cv) { __atomic_add_fetch(&cv->seq, 1, __ATOMIC_SEQ_CST); futex(&cv->seq, FUTEX_WAKE_PRIVATE, 1, NULL); From 2a8f7f3c9d955de7c0adaf0122084e64d7697c01 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:44:59 +0100 Subject: [PATCH 08/29] Fix macOS architecture verification Pass the binary before the architecture flag to lipo. Use the same valid argument order in the macOS builder preflight and CI executable check. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 2 +- nx2/make_game.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index 94f6a416..1f18c01b 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -25,7 +25,7 @@ jobs: run: | printf 'int main(void) { return 0; }\n' > "$RUNNER_TEMP/macos-probe.c" clang --target=x86_64-apple-macos10.15 "$RUNNER_TEMP/macos-probe.c" -o "$RUNNER_TEMP/macos-probe" - lipo -verify_arch x86_64 "$RUNNER_TEMP/macos-probe" + lipo "$RUNNER_TEMP/macos-probe" -verify_arch x86_64 - name: Compile macOS POSIX host shims run: | diff --git a/nx2/make_game.py b/nx2/make_game.py index 86b2fb0e..47fc9a43 100644 --- a/nx2/make_game.py +++ b/nx2/make_game.py @@ -298,7 +298,7 @@ def check_macos_toolchain(): if not shutil.which("lipo"): fail("Install Xcode Command Line Tools to verify macOS dependency architectures.") for library in (sdl, os.path.join(wgpu, "lib", "libwgpu_native.dylib")): - r = subprocess.run(["lipo", "-verify_arch", "x86_64", library], + r = subprocess.run(["lipo", library, "-verify_arch", "x86_64"], capture_output=True, text=True, encoding="utf-8", errors="replace") if r.returncode: fail("macOS dependency must contain x86_64 code for Rosetta: %s" % library) From cbf556218e3897852dea4f42ec6c0a1218d3e266 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:46:13 +0100 Subject: [PATCH 09/29] Decouple macOS host smoke compile from lift generator The host translation units do not require generated suyu helpers. Use a minimal generated-header placeholder so the smoke check does not need to build the C++ translator or require game data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index 1f18c01b..4e0c3a89 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -29,7 +29,9 @@ jobs: - name: Compile macOS POSIX host shims run: | - python third_party/a64-kit/lift/a64_rtgen.py "$RUNNER_TEMP/a64-generated" + mkdir -p "$RUNNER_TEMP/a64-generated" + printf '/* Generated lift helpers are unused by these host translation units. */\n' \ + > "$RUNNER_TEMP/a64-generated/a64_suyu.h" clang -target x86_64-apple-macos10.15 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ host/posix/win32_posix.c host/hos_main.c host/rx_backend.c From c820d1a6b7e945b21a6a8691c38fa646362cb2e6 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:46:58 +0100 Subject: [PATCH 10/29] Expose Darwin extensions in POSIX runtime Define Darwin's feature-test macro alongside X/Open for ucontext and the BSD/POSIX declarations used by the macOS host. Complete the generated-header placeholders in the host compile check. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 2 ++ host/posix/win32_posix.c | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index 4e0c3a89..c44947a9 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -32,6 +32,8 @@ jobs: mkdir -p "$RUNNER_TEMP/a64-generated" printf '/* Generated lift helpers are unused by these host translation units. */\n' \ > "$RUNNER_TEMP/a64-generated/a64_suyu.h" + printf '/* Generated per-game floating-point helpers are unused by these host translation units. */\n' \ + > "$RUNNER_TEMP/a64-generated/a64_fpx.h" clang -target x86_64-apple-macos10.15 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ host/posix/win32_posix.c host/hos_main.c host/rx_backend.c diff --git a/host/posix/win32_posix.c b/host/posix/win32_posix.c index aac158b3..f357dc9c 100644 --- a/host/posix/win32_posix.c +++ b/host/posix/win32_posix.c @@ -3,6 +3,7 @@ #define _GNU_SOURCE #ifdef __APPLE__ #define _XOPEN_SOURCE 700 +#define _DARWIN_C_SOURCE #endif #include #include From 5237d348d8a6adbd2edf2e39d2a714ec0e5b9ddf Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:47:42 +0100 Subject: [PATCH 11/29] Use generated runtime stubs for SDL compile check Include the temporary generated-header directory when syntax-checking the SDL display shim, which also includes the runtime header. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index c44947a9..9d851e64 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -38,4 +38,5 @@ jobs: -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ host/posix/win32_posix.c host/hos_main.c host/rx_backend.c clang -target x86_64-apple-macos10.15 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ - -Ihost/posix -Ihost -Ithird_party/a64-kit/rt host/posix/display_sdl.c host/posix/macos_stubs.c + -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ + host/posix/display_sdl.c host/posix/macos_stubs.c From a9b08da608623c7866bfcfe17d4460f06983e5c6 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:48:26 +0100 Subject: [PATCH 12/29] Guard Vulkan window setup on macOS The Metal display path does not define the Vulkan window descriptor; keep its initialization Linux-only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- host/posix/display_sdl.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/host/posix/display_sdl.c b/host/posix/display_sdl.c index ad96ab59..61f655f4 100644 --- a/host/posix/display_sdl.c +++ b/host/posix/display_sdl.c @@ -263,7 +263,9 @@ void hos_display_loop(void) { SetEvent(renderer_up); for (;;) Sleep(1000); } +#ifndef __APPLE__ vk_window.window = window; +#endif hos_posix_window[0] = 1; if (getenv("HOS_FULLSCREEN") && atoi(getenv("HOS_FULLSCREEN"))) set_fullscreen(1); CloseHandle(CreateThread(NULL, 0, renderer_main, NULL, 0, NULL)); From 3be1d9dccb7c40df4a92d67c196022d9c82005f1 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:49:17 +0100 Subject: [PATCH 13/29] Test macOS dependency architecture preflight Cover the production lipo invocation order so dependency architecture checks remain valid on macOS. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_macos_build_path.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_macos_build_path.py b/tests/test_macos_build_path.py index 80a6a690..03a406b2 100644 --- a/tests/test_macos_build_path.py +++ b/tests/test_macos_build_path.py @@ -48,6 +48,41 @@ def test_game_builder_selects_macos_bundle_files(self): "BESIDE": ["hookshot", "libSDL2-2.0.0.dylib", "libwgpu_native.dylib"], }) + def test_macos_preflight_verifies_dependency_architectures(self): + source = ROOT / "nx2" / "make_game.py" + script = r""" +import os, runpy, sys, tempfile, types +from pathlib import Path +from unittest.mock import patch +sys.platform = "darwin" +builder = runpy.run_path(sys.argv[1]) +with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + wgpu = root / "wgpu" + header = wgpu / "include" / "webgpu" / "webgpu.h" + library = wgpu / "lib" / "libwgpu_native.dylib" + sdl = root / "libSDL2-2.0.0.dylib" + for path in (header, library, sdl): + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + commands = [] + def run(command, **kwargs): + commands.append(command) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + toolchain = types.SimpleNamespace(find=lambda name: sys.executable) + with patch.dict(os.environ, {"WGPU_NATIVE": str(wgpu), "HOOKSHOT_SDL2": str(sdl)}), \ + patch("shutil.which", return_value="tool"), \ + patch("subprocess.run", side_effect=run), \ + patch.dict(sys.modules, {"toolchain": toolchain}): + builder["check_macos_toolchain"]() + assert [c for c in commands if c[0] == "lipo"] == [ + ["lipo", str(sdl), "-verify_arch", "x86_64"], + ["lipo", str(library), "-verify_arch", "x86_64"], + ] +""" + subprocess.run([sys.executable, "-I", "-c", script, str(source)], check=True, + capture_output=True, text=True) + def test_macos_runtime_supplies_non_linux_profiler_entry_point(self): source = (ROOT / "host" / "posix" / "macos_stubs.c").read_text(encoding="utf-8") self.assertIn("void hos_prof_start(void)", source) From 938b6df277122570dd5a88a89e214187d797e9e6 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:59:37 +0100 Subject: [PATCH 14/29] Package macOS CLI builder and dependencies Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 44 ++++++++++ docs/development.md | 28 ++++-- nx2/make_game.py | 27 ++++-- scripts/macos/README.txt | 17 ++++ scripts/macos/check-renderer.py | 41 +++++++++ scripts/macos/deps.sh | 125 +++++++++++++++++++++++++++ scripts/macos/package-builder.sh | 39 +++++++++ tests/test_macos_build_path.py | 41 +++++++++ 8 files changed, 346 insertions(+), 16 deletions(-) create mode 100644 scripts/macos/README.txt create mode 100644 scripts/macos/check-renderer.py create mode 100644 scripts/macos/deps.sh create mode 100644 scripts/macos/package-builder.sh diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index 9d851e64..96d20899 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -40,3 +40,47 @@ jobs: clang -target x86_64-apple-macos10.15 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ host/posix/display_sdl.c host/posix/macos_stubs.c + + macos-builder: + # Builds a downloadable CLI and its x86-64 dependencies; it never receives or builds game data. + runs-on: macos-latest + timeout-minutes: 150 + steps: + - uses: actions/checkout@v4 + + - name: Install dependency build tools + run: brew install cmake meson ninja nasm pkgconf + + - name: Install Rosetta + run: | + if ! arch -x86_64 /usr/bin/true; then + softwareupdate --install-rosetta --agree-to-license + fi + + - name: Build builder dependencies + run: arch -x86_64 /bin/bash scripts/macos/deps.sh + + - name: Validate bundled builder toolchain + env: + WGPU_NATIVE: ${{ github.workspace }}/build-macos/deps/wgpu-native + HOOKSHOT_SDL2: ${{ github.workspace }}/build-macos/deps/sdl2/lib/libSDL2-2.0.0.dylib + HOOKSHOT_FFMPEG: ${{ github.workspace }}/build-macos/deps/ffmpeg + run: | + arch -x86_64 build-macos/deps/python/bin/python3 -I -B tests/test_macos_build_path.py + arch -x86_64 build-macos/deps/python/bin/python3 -I -c \ + "import runpy; runpy.run_path('nx2/make_game.py')['check_macos_toolchain']()" + arch -x86_64 build-macos/deps/python/bin/python3 -I scripts/macos/check-renderer.py + + - name: Package CLI builder + run: bash scripts/macos/package-builder.sh + + - name: Verify packaged builder starts + run: | + cd build-macos/package/HookshotBuilder + ./HookshotBuilder.command --help + + - uses: actions/upload-artifact@v4 + with: + name: HookshotBuilder-macos-x86_64 + path: build-macos/HookshotBuilder-macos-x86_64.zip + if-no-files-found: error diff --git a/docs/development.md b/docs/development.md index 4033e726..1b4b2669 100644 --- a/docs/development.md +++ b/docs/development.md @@ -52,26 +52,36 @@ target is an x86-64 executable run under Rosetta 2, not a native arm64 executabl input, and the existing WebGPU renderer through `wgpu-native`'s Metal backend; the Vulkan backend is not built on macOS. This provides a real Apple Silicon/Metal path while leaving native arm64 as follow-up work. -Install Xcode Command Line Tools, Rosetta 2, and x86-64 builds of Python 3 (with the Python modules listed above), -SDL2 2.26 or newer, FFmpeg and `wgpu-native`. On Apple Silicon, the SDL2, FFmpeg, and `wgpu-native` binaries must all be x86-64; -Homebrew packages from the native arm64 prefix cannot be linked into this build. `wgpu-native` must include its -matching `include/webgpu/webgpu.h` and `lib/libwgpu_native.dylib`, built with Metal support. Then build from an -x86-64 shell, for example: +The `macOS platform` workflow uploads a `HookshotBuilder-macos-x86_64` artifact containing a command-line builder, +the Hookshot source, an x86-64 Python runtime and modules, SDL2, static FFmpeg/dav1d, and Metal-enabled `wgpu-native`. +It does not contain game data or a built game. Download and unpack it on the target Mac; Xcode Command Line Tools +and Rosetta 2 must be installed there. From Terminal, provide your own `.nup` dump: + +```sh +./HookshotBuilder/HookshotBuilder.command --nup /path/to/game.nup --out /path/to/game-folder --jobs 4 +``` + +The package is x86-64 under Rosetta on Apple Silicon, not native arm64. For development outside the artifact, use +x86-64 builds of SDL2 2.26 or newer and `wgpu-native`; Homebrew packages from the native arm64 prefix cannot be linked +into this build. `wgpu-native` must include its matching `include/webgpu/webgpu.h` and `lib/libwgpu_native.dylib`, +built with Metal support. Set `HOOKSHOT_FFMPEG` to the static FFmpeg/dav1d prefix (or provide FFmpeg through +`pkg-config`) and build from an x86-64 shell, for example: ```sh export WGPU_NATIVE=/path/to/x86_64-wgpu-native export HOOKSHOT_SDL2=/path/to/x86_64/libSDL2-2.0.0.dylib -export PKG_CONFIG_PATH=/path/to/x86_64/ffmpeg/lib/pkgconfig +export HOOKSHOT_FFMPEG=/path/to/x86_64/ffmpeg arch -x86_64 python3 -I nx2/make_game.py --nup /path/to/game.nup --out /path/to/game-folder --jobs 4 ``` The normal builder command validates the x86-64 compiler, SDL2, FFmpeg and `wgpu-native` before lifting the game, -then places `hookshot`, `libSDL2-2.0.0.dylib` and `libwgpu_native.dylib` beside `image.bin` in the output folder. +then places `hookshot`, `libSDL2-2.0.0.dylib` and `libwgpu_native.dylib` beside `image.bin` in the output folder; +FFmpeg is statically linked when `HOOKSHOT_FFMPEG` points to the packaged static libraries. For lower-level development, `nx2/lift.py --standalone --write` followed by `nx2/build.py --standalone --jobs 4` writes the executable and libraries under `work/sa/build/`. Set `RX_BACKEND=metal` to make the renderer choice explicit (Metal is the macOS default). As on other platforms, the local game dump is required to generate and build -the game. The `macOS platform` workflow checks the x86-64 POSIX shims and build selection on an Apple Silicon runner; -it is a compile smoke test, not a game build or a downloadable/notarized app release. +the game. The workflow packages and validates builder dependencies without running a game build; it is not a +notarized app release. ## Layout diff --git a/nx2/make_game.py b/nx2/make_game.py index 47fc9a43..cf786bc8 100644 --- a/nx2/make_game.py +++ b/nx2/make_game.py @@ -289,6 +289,7 @@ def check_macos_toolchain(): """Validate the x86-64 Rosetta compiler and graphics/audio libraries before the long lift.""" wgpu = os.environ.get("WGPU_NATIVE", os.path.join(os.path.expanduser("~"), "wgpu-native")) sdl = os.environ.get("HOOKSHOT_SDL2", "") + ffmpeg = os.environ.get("HOOKSHOT_FFMPEG", "") if not os.path.isfile(os.path.join(wgpu, "include", "webgpu", "webgpu.h")): fail("macOS needs x86-64 wgpu-native with Metal support: set WGPU_NATIVE to its package directory.") if not os.path.isfile(os.path.join(wgpu, "lib", "libwgpu_native.dylib")): @@ -297,7 +298,18 @@ def check_macos_toolchain(): fail("macOS needs an x86-64 SDL2 dylib: set HOOKSHOT_SDL2 to libSDL2-2.0.0.dylib.") if not shutil.which("lipo"): fail("Install Xcode Command Line Tools to verify macOS dependency architectures.") - for library in (sdl, os.path.join(wgpu, "lib", "libwgpu_native.dylib")): + libraries = [sdl, os.path.join(wgpu, "lib", "libwgpu_native.dylib")] + if ffmpeg: + headers = ("libavcodec/avcodec.h", "libswscale/swscale.h", "libswresample/swresample.h", + "libavutil/avutil.h", "dav1d/dav1d.h") + missing_headers = [h for h in headers if not os.path.isfile(os.path.join(ffmpeg, "include", h))] + static_libs = ("libavcodec.a", "libswscale.a", "libswresample.a", "libavutil.a", "libdav1d.a") + missing_libs = [l for l in static_libs if not os.path.isfile(os.path.join(ffmpeg, "lib", l))] + if missing_headers or missing_libs: + missing = missing_headers + missing_libs + fail("macOS needs the Hookshot static FFmpeg package; missing: %s" % ", ".join(missing)) + libraries += [os.path.join(ffmpeg, "lib", l) for l in static_libs] + for library in libraries: r = subprocess.run(["lipo", library, "-verify_arch", "x86_64"], capture_output=True, text=True, encoding="utf-8", errors="replace") if r.returncode: @@ -316,12 +328,13 @@ def check_macos_toolchain(): if r.returncode: fail("Xcode Command Line Tools could not link an x86-64 macOS test program: %s" % failure_reason((r.stdout + r.stderr).splitlines())) - if not shutil.which("pkg-config"): - fail("Install pkg-config and x86-64 FFmpeg development libraries for the macOS build.") - r = subprocess.run(["pkg-config", "--exists", "libavcodec", "libswscale", "libswresample", "libavutil"], - capture_output=True, text=True, encoding="utf-8", errors="replace") - if r.returncode: - fail("macOS needs x86-64 FFmpeg development libraries visible through pkg-config (set PKG_CONFIG_PATH).") + if not ffmpeg: + if not shutil.which("pkg-config"): + fail("Install pkg-config and x86-64 FFmpeg development libraries for the macOS build.") + r = subprocess.run(["pkg-config", "--exists", "libavcodec", "libswscale", "libswresample", "libavutil"], + capture_output=True, text=True, encoding="utf-8", errors="replace") + if r.returncode: + fail("macOS needs x86-64 FFmpeg development libraries visible through pkg-config (set PKG_CONFIG_PATH).") def free_memory_gb(): diff --git a/scripts/macos/README.txt b/scripts/macos/README.txt new file mode 100644 index 00000000..9992f166 --- /dev/null +++ b/scripts/macos/README.txt @@ -0,0 +1,17 @@ +Hookshot Builder for macOS (Apple Silicon) + +This package is an x86-64 command-line builder that runs under Rosetta 2 and +uses WebGPU's Metal backend. It includes the Hookshot builder source, Python +runtime and modules, SDL2, static FFmpeg/dav1d libraries, and wgpu-native. + +It does not include game data or a built game. You must provide your own +legally obtained .nup dump. Xcode Command Line Tools and Rosetta 2 are required +on the Mac where you build. + +From Terminal, run: + + ./HookshotBuilder.command --nup /path/to/your-game.nup --out /path/to/output + +Add --jobs 2 (or another suitable value) to limit parallel compiler memory use. +The output folder contains the built game. Keep this builder package available +if you later use the builder's --update option. diff --git a/scripts/macos/check-renderer.py b/scripts/macos/check-renderer.py new file mode 100644 index 00000000..3d7b4cf5 --- /dev/null +++ b/scripts/macos/check-renderer.py @@ -0,0 +1,41 @@ +"""Syntax-check the complete macOS WebGPU renderer against the packaged wgpu-native headers.""" +import os +import subprocess +import sys +import tempfile + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(ROOT, "third_party", "a64-kit", "tools")) +sys.path.insert(0, os.path.join(ROOT, "nx2")) +import build +import toolchain + + +def main(): + wgpu = os.environ["WGPU_NATIVE"] + cc = toolchain.need(toolchain.find("clang++")) + gpu = build.GPU + host = build.HOST + rt = os.path.join(ROOT, "third_party", "a64-kit", "rt") + with tempfile.TemporaryDirectory(prefix="hookshot-macos-generated-") as generated: + for name in ("a64_suyu.h", "a64_fpx.h"): + with open(os.path.join(generated, name), "w", encoding="utf-8") as f: + f.write("/* Generated game-specific helpers are unused by renderer sources. */\n") + flags = [ + "--target=x86_64-apple-macos10.15", "-std=c++20", "-O2", "-g0", "-DNDEBUG", "-Wno-everything", + "-DA64_WRITE_TRACK", + "-I" + os.path.join(gpu, "compat"), + "-I" + os.path.join(gpu, "third_party", "suyu"), + "-I" + os.path.join(wgpu, "include"), + "-I" + host, "-I" + rt, "-I" + build.POSIX, "-I" + generated, + "-include", os.path.join(gpu, "render", "rx_wgpu_names.h"), + ] + for source in build.gpu_sources(): + result = subprocess.run([cc] + flags + ["-fsyntax-only", source], text=True) + if result.returncode: + raise SystemExit("macOS renderer syntax check failed: " + os.path.relpath(source, ROOT)) + print("macOS WebGPU renderer sources compile against wgpu-native.") + + +if __name__ == "__main__": + main() diff --git a/scripts/macos/deps.sh b/scripts/macos/deps.sh new file mode 100644 index 00000000..bf11d37a --- /dev/null +++ b/scripts/macos/deps.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# Build the x86-64 libraries and Python runtime used by Hookshot's Rosetta builder. +set -euo pipefail +cd "$(dirname "$0")/../.." + +DEPS=${DEPS:-$PWD/build-macos/deps} +DL=$DEPS/downloads +mkdir -p "$DEPS" "$DL" +JOBS=$(sysctl -n hw.logicalcpu) + +PY_TAG=20261026 +PY_VER=3.12.16 +SDL_VER=2.32.10 +FFMPEG_VER=9.0.2 +DAV1D_VER=1.5.4 +WGPU_VER=v29.0.1.1 +MIN_MACOS=10.15 + +fetch() { + local url=$1 file=$2 + [ -s "$DL/$file" ] || curl -fL --retry 5 --retry-delay 10 --proto '=https' -o "$DL/$file.part" "$url" + [ -s "$DL/$file" ] || mv -f "$DL/$file.part" "$DL/$file" +} + +# ---- x86-64 Python and the builder's modules ---- +if [ ! -x "$DEPS/python/bin/python3" ]; then + fetch "https://github.com/astral-sh/python-build-standalone/releases/download/$PY_TAG/cpython-$PY_VER%2B$PY_TAG-x86_64-apple-darwin-install_only.tar.gz" python.tar.gz + tar -xzf "$DL/python.tar.gz" -C "$DEPS" + arch -x86_64 "$DEPS/python/bin/python3" -m pip install --no-cache-dir --only-binary :all: \ + --disable-pip-version-check -q \ + "lz4==4.4.5" "numpy==2.5.4" "capstone==5.0.9" "unicorn==2.1.4" "zstandard==0.25.0" + rm -rf "$DEPS/python/lib/python3.12/test" "$DEPS/python/lib/python3.12/idlelib" \ + "$DEPS/python/lib/python3.12/ensurepip" + find "$DEPS/python" -name __pycache__ -prune -exec rm -rf {} + +fi + +# ---- SDL2, built as an x86-64 dylib ---- +if [ ! -f "$DEPS/sdl2/lib/libSDL2-2.0.0.dylib" ]; then + fetch "https://github.com/libsdl-org/SDL/releases/download/release-$SDL_VER/SDL2-$SDL_VER.tar.gz" sdl2.tar.gz + rm -rf "$DEPS/src-sdl2" "$DEPS/sdl2" + mkdir -p "$DEPS/src-sdl2" + tar -xzf "$DL/sdl2.tar.gz" -C "$DEPS/src-sdl2" --strip-components=1 + cmake -S "$DEPS/src-sdl2" -B "$DEPS/src-sdl2/build" -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_ARCHITECTURES=x86_64 -DCMAKE_OSX_DEPLOYMENT_TARGET="$MIN_MACOS" \ + -DSDL_SHARED=ON -DSDL_STATIC=OFF -DSDL_TEST=OFF + cmake --build "$DEPS/src-sdl2/build" --parallel "$JOBS" + cmake --install "$DEPS/src-sdl2/build" --prefix "$DEPS/sdl2" + cp "$DEPS/src-sdl2/LICENSE.txt" "$DEPS/sdl2/" + rm -rf "$DEPS/src-sdl2" +fi + +# ---- FFmpeg and dav1d, static and position-independent for the game executable ---- +P=$DEPS/ffmpeg +if [ ! -f "$P/lib/libavcodec.a" ] || [ ! -f "$P/lib/libdav1d.a" ]; then + rm -rf "$P" "$DEPS/src-dav1d" "$DEPS/src-ffmpeg" + fetch "https://code.videolan.org/videolan/dav1d/-/archive/$DAV1D_VER/dav1d-$DAV1D_VER.tar.gz" dav1d.tar.gz + mkdir -p "$DEPS/src-dav1d" + tar -xzf "$DL/dav1d.tar.gz" -C "$DEPS/src-dav1d" --strip-components=1 + cat > "$DEPS/macos-x86_64.ini" <<'EOF' +[binaries] +c = ['clang', '-arch', 'x86_64', '-mmacosx-version-min=10.15'] +ar = 'ar' +strip = 'strip' +nasm = 'nasm' + +[built-in options] +c_args = ['-arch', 'x86_64', '-mmacosx-version-min=10.15'] +c_link_args = ['-arch', 'x86_64', '-mmacosx-version-min=10.15'] + +[host_machine] +system = 'darwin' +cpu_family = 'x86_64' +cpu = 'x86_64' +endian = 'little' +EOF + meson setup "$DEPS/src-dav1d/build" "$DEPS/src-dav1d" --cross-file "$DEPS/macos-x86_64.ini" \ + --prefix="$P" --libdir=lib --buildtype=release --default-library=static \ + -Db_staticpic=true -Denable_tools=false -Denable_tests=false -Denable_examples=false + ninja -C "$DEPS/src-dav1d/build" -j "$JOBS" install + + fetch "https://ffmpeg.org/releases/ffmpeg-$FFMPEG_VER.tar.xz" ffmpeg.tar.xz + mkdir -p "$DEPS/src-ffmpeg" + tar -xJf "$DL/ffmpeg.tar.xz" -C "$DEPS/src-ffmpeg" --strip-components=1 + ( + cd "$DEPS/src-ffmpeg" + PKG_CONFIG_PATH="$P/lib/pkgconfig" CFLAGS="-arch x86_64 -mmacosx-version-min=$MIN_MACOS -fPIC" \ + LDFLAGS="-arch x86_64 -mmacosx-version-min=$MIN_MACOS" \ + ./configure --prefix="$P" --pkg-config-flags=--static --arch=x86_64 --target-os=darwin \ + --enable-cross-compile --cc=clang --enable-static --disable-shared --enable-pic \ + --disable-programs --disable-doc --disable-network --disable-autodetect \ + --disable-everything --disable-avformat --disable-avfilter --disable-avdevice \ + --enable-avcodec --enable-swscale --enable-swresample --enable-avutil \ + --enable-libdav1d --enable-decoder=vp8,vp9,libdav1d --enable-parser=vp8,vp9,av1 + make -j"$JOBS" + make install + ) + rm -rf "$DEPS/src-dav1d" "$DEPS/src-ffmpeg" "$P/share" + for lib in libavcodec.a libswscale.a libswresample.a libavutil.a libdav1d.a; do + lipo "$P/lib/$lib" -verify_arch x86_64 + done +fi + +# ---- Metal-enabled x86-64 wgpu-native release ---- +if [ ! -f "$DEPS/wgpu-native/lib/libwgpu_native.dylib" ]; then + fetch "https://github.com/gfx-rs/wgpu-native/releases/download/$WGPU_VER/wgpu-macos-x86_64-release.zip" wgpu.zip + rm -rf "$DEPS/wgpu-native" + mkdir -p "$DEPS/wgpu-native" + unzip -q "$DL/wgpu.zip" -d "$DEPS/wgpu-native" +fi +lipo "$DEPS/sdl2/lib/libSDL2-2.0.0.dylib" -verify_arch x86_64 +lipo "$DEPS/wgpu-native/lib/libwgpu_native.dylib" -verify_arch x86_64 + +# Keep the notices for the binary dependencies in the downloadable package. +L=$DEPS/licenses +mkdir -p "$L" +cp "$DEPS/sdl2/LICENSE.txt" "$L/SDL2-LICENSE.txt" +curl -fsSL --retry 5 -o "$L/FFmpeg-COPYING.LGPLv2.1" \ + "https://raw.githubusercontent.com/FFmpeg/FFmpeg/n$FFMPEG_VER/COPYING.LGPLv2.1" +curl -fsSL --retry 5 -o "$L/dav1d-COPYING" \ + "https://code.videolan.org/videolan/dav1d/-/raw/$DAV1D_VER/COPYING" +curl -fsSL --retry 5 -o "$L/wgpu-native-LICENSE-APACHE" \ + "https://raw.githubusercontent.com/gfx-rs/wgpu-native/$WGPU_VER/LICENSE-APACHE" +curl -fsSL --retry 5 -o "$L/wgpu-native-LICENSE-MIT" \ + "https://raw.githubusercontent.com/gfx-rs/wgpu-native/$WGPU_VER/LICENSE-MIT" +echo "macOS builder dependencies: $(du -sh "$DEPS" | cut -f1)" diff --git a/scripts/macos/package-builder.sh b/scripts/macos/package-builder.sh new file mode 100644 index 00000000..46a24e68 --- /dev/null +++ b/scripts/macos/package-builder.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Assemble a downloadable macOS CLI builder; no game dump or game build is included. +set -euo pipefail +cd "$(dirname "$0")/../.." + +DEPS=${DEPS:-$PWD/build-macos/deps} +OUT=${OUT:-$PWD/build-macos} +STAGE=$OUT/package +ROOT=$STAGE/HookshotBuilder +SOURCE=$ROOT/tools/source + +test -x "$DEPS/python/bin/python3" +test -f "$DEPS/sdl2/lib/libSDL2-2.0.0.dylib" +test -f "$DEPS/ffmpeg/lib/libavcodec.a" +test -f "$DEPS/wgpu-native/lib/libwgpu_native.dylib" +rm -rf "$STAGE" +mkdir -p "$SOURCE" "$ROOT/tools" +git archive --format=tar HEAD | tar -xf - -C "$SOURCE" +cp -R "$DEPS/python" "$DEPS/sdl2" "$DEPS/ffmpeg" "$DEPS/wgpu-native" "$DEPS/licenses" "$ROOT/tools/" +cp LICENSE "$ROOT/LICENSE" + +cat > "$ROOT/HookshotBuilder.command" <<'EOF' +#!/bin/sh +set -eu +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +TOOLS="$ROOT/tools" +export WGPU_NATIVE="$TOOLS/wgpu-native" +export HOOKSHOT_SDL2="$TOOLS/sdl2/lib/libSDL2-2.0.0.dylib" +export HOOKSHOT_FFMPEG="$TOOLS/ffmpeg" +export RX_BACKEND=metal +exec arch -x86_64 "$TOOLS/python/bin/python3" -I "$TOOLS/source/nx2/make_game.py" "$@" +EOF +chmod +x "$ROOT/HookshotBuilder.command" +cp scripts/macos/README.txt "$ROOT/README.txt" + +mkdir -p "$OUT" +cd "$STAGE" +ditto -c -k --sequesterRsrc --keepParent HookshotBuilder "$OUT/HookshotBuilder-macos-x86_64.zip" +echo "Created $OUT/HookshotBuilder-macos-x86_64.zip" diff --git a/tests/test_macos_build_path.py b/tests/test_macos_build_path.py index 03a406b2..739b0d49 100644 --- a/tests/test_macos_build_path.py +++ b/tests/test_macos_build_path.py @@ -83,6 +83,47 @@ def run(command, **kwargs): subprocess.run([sys.executable, "-I", "-c", script, str(source)], check=True, capture_output=True, text=True) + def test_macos_preflight_accepts_bundled_static_ffmpeg(self): + source = ROOT / "nx2" / "make_game.py" + script = r""" +import os, runpy, sys, tempfile, types +from pathlib import Path +from unittest.mock import patch +sys.platform = "darwin" +builder = runpy.run_path(sys.argv[1]) +with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + wgpu = root / "wgpu" + sdl = root / "libSDL2-2.0.0.dylib" + ffmpeg = root / "ffmpeg" + for path in (wgpu / "include/webgpu/webgpu.h", wgpu / "lib/libwgpu_native.dylib", sdl): + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + headers = ("libavcodec/avcodec.h", "libswscale/swscale.h", "libswresample/swresample.h", + "libavutil/avutil.h", "dav1d/dav1d.h") + libs = ("libavcodec.a", "libswscale.a", "libswresample.a", "libavutil.a", "libdav1d.a") + for relative in headers + libs: + path = ffmpeg / ("include" if relative.endswith(".h") else "lib") / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + commands = [] + def run(command, **kwargs): + commands.append(command) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + toolchain = types.SimpleNamespace(find=lambda name: sys.executable) + env = {"WGPU_NATIVE": str(wgpu), "HOOKSHOT_SDL2": str(sdl), "HOOKSHOT_FFMPEG": str(ffmpeg)} + with patch.dict(os.environ, env), patch("shutil.which", return_value="tool"), \ + patch("subprocess.run", side_effect=run), patch.dict(sys.modules, {"toolchain": toolchain}): + builder["check_macos_toolchain"]() + assert [c for c in commands if c[0] == "lipo"] == [ + ["lipo", str(sdl), "-verify_arch", "x86_64"], + ["lipo", str(wgpu / "lib/libwgpu_native.dylib"), "-verify_arch", "x86_64"], + ] + [["lipo", str(ffmpeg / "lib" / lib), "-verify_arch", "x86_64"] for lib in libs] + assert not any(c[0] == "pkg-config" for c in commands) +""" + subprocess.run([sys.executable, "-I", "-c", script, str(source)], check=True, + capture_output=True, text=True) + def test_macos_runtime_supplies_non_linux_profiler_entry_point(self): source = (ROOT / "host" / "posix" / "macos_stubs.c").read_text(encoding="utf-8") self.assertIn("void hos_prof_start(void)", source) From d990dbb2f613fea589f1a86ec837857e943f8e8b Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:19:06 +0100 Subject: [PATCH 15/29] Add macOS builder app and speed up CI Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 11 ++++-- builder/linux/hookshot_builder.py | 8 +++-- docs/development.md | 10 +++--- nx2/make_game.py | 4 +-- scripts/macos/Builder.applescript | 7 ++++ scripts/macos/README.txt | 10 +++--- scripts/macos/deps.sh | 50 ++++++---------------------- scripts/macos/package-builder.sh | 6 ++++ tests/test_macos_build_path.py | 4 +-- 9 files changed, 55 insertions(+), 55 deletions(-) create mode 100644 scripts/macos/Builder.applescript diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index 96d20899..6b6ca36a 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -48,8 +48,13 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: build-macos/deps + key: macos-builder-deps-${{ hashFiles('scripts/macos/deps.sh') }} + - name: Install dependency build tools - run: brew install cmake meson ninja nasm pkgconf + run: brew install cmake nasm - name: Install Rosetta run: | @@ -58,7 +63,7 @@ jobs: fi - name: Build builder dependencies - run: arch -x86_64 /bin/bash scripts/macos/deps.sh + run: bash scripts/macos/deps.sh - name: Validate bundled builder toolchain env: @@ -67,6 +72,7 @@ jobs: HOOKSHOT_FFMPEG: ${{ github.workspace }}/build-macos/deps/ffmpeg run: | arch -x86_64 build-macos/deps/python/bin/python3 -I -B tests/test_macos_build_path.py + arch -x86_64 build-macos/deps/python/bin/python3 -I -c "import tkinter" arch -x86_64 build-macos/deps/python/bin/python3 -I -c \ "import runpy; runpy.run_path('nx2/make_game.py')['check_macos_toolchain']()" arch -x86_64 build-macos/deps/python/bin/python3 -I scripts/macos/check-renderer.py @@ -76,6 +82,7 @@ jobs: - name: Verify packaged builder starts run: | + test -d build-macos/package/HookshotBuilder/HookshotBuilder.app cd build-macos/package/HookshotBuilder ./HookshotBuilder.command --help diff --git a/builder/linux/hookshot_builder.py b/builder/linux/hookshot_builder.py index 8275cb5d..3e06347a 100644 --- a/builder/linux/hookshot_builder.py +++ b/builder/linux/hookshot_builder.py @@ -24,7 +24,8 @@ import make_game # noqa: E402 (LANGUAGES, BUILD_ID) import toolchain # noqa: E402 (default_jobs) -CONFIG = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "hookshot", "builder.ini") +CONFIG = os.path.join(os.path.expanduser("~/Library/Application Support"), "Hookshot", "builder.ini") if sys.platform == "darwin" else \ + os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "hookshot", "builder.ini") STAGES = {"read": "Reading your game files", "lift": "Translating the game to PC code", "compile": "Compiling", "finish": "Putting the game folder together"} @@ -67,6 +68,8 @@ def desktop_exec(path): def add_to_menu(folder): """an entry in the applications menu that starts the game""" + if sys.platform == "darwin": + return icon = os.path.join(folder, "hookshot.png") src = os.path.join(ROOT, "assets", "hookshot.png") if os.path.exists(src): @@ -300,7 +303,8 @@ def on_exit(self, code): except OSError: pass ttk.Button(self.buttons, text="Play", command=lambda: self.play_and_close(folder)).pack(side="right", padx=4) - ttk.Button(self.buttons, text="Open folder", command=lambda: subprocess.Popen(["xdg-open", folder])).pack(side="right", padx=4) + opener = "open" if sys.platform == "darwin" else "xdg-open" + ttk.Button(self.buttons, text="Open folder", command=lambda: subprocess.Popen([opener, folder])).pack(side="right", padx=4) else: self.stage_text.set("The build stopped: %s" % getattr(self, "error", "see the lines above")) ttk.Button(self.buttons, text="Back", command=self.show_form).pack(side="right", padx=4) diff --git a/docs/development.md b/docs/development.md index 1b4b2669..48b160b6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -53,9 +53,10 @@ input, and the existing WebGPU renderer through `wgpu-native`'s Metal backend; t macOS. This provides a real Apple Silicon/Metal path while leaving native arm64 as follow-up work. The `macOS platform` workflow uploads a `HookshotBuilder-macos-x86_64` artifact containing a command-line builder, -the Hookshot source, an x86-64 Python runtime and modules, SDL2, static FFmpeg/dav1d, and Metal-enabled `wgpu-native`. +the Hookshot source, an x86-64 Python runtime and modules, SDL2, static FFmpeg, and Metal-enabled `wgpu-native`. It does not contain game data or a built game. Download and unpack it on the target Mac; Xcode Command Line Tools -and Rosetta 2 must be installed there. From Terminal, provide your own `.nup` dump: +and Rosetta 2 must be installed there. Double-click `HookshotBuilder.app` to choose your own `.nup` dump and output +folder, or use the command-line launcher: ```sh ./HookshotBuilder/HookshotBuilder.command --nup /path/to/game.nup --out /path/to/game-folder --jobs 4 @@ -64,7 +65,7 @@ and Rosetta 2 must be installed there. From Terminal, provide your own `.nup` du The package is x86-64 under Rosetta on Apple Silicon, not native arm64. For development outside the artifact, use x86-64 builds of SDL2 2.26 or newer and `wgpu-native`; Homebrew packages from the native arm64 prefix cannot be linked into this build. `wgpu-native` must include its matching `include/webgpu/webgpu.h` and `lib/libwgpu_native.dylib`, -built with Metal support. Set `HOOKSHOT_FFMPEG` to the static FFmpeg/dav1d prefix (or provide FFmpeg through +built with Metal support. Set `HOOKSHOT_FFMPEG` to the static FFmpeg prefix (or provide FFmpeg through `pkg-config`) and build from an x86-64 shell, for example: ```sh @@ -76,7 +77,8 @@ arch -x86_64 python3 -I nx2/make_game.py --nup /path/to/game.nup --out /path/to/ The normal builder command validates the x86-64 compiler, SDL2, FFmpeg and `wgpu-native` before lifting the game, then places `hookshot`, `libSDL2-2.0.0.dylib` and `libwgpu_native.dylib` beside `image.bin` in the output folder; -FFmpeg is statically linked when `HOOKSHOT_FFMPEG` points to the packaged static libraries. +FFmpeg is statically linked when `HOOKSHOT_FFMPEG` points to the packaged static libraries. The macOS FFmpeg build +uses its native AV1 decoder to avoid compiling an additional dav1d dependency in CI. For lower-level development, `nx2/lift.py --standalone --write` followed by `nx2/build.py --standalone --jobs 4` writes the executable and libraries under `work/sa/build/`. Set `RX_BACKEND=metal` to make the renderer choice explicit (Metal is the macOS default). As on other platforms, the local game dump is required to generate and build diff --git a/nx2/make_game.py b/nx2/make_game.py index cf786bc8..ec2778d4 100644 --- a/nx2/make_game.py +++ b/nx2/make_game.py @@ -301,9 +301,9 @@ def check_macos_toolchain(): libraries = [sdl, os.path.join(wgpu, "lib", "libwgpu_native.dylib")] if ffmpeg: headers = ("libavcodec/avcodec.h", "libswscale/swscale.h", "libswresample/swresample.h", - "libavutil/avutil.h", "dav1d/dav1d.h") + "libavutil/avutil.h") missing_headers = [h for h in headers if not os.path.isfile(os.path.join(ffmpeg, "include", h))] - static_libs = ("libavcodec.a", "libswscale.a", "libswresample.a", "libavutil.a", "libdav1d.a") + static_libs = ("libavcodec.a", "libswscale.a", "libswresample.a", "libavutil.a") missing_libs = [l for l in static_libs if not os.path.isfile(os.path.join(ffmpeg, "lib", l))] if missing_headers or missing_libs: missing = missing_headers + missing_libs diff --git a/scripts/macos/Builder.applescript b/scripts/macos/Builder.applescript new file mode 100644 index 00000000..f9f9babd --- /dev/null +++ b/scripts/macos/Builder.applescript @@ -0,0 +1,7 @@ +on run + set appRoot to POSIX path of (path to me) + set tools to appRoot & "Contents/Resources/tools/" + set source to tools & "source" + set commandLine to "export HOOKSHOT_TOOLS=" & quoted form of source & " && export WGPU_NATIVE=" & quoted form of (tools & "wgpu-native") & " && export HOOKSHOT_SDL2=" & quoted form of (tools & "sdl2/lib/libSDL2-2.0.0.dylib") & " && export HOOKSHOT_FFMPEG=" & quoted form of (tools & "ffmpeg") & " && export RX_BACKEND=metal && exec arch -x86_64 " & quoted form of (tools & "python/bin/python3") & " -I " & quoted form of (source & "/builder/linux/hookshot_builder.py") + do shell script commandLine +end run diff --git a/scripts/macos/README.txt b/scripts/macos/README.txt index 9992f166..c195e7b0 100644 --- a/scripts/macos/README.txt +++ b/scripts/macos/README.txt @@ -1,14 +1,16 @@ Hookshot Builder for macOS (Apple Silicon) -This package is an x86-64 command-line builder that runs under Rosetta 2 and -uses WebGPU's Metal backend. It includes the Hookshot builder source, Python -runtime and modules, SDL2, static FFmpeg/dav1d libraries, and wgpu-native. +This package includes a macOS app and an x86-64 command-line builder that run +under Rosetta 2 and use WebGPU's Metal backend. It includes Hookshot source, Python +runtime and modules, SDL2, static FFmpeg libraries, and wgpu-native. It does not include game data or a built game. You must provide your own legally obtained .nup dump. Xcode Command Line Tools and Rosetta 2 are required on the Mac where you build. -From Terminal, run: +Double-click HookshotBuilder.app to open the Hookshot Builder interface. It +matches the existing builder flow: choose your dump and output folder, then +watch the build progress. For command-line use, run: ./HookshotBuilder.command --nup /path/to/your-game.nup --out /path/to/output diff --git a/scripts/macos/deps.sh b/scripts/macos/deps.sh index bf11d37a..4e7ada89 100644 --- a/scripts/macos/deps.sh +++ b/scripts/macos/deps.sh @@ -12,7 +12,6 @@ PY_TAG=20261026 PY_VER=3.12.16 SDL_VER=2.32.10 FFMPEG_VER=9.0.2 -DAV1D_VER=1.5.4 WGPU_VER=v29.0.1.1 MIN_MACOS=10.15 @@ -49,53 +48,28 @@ if [ ! -f "$DEPS/sdl2/lib/libSDL2-2.0.0.dylib" ]; then rm -rf "$DEPS/src-sdl2" fi -# ---- FFmpeg and dav1d, static and position-independent for the game executable ---- +# ---- FFmpeg, static and position-independent for the game executable ---- P=$DEPS/ffmpeg -if [ ! -f "$P/lib/libavcodec.a" ] || [ ! -f "$P/lib/libdav1d.a" ]; then - rm -rf "$P" "$DEPS/src-dav1d" "$DEPS/src-ffmpeg" - fetch "https://code.videolan.org/videolan/dav1d/-/archive/$DAV1D_VER/dav1d-$DAV1D_VER.tar.gz" dav1d.tar.gz - mkdir -p "$DEPS/src-dav1d" - tar -xzf "$DL/dav1d.tar.gz" -C "$DEPS/src-dav1d" --strip-components=1 - cat > "$DEPS/macos-x86_64.ini" <<'EOF' -[binaries] -c = ['clang', '-arch', 'x86_64', '-mmacosx-version-min=10.15'] -ar = 'ar' -strip = 'strip' -nasm = 'nasm' - -[built-in options] -c_args = ['-arch', 'x86_64', '-mmacosx-version-min=10.15'] -c_link_args = ['-arch', 'x86_64', '-mmacosx-version-min=10.15'] - -[host_machine] -system = 'darwin' -cpu_family = 'x86_64' -cpu = 'x86_64' -endian = 'little' -EOF - meson setup "$DEPS/src-dav1d/build" "$DEPS/src-dav1d" --cross-file "$DEPS/macos-x86_64.ini" \ - --prefix="$P" --libdir=lib --buildtype=release --default-library=static \ - -Db_staticpic=true -Denable_tools=false -Denable_tests=false -Denable_examples=false - ninja -C "$DEPS/src-dav1d/build" -j "$JOBS" install - +if [ ! -f "$P/lib/libavcodec.a" ]; then + rm -rf "$P" "$DEPS/src-ffmpeg" fetch "https://ffmpeg.org/releases/ffmpeg-$FFMPEG_VER.tar.xz" ffmpeg.tar.xz mkdir -p "$DEPS/src-ffmpeg" tar -xJf "$DL/ffmpeg.tar.xz" -C "$DEPS/src-ffmpeg" --strip-components=1 ( cd "$DEPS/src-ffmpeg" - PKG_CONFIG_PATH="$P/lib/pkgconfig" CFLAGS="-arch x86_64 -mmacosx-version-min=$MIN_MACOS -fPIC" \ + CFLAGS="-arch x86_64 -mmacosx-version-min=$MIN_MACOS -fPIC" \ LDFLAGS="-arch x86_64 -mmacosx-version-min=$MIN_MACOS" \ - ./configure --prefix="$P" --pkg-config-flags=--static --arch=x86_64 --target-os=darwin \ - --enable-cross-compile --cc=clang --enable-static --disable-shared --enable-pic \ + ./configure --prefix="$P" --arch=x86_64 --target-os=darwin \ + --cc=clang --enable-static --disable-shared --enable-pic \ --disable-programs --disable-doc --disable-network --disable-autodetect \ --disable-everything --disable-avformat --disable-avfilter --disable-avdevice \ --enable-avcodec --enable-swscale --enable-swresample --enable-avutil \ - --enable-libdav1d --enable-decoder=vp8,vp9,libdav1d --enable-parser=vp8,vp9,av1 + --enable-decoder=vp8,vp9,av1 --enable-parser=vp8,vp9,av1 make -j"$JOBS" make install ) - rm -rf "$DEPS/src-dav1d" "$DEPS/src-ffmpeg" "$P/share" - for lib in libavcodec.a libswscale.a libswresample.a libavutil.a libdav1d.a; do + rm -rf "$DEPS/src-ffmpeg" "$P/share" + for lib in libavcodec.a libswscale.a libswresample.a libavutil.a; do lipo "$P/lib/$lib" -verify_arch x86_64 done fi @@ -116,10 +90,8 @@ mkdir -p "$L" cp "$DEPS/sdl2/LICENSE.txt" "$L/SDL2-LICENSE.txt" curl -fsSL --retry 5 -o "$L/FFmpeg-COPYING.LGPLv2.1" \ "https://raw.githubusercontent.com/FFmpeg/FFmpeg/n$FFMPEG_VER/COPYING.LGPLv2.1" -curl -fsSL --retry 5 -o "$L/dav1d-COPYING" \ - "https://code.videolan.org/videolan/dav1d/-/raw/$DAV1D_VER/COPYING" curl -fsSL --retry 5 -o "$L/wgpu-native-LICENSE-APACHE" \ - "https://raw.githubusercontent.com/gfx-rs/wgpu-native/$WGPU_VER/LICENSE-APACHE" + "https://raw.githubusercontent.com/gfx-rs/wgpu-native/$WGPU_VER/LICENSE.APACHE" curl -fsSL --retry 5 -o "$L/wgpu-native-LICENSE-MIT" \ - "https://raw.githubusercontent.com/gfx-rs/wgpu-native/$WGPU_VER/LICENSE-MIT" + "https://raw.githubusercontent.com/gfx-rs/wgpu-native/$WGPU_VER/LICENSE.MIT" echo "macOS builder dependencies: $(du -sh "$DEPS" | cut -f1)" diff --git a/scripts/macos/package-builder.sh b/scripts/macos/package-builder.sh index 46a24e68..09b15f1e 100644 --- a/scripts/macos/package-builder.sh +++ b/scripts/macos/package-builder.sh @@ -33,6 +33,12 @@ EOF chmod +x "$ROOT/HookshotBuilder.command" cp scripts/macos/README.txt "$ROOT/README.txt" +APP=$ROOT/HookshotBuilder.app +osacompile -o "$APP" scripts/macos/Builder.applescript +mkdir -p "$APP/Contents/Resources" +ln -s ../../../tools "$APP/Contents/Resources/tools" +test -x "$APP/Contents/MacOS/applet" + mkdir -p "$OUT" cd "$STAGE" ditto -c -k --sequesterRsrc --keepParent HookshotBuilder "$OUT/HookshotBuilder-macos-x86_64.zip" diff --git a/tests/test_macos_build_path.py b/tests/test_macos_build_path.py index 739b0d49..cb043431 100644 --- a/tests/test_macos_build_path.py +++ b/tests/test_macos_build_path.py @@ -100,8 +100,8 @@ def test_macos_preflight_accepts_bundled_static_ffmpeg(self): path.parent.mkdir(parents=True, exist_ok=True) path.touch() headers = ("libavcodec/avcodec.h", "libswscale/swscale.h", "libswresample/swresample.h", - "libavutil/avutil.h", "dav1d/dav1d.h") - libs = ("libavcodec.a", "libswscale.a", "libswresample.a", "libavutil.a", "libdav1d.a") + "libavutil/avutil.h") + libs = ("libavcodec.a", "libswscale.a", "libswresample.a", "libavutil.a") for relative in headers + libs: path = ffmpeg / ("include" if relative.endswith(".h") else "lib") / relative path.parent.mkdir(parents=True, exist_ok=True) From eb820d61b52eb57e501ae6c9ef8989e80afc9dd6 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:25:28 +0100 Subject: [PATCH 16/29] Isolate macOS dependency preflight test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_macos_build_path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_macos_build_path.py b/tests/test_macos_build_path.py index cb043431..ee036658 100644 --- a/tests/test_macos_build_path.py +++ b/tests/test_macos_build_path.py @@ -70,7 +70,7 @@ def run(command, **kwargs): commands.append(command) return types.SimpleNamespace(returncode=0, stdout="", stderr="") toolchain = types.SimpleNamespace(find=lambda name: sys.executable) - with patch.dict(os.environ, {"WGPU_NATIVE": str(wgpu), "HOOKSHOT_SDL2": str(sdl)}), \ + with patch.dict(os.environ, {"WGPU_NATIVE": str(wgpu), "HOOKSHOT_SDL2": str(sdl), "HOOKSHOT_FFMPEG": ""}), \ patch("shutil.which", return_value="tool"), \ patch("subprocess.run", side_effect=run), \ patch.dict(sys.modules, {"toolchain": toolchain}): From d6b9a3544acaf87f9313d73eaab0a56ae1cc1f6f Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:33:15 +0100 Subject: [PATCH 17/29] Pass the macOS SDK to direct clang builds Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nx2/build.py | 2 ++ nx2/make_game.py | 7 +++++-- tests/test_macos_build_path.py | 27 +++++++++++++++++++++++--- third_party/a64-kit/tools/toolchain.py | 18 +++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/nx2/build.py b/nx2/build.py index 340bd252..2c0f53d5 100644 --- a/nx2/build.py +++ b/nx2/build.py @@ -58,6 +58,8 @@ def target(): """toolchain.native_target(), with the sysroot on Linux""" target = ["--target=x86_64-apple-macos10.15"] if MACOS else toolchain.native_target() + if MACOS: + target += toolchain.macos_sysroot_flags() return target + (["--sysroot=" + SYSROOT] if LINUX and SYSROOT else []) diff --git a/nx2/make_game.py b/nx2/make_game.py index ec2778d4..d5b2dd0d 100644 --- a/nx2/make_game.py +++ b/nx2/make_game.py @@ -73,7 +73,9 @@ def failure_reason(lines): tail, seen = [], None for line in lines: tail = (tail + [line])[-12:] - if "error:" in line.lower() and "CalledProcessError" not in line: + lower = line.lower() + if ("error:" in lower and "CalledProcessError" not in line and + "linker command failed" not in lower): seen = line if seen is not None: return seen @@ -323,7 +325,8 @@ def check_macos_toolchain(): src, exe = os.path.join(d, "probe.c"), os.path.join(d, "probe") with open(src, "w", encoding="utf-8") as f: f.write("int main(void) { return 0; }\n") - r = subprocess.run([cc, "--target=x86_64-apple-macos10.15", src, "-o", exe], + r = subprocess.run([cc, "--target=x86_64-apple-macos10.15"] + toolchain.macos_sysroot_flags() + + [src, "-o", exe], capture_output=True, text=True, encoding="utf-8", errors="replace") if r.returncode: fail("Xcode Command Line Tools could not link an x86-64 macOS test program: %s" diff --git a/tests/test_macos_build_path.py b/tests/test_macos_build_path.py index ee036658..1af2888b 100644 --- a/tests/test_macos_build_path.py +++ b/tests/test_macos_build_path.py @@ -9,12 +9,31 @@ class MacOSBuildPathTests(unittest.TestCase): + def test_macos_toolchain_uses_the_xcrun_sdk(self): + source = ROOT / "third_party" / "a64-kit" / "tools" / "toolchain.py" + script = r""" +import runpy, sys, tempfile, types +from pathlib import Path +from unittest.mock import patch +sys.platform = "darwin" +tools = runpy.run_path(sys.argv[1]) +with tempfile.TemporaryDirectory() as sdk: + with patch.dict("os.environ", {"SDKROOT": ""}), patch("shutil.which", return_value="xcrun"), \ + patch("subprocess.run", return_value=types.SimpleNamespace(returncode=0, stdout=sdk, stderr="")) as run: + assert tools["macos_sysroot_flags"]() == ["-isysroot", sdk] + run.assert_called_once_with(["xcrun", "--sdk", "macosx", "--show-sdk-path"], + capture_output=True, text=True) +""" + subprocess.run([sys.executable, "-I", "-c", script, str(source)], check=True, + capture_output=True, text=True) + def test_selects_x86_64_posix_and_metal_build_target(self): source = ROOT / "nx2" / "build.py" script = ( "import json, runpy, sys; " "sys.platform = 'darwin'; " "b = runpy.run_path(sys.argv[1]); " + "b['toolchain'].macos_sysroot_flags = lambda: ['-isysroot', '/MacOSX.sdk']; " "print(json.dumps({k: b[k] for k in ('WINDOWS', 'LINUX', 'MACOS', 'POSIX_HOST', 'EXE', 'MARCH')} " "| {'target': b['target']()}))" ) @@ -28,7 +47,7 @@ def test_selects_x86_64_posix_and_metal_build_target(self): "POSIX_HOST": True, "EXE": "hookshot", "MARCH": "x86-64", - "target": ["--target=x86_64-apple-macos10.15"], + "target": ["--target=x86_64-apple-macos10.15", "-isysroot", "/MacOSX.sdk"], }) def test_game_builder_selects_macos_bundle_files(self): @@ -69,7 +88,8 @@ def test_macos_preflight_verifies_dependency_architectures(self): def run(command, **kwargs): commands.append(command) return types.SimpleNamespace(returncode=0, stdout="", stderr="") - toolchain = types.SimpleNamespace(find=lambda name: sys.executable) + toolchain = types.SimpleNamespace(find=lambda name: sys.executable, + macos_sysroot_flags=lambda: ["-isysroot", "/MacOSX.sdk"]) with patch.dict(os.environ, {"WGPU_NATIVE": str(wgpu), "HOOKSHOT_SDL2": str(sdl), "HOOKSHOT_FFMPEG": ""}), \ patch("shutil.which", return_value="tool"), \ patch("subprocess.run", side_effect=run), \ @@ -110,7 +130,8 @@ def test_macos_preflight_accepts_bundled_static_ffmpeg(self): def run(command, **kwargs): commands.append(command) return types.SimpleNamespace(returncode=0, stdout="", stderr="") - toolchain = types.SimpleNamespace(find=lambda name: sys.executable) + toolchain = types.SimpleNamespace(find=lambda name: sys.executable, + macos_sysroot_flags=lambda: ["-isysroot", "/MacOSX.sdk"]) env = {"WGPU_NATIVE": str(wgpu), "HOOKSHOT_SDL2": str(sdl), "HOOKSHOT_FFMPEG": str(ffmpeg)} with patch.dict(os.environ, env), patch("shutil.which", return_value="tool"), \ patch("subprocess.run", side_effect=run), patch.dict(sys.modules, {"toolchain": toolchain}): diff --git a/third_party/a64-kit/tools/toolchain.py b/third_party/a64-kit/tools/toolchain.py index 0144c342..3a7cc23f 100644 --- a/third_party/a64-kit/tools/toolchain.py +++ b/third_party/a64-kit/tools/toolchain.py @@ -37,6 +37,24 @@ def find(tool): return shutil.which(tool) or os.path.join(folder, tool + exts[0]) +def macos_sysroot_flags(): + """Return the active macOS SDK for direct clang invocations (xcrun normally adds this implicitly).""" + if sys.platform != "darwin": + return [] + sdk = os.environ.get("SDKROOT", "") + if sdk and os.path.isdir(sdk): + return ["-isysroot", sdk] + xcrun = shutil.which("xcrun") + if not xcrun: + raise SystemExit("Xcode Command Line Tools are required to locate the macOS SDK (xcrun not found).") + result = subprocess.run([xcrun, "--sdk", "macosx", "--show-sdk-path"], capture_output=True, text=True) + sdk = result.stdout.strip() + if result.returncode or not sdk or not os.path.isdir(sdk): + reason = result.stderr.strip() or "xcrun returned no SDK path" + raise SystemExit("Could not locate the macOS SDK: %s" % reason) + return ["-isysroot", sdk] + + def need(path): if not os.path.isfile(path): raise SystemExit("%s not found: set EMSDK to an emsdk checkout (now %s), or put %s on PATH" From 5a4536651523fbe8fb8c92394fe3ea236f994fc4 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:41:50 +0100 Subject: [PATCH 18/29] Pass macOS SDK to renderer check Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/macos/check-renderer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/macos/check-renderer.py b/scripts/macos/check-renderer.py index 3d7b4cf5..d60bb4d9 100644 --- a/scripts/macos/check-renderer.py +++ b/scripts/macos/check-renderer.py @@ -23,6 +23,7 @@ def main(): f.write("/* Generated game-specific helpers are unused by renderer sources. */\n") flags = [ "--target=x86_64-apple-macos10.15", "-std=c++20", "-O2", "-g0", "-DNDEBUG", "-Wno-everything", + *toolchain.macos_sysroot_flags(), "-DA64_WRITE_TRACK", "-I" + os.path.join(gpu, "compat"), "-I" + os.path.join(gpu, "third_party", "suyu"), From 6f20cb1e834e73dd59796051db6c09689a4100d9 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:53:05 +0100 Subject: [PATCH 19/29] Fix macOS renderer compiler validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 6 +++--- docs/development.md | 1 + gpu/render/rx_buffer.cpp | 23 ++++++++++++++++------- nx2/build.py | 2 +- nx2/make_game.py | 2 +- scripts/macos/README.txt | 1 + scripts/macos/check-renderer.py | 2 +- tests/test_macos_build_path.py | 2 +- 8 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index 6b6ca36a..c5690bcc 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -24,7 +24,7 @@ jobs: - name: Link x86-64 macOS executable run: | printf 'int main(void) { return 0; }\n' > "$RUNNER_TEMP/macos-probe.c" - clang --target=x86_64-apple-macos10.15 "$RUNNER_TEMP/macos-probe.c" -o "$RUNNER_TEMP/macos-probe" + clang --target=x86_64-apple-macos13.3 "$RUNNER_TEMP/macos-probe.c" -o "$RUNNER_TEMP/macos-probe" lipo "$RUNNER_TEMP/macos-probe" -verify_arch x86_64 - name: Compile macOS POSIX host shims @@ -34,10 +34,10 @@ jobs: > "$RUNNER_TEMP/a64-generated/a64_suyu.h" printf '/* Generated per-game floating-point helpers are unused by these host translation units. */\n' \ > "$RUNNER_TEMP/a64-generated/a64_fpx.h" - clang -target x86_64-apple-macos10.15 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ + clang -target x86_64-apple-macos13.3 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ host/posix/win32_posix.c host/hos_main.c host/rx_backend.c - clang -target x86_64-apple-macos10.15 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ + clang -target x86_64-apple-macos13.3 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ host/posix/display_sdl.c host/posix/macos_stubs.c diff --git a/docs/development.md b/docs/development.md index 48b160b6..45006a41 100644 --- a/docs/development.md +++ b/docs/development.md @@ -51,6 +51,7 @@ The current runtime has x86-64 context-switch assembly and x86-specific synchron target is an x86-64 executable run under Rosetta 2, not a native arm64 executable. It uses SDL2 for the window and input, and the existing WebGPU renderer through `wgpu-native`'s Metal backend; the Vulkan backend is not built on macOS. This provides a real Apple Silicon/Metal path while leaving native arm64 as follow-up work. +The executable targets macOS 13.3 or newer because Apple's C++20 floating-point formatting support requires it. The `macOS platform` workflow uploads a `HookshotBuilder-macos-x86_64` artifact containing a command-line builder, the Hookshot source, an x86-64 Python runtime and modules, SDL2, static FFmpeg, and Metal-enabled `wgpu-native`. diff --git a/gpu/render/rx_buffer.cpp b/gpu/render/rx_buffer.cpp index 4bcf1e50..e9627532 100644 --- a/gpu/render/rx_buffer.cpp +++ b/gpu/render/rx_buffer.cpp @@ -7,11 +7,11 @@ // below it). A segment is created when a draw first needs it; its pages are uploaded when a draw needs them (one // valid bit per 4 KB page; pages a dispatch wrote are copied from the neighbour that holds them). // -// Changes: the guest arena is allocated with MEM_WRITE_WATCH (hos_main.c). At the start of each GPU submission the -// pages written since the last one are invalidated (GetWriteWatch), which catches every CPU write the submission's -// data needs. Writes the command processor makes while it runs a submission (inline uploads, copies: hos/gpu.c) -// are reported through rx_memory_written; if a page they change was read earlier in the open batch, the batch is -// submitted first, so earlier draws see the old contents. +// Changes: A64_WRITE_TRACK marks guest pages written by the CPU; at the start of each GPU submission those pages are +// invalidated. Windows also uses MEM_WRITE_WATCH for writes that bypass the translated stores. Writes the command +// processor makes while it runs a submission (inline uploads, copies: hos/gpu.c) are reported through +// rx_memory_written; if a page they change was read earlier in the open batch, the batch is submitted first, so +// earlier draws see the old contents. #include #include #include @@ -53,7 +53,9 @@ std::vector segments; // by base / kStep // ((mkw-recomp) by buffer: the table has a slot per 8 MB of the 512 GB window, 65536, which a search by buffer walked) std::unordered_map segment_of_buffer; int64_t batch_id; +#if !defined(__APPLE__) || !defined(A64_WRITE_TRACK) || defined(__EMSCRIPTEN__) bool watch_ok = true; +#endif #if defined(A64_WRITE_TRACK) && !defined(__EMSCRIPTEN__) extern "C" uint8_t a64_dirty[1ull << 27]; // (rt/a64_rt.c: (mkw-recomp) A64_DIRTY_PAGES, a mark per page of 512 GB) #endif @@ -362,8 +364,8 @@ BufferRef guest_buffer(uint64_t gpu_va, uint64_t size) { return {s->buffer, guest - s->base, size}; } -/// The guest pages the CPU wrote since the last call (GetWriteWatch, which also resets them): any thread. False when -/// write watching does not work (then nothing is tracked). +/// The guest pages the CPU wrote since the last call (the active write watch is reset): any thread. False when write +/// watching does not work (then nothing is tracked). bool collect_written_pages(std::vector& out) { out.clear(); #if defined(A64_WRITE_TRACK) && !defined(__EMSCRIPTEN__) @@ -392,6 +394,7 @@ bool collect_written_pages(std::vector& out) { return true; } #endif +#if !defined(__APPLE__) || !defined(A64_WRITE_TRACK) || defined(__EMSCRIPTEN__) if (!watch_ok) return false; // only the memory mapped into the GPU's address space (what the renderer can read); the cost of GetWriteWatch // grows with the range, not with the pages written @@ -448,6 +451,7 @@ bool collect_written_pages(std::vector& out) { hos_log("rx: change tracking watches %d ranges, %llu MB", nranges, (unsigned long long)(total >> 20)); } return true; +#endif } /// Pages the CPU wrote (collect_written_pages) are invalidated (called with no batch open). @@ -687,10 +691,13 @@ void buffer_cache_sync_to_guest(uint64_t gpu_va, uint64_t size) { uint8_t* dst = hos_guest_base() + p0 * kPage; // (pages the CPU wrote since the last scan keep their write-watch mark; the others are not reported as CPU // writes: the segment already holds this) + // macOS keeps these marks for collect_written_pages; only the native write-watch path resets marks here. +#if !defined(__APPLE__) || !defined(A64_WRITE_TRACK) || defined(__EMSCRIPTEN__) std::vector dirty(p1 - p0 + 1); ULONG_PTR ndirty = dirty.size(); DWORD gran = 0; const bool watched = !GetWriteWatch(0, dst, n, dirty.data(), &ndirty, &gran); +#endif const auto* src = static_cast(wgpuBufferGetConstMappedRange(staging, 0, n)); std::lock_guard lk(shadow_mu); for (uint64_t p = p0; p <= p1; p++) { @@ -708,7 +715,9 @@ void buffer_cache_sync_to_guest(uint64_t gpu_va, uint64_t size) { for (uint32_t w = 0; w < kPage / 4; w++) if (gw[w] != sw[w]) dw[w] = gw[w]; } +#if !defined(__APPLE__) || !defined(A64_WRITE_TRACK) || defined(__EMSCRIPTEN__) if (watched && ndirty == 0) ResetWriteWatch(dst, n); +#endif wgpuBufferUnmap(staging); for (uint64_t p = p0; p <= p1; p++) { gpu_pages[p] = 0; diff --git a/nx2/build.py b/nx2/build.py index 2c0f53d5..2fe47af9 100644 --- a/nx2/build.py +++ b/nx2/build.py @@ -57,7 +57,7 @@ def target(): """toolchain.native_target(), with the sysroot on Linux""" - target = ["--target=x86_64-apple-macos10.15"] if MACOS else toolchain.native_target() + target = ["--target=x86_64-apple-macos13.3"] if MACOS else toolchain.native_target() if MACOS: target += toolchain.macos_sysroot_flags() return target + (["--sysroot=" + SYSROOT] if LINUX and SYSROOT else []) diff --git a/nx2/make_game.py b/nx2/make_game.py index d5b2dd0d..69c13666 100644 --- a/nx2/make_game.py +++ b/nx2/make_game.py @@ -325,7 +325,7 @@ def check_macos_toolchain(): src, exe = os.path.join(d, "probe.c"), os.path.join(d, "probe") with open(src, "w", encoding="utf-8") as f: f.write("int main(void) { return 0; }\n") - r = subprocess.run([cc, "--target=x86_64-apple-macos10.15"] + toolchain.macos_sysroot_flags() + + r = subprocess.run([cc, "--target=x86_64-apple-macos13.3"] + toolchain.macos_sysroot_flags() + [src, "-o", exe], capture_output=True, text=True, encoding="utf-8", errors="replace") if r.returncode: diff --git a/scripts/macos/README.txt b/scripts/macos/README.txt index c195e7b0..98bee379 100644 --- a/scripts/macos/README.txt +++ b/scripts/macos/README.txt @@ -7,6 +7,7 @@ runtime and modules, SDL2, static FFmpeg libraries, and wgpu-native. It does not include game data or a built game. You must provide your own legally obtained .nup dump. Xcode Command Line Tools and Rosetta 2 are required on the Mac where you build. +The generated executable requires macOS 13.3 or newer. Double-click HookshotBuilder.app to open the Hookshot Builder interface. It matches the existing builder flow: choose your dump and output folder, then diff --git a/scripts/macos/check-renderer.py b/scripts/macos/check-renderer.py index d60bb4d9..900583fd 100644 --- a/scripts/macos/check-renderer.py +++ b/scripts/macos/check-renderer.py @@ -22,7 +22,7 @@ def main(): with open(os.path.join(generated, name), "w", encoding="utf-8") as f: f.write("/* Generated game-specific helpers are unused by renderer sources. */\n") flags = [ - "--target=x86_64-apple-macos10.15", "-std=c++20", "-O2", "-g0", "-DNDEBUG", "-Wno-everything", + "--target=x86_64-apple-macos13.3", "-std=c++20", "-O2", "-g0", "-DNDEBUG", "-Wno-everything", *toolchain.macos_sysroot_flags(), "-DA64_WRITE_TRACK", "-I" + os.path.join(gpu, "compat"), diff --git a/tests/test_macos_build_path.py b/tests/test_macos_build_path.py index 1af2888b..d09fef9a 100644 --- a/tests/test_macos_build_path.py +++ b/tests/test_macos_build_path.py @@ -47,7 +47,7 @@ def test_selects_x86_64_posix_and_metal_build_target(self): "POSIX_HOST": True, "EXE": "hookshot", "MARCH": "x86-64", - "target": ["--target=x86_64-apple-macos10.15", "-isysroot", "/MacOSX.sdk"], + "target": ["--target=x86_64-apple-macos13.3", "-isysroot", "/MacOSX.sdk"], }) def test_game_builder_selects_macos_bundle_files(self): From 11c73b4b1ed78cb230f0f1fd640242f3af79db45 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:59:01 +0100 Subject: [PATCH 20/29] Keep renderer write-watch state visible on macOS Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- gpu/render/rx_buffer.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/gpu/render/rx_buffer.cpp b/gpu/render/rx_buffer.cpp index e9627532..f155f89e 100644 --- a/gpu/render/rx_buffer.cpp +++ b/gpu/render/rx_buffer.cpp @@ -53,9 +53,7 @@ std::vector segments; // by base / kStep // ((mkw-recomp) by buffer: the table has a slot per 8 MB of the 512 GB window, 65536, which a search by buffer walked) std::unordered_map segment_of_buffer; int64_t batch_id; -#if !defined(__APPLE__) || !defined(A64_WRITE_TRACK) || defined(__EMSCRIPTEN__) bool watch_ok = true; -#endif #if defined(A64_WRITE_TRACK) && !defined(__EMSCRIPTEN__) extern "C" uint8_t a64_dirty[1ull << 27]; // (rt/a64_rt.c: (mkw-recomp) A64_DIRTY_PAGES, a mark per page of 512 GB) #endif From 2b712ce9adfe4d77904b046d97db252494ea1466 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:14:47 +0100 Subject: [PATCH 21/29] Use SDL window dimensions for macOS renderer Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- gpu/render/rx_core.cpp | 8 ++++++++ host/posix/display_sdl.c | 5 +++++ tests/test_macos_build_path.py | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/gpu/render/rx_core.cpp b/gpu/render/rx_core.cpp index e7ff62a2..cc17e019 100644 --- a/gpu/render/rx_core.cpp +++ b/gpu/render/rx_core.cpp @@ -57,6 +57,9 @@ bool wgpuDevicePoll(WGPUDevice device, bool wait, const void* submission) { #endif namespace rx { +#ifdef __APPLE__ +extern "C" void hos_posix_window_pixel_size(int* width, int* height); +#endif bool draw_must_run = false; bool rt_history_on() { static const bool on = getenv("RX_RT_HISTORY") != nullptr; return on; } @@ -578,6 +581,11 @@ bool init_device(HWND hwnd) { #ifdef __EMSCRIPTEN__ present_mode = WGPUPresentMode_Fifo; configure_surface(1280, 720); +#elif defined(__APPLE__) + int width = 0, height = 0; + hos_posix_window_pixel_size(&width, &height); + configure_surface((uint32_t)std::max(width, 1), (uint32_t)std::max(height, 1)); + present_window_size(width, height); #else RECT rc; GetClientRect(hwnd, &rc); diff --git a/host/posix/display_sdl.c b/host/posix/display_sdl.c index 61f655f4..790716e6 100644 --- a/host/posix/display_sdl.c +++ b/host/posix/display_sdl.c @@ -157,6 +157,11 @@ static void window_pixel_size(void *w, int *width, int *height) { sdl.Vulkan_GetDrawableSize(w, width, height); #endif } +#ifdef __APPLE__ +void hos_posix_window_pixel_size(int *width, int *height) { + window_pixel_size(window, width, height); +} +#endif static void mouse_capture(int on) { if (on == (int)mouse_captured) return; diff --git a/tests/test_macos_build_path.py b/tests/test_macos_build_path.py index d09fef9a..23797fb8 100644 --- a/tests/test_macos_build_path.py +++ b/tests/test_macos_build_path.py @@ -149,6 +149,13 @@ def test_macos_runtime_supplies_non_linux_profiler_entry_point(self): source = (ROOT / "host" / "posix" / "macos_stubs.c").read_text(encoding="utf-8") self.assertIn("void hos_prof_start(void)", source) + def test_macos_renderer_uses_sdl_pixel_dimensions(self): + renderer = (ROOT / "gpu" / "render" / "rx_core.cpp").read_text(encoding="utf-8") + window = (ROOT / "host" / "posix" / "display_sdl.c").read_text(encoding="utf-8") + self.assertIn("hos_posix_window_pixel_size(&width, &height)", renderer) + self.assertIn("void hos_posix_window_pixel_size(int *width, int *height)", window) + self.assertIn("window_pixel_size(window, width, height)", window) + if __name__ == "__main__": unittest.main(verbosity=2) From 8a30e75d48f73478b8ddec56a4280e17ee25de5e Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:33:47 +0100 Subject: [PATCH 22/29] Port macOS builder and game target to native arm64 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 62 +++++++++++++-------- docs/development.md | 34 ++++++------ gpu/render/rx_core.cpp | 4 ++ gpu/render/rx_draw.cpp | 2 +- gpu/render/rx_internal.h | 11 ++++ host/hos_setjmp.c | 63 +++++++++++++++++++--- host/hos_thread.c | 3 +- host/posix/windows.h | 2 +- nx2/build.py | 15 +++--- nx2/make_game.py | 21 ++++---- scripts/macos/Builder.applescript | 2 +- scripts/macos/README.txt | 8 +-- scripts/macos/check-arm64-translation.py | 21 ++++++++ scripts/macos/check-renderer.py | 3 +- scripts/macos/deps.sh | 30 +++++------ scripts/macos/package-builder.sh | 13 +++-- tests/test_macos_build_path.py | 33 ++++++++---- third_party/a64-kit/lift/suyu/arm64_to_c.h | 42 ++++++++------- third_party/a64-kit/tools/toolchain.py | 6 +++ 19 files changed, 255 insertions(+), 120 deletions(-) create mode 100644 scripts/macos/check-arm64-translation.py diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index c5690bcc..3ad3353a 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -9,7 +9,7 @@ permissions: jobs: macos-posix: - # Apple Silicon runner; Hookshot's current native context-switching assembly requires x86-64. + # Apple Silicon runner: verify the native arm64 game and runtime target. runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -19,13 +19,20 @@ jobs: python-version: "3.12" - name: Build configuration test - run: python -I -B tests/test_macos_build_path.py + run: | + python -I -B tests/test_macos_build_path.py + python -I third_party/a64-kit/lift/build_emit.py + python -I scripts/macos/check-arm64-translation.py - - name: Link x86-64 macOS executable + - name: Link native arm64 macOS executable run: | printf 'int main(void) { return 0; }\n' > "$RUNNER_TEMP/macos-probe.c" - clang --target=x86_64-apple-macos13.3 "$RUNNER_TEMP/macos-probe.c" -o "$RUNNER_TEMP/macos-probe" - lipo "$RUNNER_TEMP/macos-probe" -verify_arch x86_64 + clang -target arm64-apple-macos13.3 -mcpu=apple-m1 "$RUNNER_TEMP/macos-probe.c" -o "$RUNNER_TEMP/macos-probe" + lipo "$RUNNER_TEMP/macos-probe" -verify_arch arm64 + printf 'int main(void) { unsigned __int128 v = 0, e = 0; return __atomic_compare_exchange_n(&v, &e, 1, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST); }\n' \ + > "$RUNNER_TEMP/arm64-atomic-probe.c" + clang -target arm64-apple-macos13.3 -mcpu=apple-m1 "$RUNNER_TEMP/arm64-atomic-probe.c" \ + -o "$RUNNER_TEMP/arm64-atomic-probe" - name: Compile macOS POSIX host shims run: | @@ -34,20 +41,32 @@ jobs: > "$RUNNER_TEMP/a64-generated/a64_suyu.h" printf '/* Generated per-game floating-point helpers are unused by these host translation units. */\n' \ > "$RUNNER_TEMP/a64-generated/a64_fpx.h" - clang -target x86_64-apple-macos13.3 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ + test "$(uname -m)" = arm64 + python -c "import platform; assert platform.machine() == 'arm64', platform.machine()" + clang -target arm64-apple-macos13.3 -mcpu=apple-m1 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ - host/posix/win32_posix.c host/hos_main.c host/rx_backend.c - clang -target x86_64-apple-macos13.3 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ + host/posix/win32_posix.c host/hos_main.c host/rx_backend.c host/hos_setjmp.c \ + host/hos_thread.c host/hos_svc.c host/nn_os_sync.c + clang -target arm64-apple-macos13.3 -mcpu=apple-m1 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ host/posix/display_sdl.c host/posix/macos_stubs.c macos-builder: - # Builds a downloadable CLI and its x86-64 dependencies; it never receives or builds game data. + # Builds a native arm64 downloadable CLI and dependencies; it never receives or builds game data. runs-on: macos-latest timeout-minutes: 150 steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Verify Apple Silicon runner + run: | + test "$(uname -m)" = arm64 + python -c "import platform; assert platform.machine() == 'arm64', platform.machine()" + - uses: actions/cache@v4 with: path: build-macos/deps @@ -56,12 +75,6 @@ jobs: - name: Install dependency build tools run: brew install cmake nasm - - name: Install Rosetta - run: | - if ! arch -x86_64 /usr/bin/true; then - softwareupdate --install-rosetta --agree-to-license - fi - - name: Build builder dependencies run: bash scripts/macos/deps.sh @@ -71,11 +84,17 @@ jobs: HOOKSHOT_SDL2: ${{ github.workspace }}/build-macos/deps/sdl2/lib/libSDL2-2.0.0.dylib HOOKSHOT_FFMPEG: ${{ github.workspace }}/build-macos/deps/ffmpeg run: | - arch -x86_64 build-macos/deps/python/bin/python3 -I -B tests/test_macos_build_path.py - arch -x86_64 build-macos/deps/python/bin/python3 -I -c "import tkinter" - arch -x86_64 build-macos/deps/python/bin/python3 -I -c \ + build-macos/deps/python/bin/python3 -I -B tests/test_macos_build_path.py + build-macos/deps/python/bin/python3 -I -c "import tkinter" + build-macos/deps/python/bin/python3 -I -c \ + "import platform; assert platform.machine() == 'arm64', platform.machine()" + build-macos/deps/python/bin/python3 -I -c \ "import runpy; runpy.run_path('nx2/make_game.py')['check_macos_toolchain']()" - arch -x86_64 build-macos/deps/python/bin/python3 -I scripts/macos/check-renderer.py + python -I scripts/macos/check-renderer.py + lipo build-macos/deps/python/bin/python3 -verify_arch arm64 + lipo build-macos/deps/sdl2/lib/libSDL2-2.0.0.dylib -verify_arch arm64 + lipo build-macos/deps/wgpu-native/lib/libwgpu_native.dylib -verify_arch arm64 + for lib in build-macos/deps/ffmpeg/lib/*.a; do lipo "$lib" -verify_arch arm64; done - name: Package CLI builder run: bash scripts/macos/package-builder.sh @@ -83,11 +102,12 @@ jobs: - name: Verify packaged builder starts run: | test -d build-macos/package/HookshotBuilder/HookshotBuilder.app + lipo build-macos/package/HookshotBuilder/HookshotBuilder.app/Contents/MacOS/applet -verify_arch arm64 cd build-macos/package/HookshotBuilder ./HookshotBuilder.command --help - uses: actions/upload-artifact@v4 with: - name: HookshotBuilder-macos-x86_64 - path: build-macos/HookshotBuilder-macos-x86_64.zip + name: HookshotBuilder-macos-arm64 + path: build-macos/HookshotBuilder-macos-arm64.zip if-no-files-found: error diff --git a/docs/development.md b/docs/development.md index 45006a41..f8b4bde2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,38 +45,34 @@ background when the existing "Controller in background" option or `HOS_INPUT_BAC `HOS_INPUT_BACKGROUND=0` overrides that option. `HOS_INPUT_EXCLUSIVE=1` controls merging the player's devices while the script's existing exclusive interval is active; it is not a focus override. -## macOS (Apple Silicon, first build path) +## macOS (Apple Silicon) -The current runtime has x86-64 context-switch assembly and x86-specific synchronization code, so this first macOS -target is an x86-64 executable run under Rosetta 2, not a native arm64 executable. It uses SDL2 for the window and +The macOS builder and generated game are native arm64, with no Rosetta dependency. It uses SDL2 for the window and input, and the existing WebGPU renderer through `wgpu-native`'s Metal backend; the Vulkan backend is not built on -macOS. This provides a real Apple Silicon/Metal path while leaving native arm64 as follow-up work. -The executable targets macOS 13.3 or newer because Apple's C++20 floating-point formatting support requires it. +macOS. The executable targets macOS 13.3 or newer because Apple's C++20 floating-point formatting support requires it. -The `macOS platform` workflow uploads a `HookshotBuilder-macos-x86_64` artifact containing a command-line builder, -the Hookshot source, an x86-64 Python runtime and modules, SDL2, static FFmpeg, and Metal-enabled `wgpu-native`. +The `macOS platform` workflow uploads a `HookshotBuilder-macos-arm64` artifact containing a command-line builder, +the Hookshot source, a native arm64 Python runtime and modules, SDL2, static FFmpeg, and Metal-enabled `wgpu-native`. It does not contain game data or a built game. Download and unpack it on the target Mac; Xcode Command Line Tools -and Rosetta 2 must be installed there. Double-click `HookshotBuilder.app` to choose your own `.nup` dump and output -folder, or use the command-line launcher: +must be installed there. Double-click `HookshotBuilder.app` to choose your own `.nup` dump and output folder, or +use the command-line launcher: ```sh ./HookshotBuilder/HookshotBuilder.command --nup /path/to/game.nup --out /path/to/game-folder --jobs 4 ``` -The package is x86-64 under Rosetta on Apple Silicon, not native arm64. For development outside the artifact, use -x86-64 builds of SDL2 2.26 or newer and `wgpu-native`; Homebrew packages from the native arm64 prefix cannot be linked -into this build. `wgpu-native` must include its matching `include/webgpu/webgpu.h` and `lib/libwgpu_native.dylib`, -built with Metal support. Set `HOOKSHOT_FFMPEG` to the static FFmpeg prefix (or provide FFmpeg through -`pkg-config`) and build from an x86-64 shell, for example: +For development outside the artifact, use arm64 builds of SDL2 2.26 or newer and `wgpu-native`. `wgpu-native` must +include its matching `include/webgpu/webgpu.h` and `lib/libwgpu_native.dylib`, built with Metal support. Set +`HOOKSHOT_FFMPEG` to the static FFmpeg prefix (or provide FFmpeg through `pkg-config`) and build natively: ```sh -export WGPU_NATIVE=/path/to/x86_64-wgpu-native -export HOOKSHOT_SDL2=/path/to/x86_64/libSDL2-2.0.0.dylib -export HOOKSHOT_FFMPEG=/path/to/x86_64/ffmpeg -arch -x86_64 python3 -I nx2/make_game.py --nup /path/to/game.nup --out /path/to/game-folder --jobs 4 +export WGPU_NATIVE=/path/to/arm64-wgpu-native +export HOOKSHOT_SDL2=/path/to/arm64/libSDL2-2.0.0.dylib +export HOOKSHOT_FFMPEG=/path/to/arm64/ffmpeg +python3 -I nx2/make_game.py --nup /path/to/game.nup --out /path/to/game-folder --jobs 4 ``` -The normal builder command validates the x86-64 compiler, SDL2, FFmpeg and `wgpu-native` before lifting the game, +The normal builder command validates the arm64 compiler, SDL2, FFmpeg and `wgpu-native` before lifting the game, then places `hookshot`, `libSDL2-2.0.0.dylib` and `libwgpu_native.dylib` beside `image.bin` in the output folder; FFmpeg is statically linked when `HOOKSHOT_FFMPEG` points to the packaged static libraries. The macOS FFmpeg build uses its native AV1 decoder to avoid compiling an additional dav1d dependency in CI. diff --git a/gpu/render/rx_core.cpp b/gpu/render/rx_core.cpp index cc17e019..b4635239 100644 --- a/gpu/render/rx_core.cpp +++ b/gpu/render/rx_core.cpp @@ -1370,6 +1370,7 @@ bool stats_on = false; int64_t clock_ns() { if (!stats_on) return 0; +#if defined(__x86_64__) || defined(_M_X64) static const double ns_per_tick = [] { const int64_t t0 = hos_now_ns(); const uint64_t c0 = __rdtsc(); @@ -1379,6 +1380,9 @@ int64_t clock_ns() { return c1 > c0 ? double(t1 - t0) / double(c1 - c0) : 0.0; }(); return (int64_t)(double(__rdtsc()) * ns_per_tick); +#else + return hos_now_ns(); +#endif } bool gpu_read(uint64_t va, void* dst, uint64_t n) { diff --git a/gpu/render/rx_draw.cpp b/gpu/render/rx_draw.cpp index 3e3729f5..e487299d 100644 --- a/gpu/render/rx_draw.cpp +++ b/gpu/render/rx_draw.cpp @@ -3473,7 +3473,7 @@ void nvn_draw(NvnState& s, const NvnDraw& d) { // the frame rate depends on the replay's time (what making it faster could give) if (static const int64_t spin = getenv("RX_DEBUG_DRAW_SPIN_NS") ? atoll(getenv("RX_DEBUG_DRAW_SPIN_NS")) : 0; spin) { const int64_t until = clock_ns() + spin; - while (clock_ns() < until) _mm_pause(); + while (clock_ns() < until) cpu_relax(); } struct DrawTimer { int64_t t0 = clock_ns(); diff --git a/gpu/render/rx_internal.h b/gpu/render/rx_internal.h index 1230099c..de36c4db 100644 --- a/gpu/render/rx_internal.h +++ b/gpu/render/rx_internal.h @@ -11,12 +11,23 @@ #include #include #include +#include #if defined(_MSC_VER) #include #elif defined(__x86_64__) #include #endif +inline void cpu_relax() { +#if defined(__aarch64__) || defined(__arm64__) || defined(_M_ARM64) + __asm__ volatile("yield" ::: "memory"); +#elif defined(__x86_64__) || defined(_M_X64) + _mm_pause(); +#else + std::this_thread::yield(); +#endif +} + #include #ifdef __EMSCRIPTEN__ // The browser build (the browser's WebGPU through emdawnwebgpu): wgpu-native's extras stand in here. Every WebGPU diff --git a/host/hos_setjmp.c b/host/hos_setjmp.c index ec01bff4..2316aab5 100644 --- a/host/hos_setjmp.c +++ b/host/hos_setjmp.c @@ -19,16 +19,15 @@ recomp_sj_longjmp brings the host back to that caller, which runs the owner's code from the return address: what the owner would have done, ending in its own return to the caller. - (recomp_sj_setjmp / recomp_sj_longjmp are this file's own, in assembly: every Win64 callee-saved register, xmm6-15 - with them, and the stack pointer and return address. clang's __builtin_setjmp, which this used first, saves the - frame address, which on Win64 sits 128 bytes below the frame pointer clang addresses the frame's spill slots with, - and its longjmp set rbp to it: the caller's reloads of c and the slot read garbage, and the text box thread - called a stack address after its first longjmp.) */ + (recomp_sj_setjmp / recomp_sj_longjmp are this file's own, in assembly: each host ABI's callee-saved state, stack + pointer and return address. clang's __builtin_setjmp, which this used first, saves the frame address, which on + Win64 sits 128 bytes below the frame pointer clang addresses the frame's spill slots with, and its longjmp set + rbp to it: the caller's reloads of c and the slot read garbage, and the text box thread called a stack address + after its first longjmp.) */ #include "hos.h" #include #include #include -#include #include #define DEPTH 256 @@ -100,6 +99,54 @@ __attribute__((naked, noreturn)) static void recomp_sj_longjmp(void **buf, int v "jmpq *72(%rcx)\n\t"); } +#elif defined(__aarch64__) || defined(__arm64__) +/* AAPCS64: x19-x29, SP, LR and d8-d15 are callee-saved. FPCR/FPSR keep the host FP environment intact. */ +__attribute__((naked, returns_twice)) int recomp_sj_setjmp(void **buf) { + __asm__ volatile( + "stp x19, x20, [x0, #0]\n\t" + "stp x21, x22, [x0, #16]\n\t" + "stp x23, x24, [x0, #32]\n\t" + "stp x25, x26, [x0, #48]\n\t" + "stp x27, x28, [x0, #64]\n\t" + "stp x29, x30, [x0, #80]\n\t" + "mov x9, sp\n\t" + "str x9, [x0, #96]\n\t" + "stp d8, d9, [x0, #104]\n\t" + "stp d10, d11, [x0, #120]\n\t" + "stp d12, d13, [x0, #136]\n\t" + "stp d14, d15, [x0, #152]\n\t" + "mrs x9, fpcr\n\t" + "str x9, [x0, #168]\n\t" + "mrs x9, fpsr\n\t" + "str x9, [x0, #176]\n\t" + "mov w0, #0\n\t" + "ret\n\t"); +} + +__attribute__((naked, noreturn)) static void recomp_sj_longjmp(void **buf, int value) { + __asm__ volatile( + "ldp x19, x20, [x0, #0]\n\t" + "ldp x21, x22, [x0, #16]\n\t" + "ldp x23, x24, [x0, #32]\n\t" + "ldp x25, x26, [x0, #48]\n\t" + "ldp x27, x28, [x0, #64]\n\t" + "ldp x29, x30, [x0, #80]\n\t" + "ldp d8, d9, [x0, #104]\n\t" + "ldp d10, d11, [x0, #120]\n\t" + "ldp d12, d13, [x0, #136]\n\t" + "ldp d14, d15, [x0, #152]\n\t" + "ldr x9, [x0, #168]\n\t" + "msr fpcr, x9\n\t" + "ldr x9, [x0, #176]\n\t" + "msr fpsr, x9\n\t" + "ldr x9, [x0, #96]\n\t" + "mov sp, x9\n\t" + "cmp w1, #0\n\t" + "mov w0, #1\n\t" + "csel w0, w1, w0, ne\n\t" + "br x30\n\t"); +} + #else /* (linux) the System V ABI's: the buffer as above, its callee-saved registers only (rbx rbp r12-r15: rdi, rsi and the xmm registers are the caller's to keep there), buf in rdi and the value in esi */ @@ -169,8 +216,8 @@ void hos_setjmp_return(Ctx *c, uint64_t target) { for (int i = stack->depth - 1; i >= 0; i--) { SjSlot *s = &stack->slot[i]; if (s->rets[0] != target && s->rets[1] != target && s->rets[2] != target && s->rets[3] != target) continue; - static volatile LONG logged; - if (InterlockedIncrement(&logged) <= 4) + static uint32_t logged; + if (__atomic_fetch_add(&logged, 1, __ATOMIC_RELAXED) < 4) hos_log("longjmp to %#llx (value %llu, %d slots above dropped)", (unsigned long long)target, (unsigned long long)c->x[0], stack->depth - 1 - i); s->target = target; diff --git a/host/hos_thread.c b/host/hos_thread.c index 1f21dbb5..b7350959 100644 --- a/host/hos_thread.c +++ b/host/hos_thread.c @@ -12,7 +12,6 @@ #ifndef CREATE_WAITABLE_TIMER_HIGH_RESOLUTION /* (older Windows SDKs lack it: Windows 10 1803 and later take it) */ #define CREATE_WAITABLE_TIMER_HIGH_RESOLUTION 0x00000002 #endif -#include #ifdef __EMSCRIPTEN__ #include #include @@ -808,7 +807,7 @@ static int present_poll(Ctx *c, int64_t ns) { const int64_t end = hos_now_ns() + ns; while (v != 1 && hos_now_ns() < end) { SwitchToThread(); - for (int i = 0; i < 64; i++) _mm_pause(); + for (int i = 0; i < 64; i++) YieldProcessor(); hos_nvn_event_value(ev, &v); } if (away) hos_vcore_back(); diff --git a/host/posix/windows.h b/host/posix/windows.h index b5f04c43..98963e42 100644 --- a/host/posix/windows.h +++ b/host/posix/windows.h @@ -164,7 +164,7 @@ void SetLastError(DWORD e); #define InterlockedCompareExchangePointer InterlockedCompareExchange #define MemoryBarrier() __atomic_thread_fence(__ATOMIC_SEQ_CST) #define _ReadWriteBarrier() __atomic_signal_fence(__ATOMIC_SEQ_CST) -#if defined(__aarch64__) +#if defined(__aarch64__) || defined(__arm64__) #define YieldProcessor() __builtin_arm_yield() #else #define YieldProcessor() __builtin_ia32_pause() diff --git a/nx2/build.py b/nx2/build.py index 2fe47af9..a7888ab0 100644 --- a/nx2/build.py +++ b/nx2/build.py @@ -48,7 +48,7 @@ LINUX = sys.platform.startswith("linux") MACOS = sys.platform == "darwin" POSIX_HOST = LINUX or MACOS -# Linux uses Vulkan. macOS targets x86-64 on Apple Silicon through Rosetta and uses WebGPU's Metal backend. +# Linux uses Vulkan. macOS builds native arm64 and uses WebGPU's Metal backend. POSIX = os.path.join(kit.HERE, "..", "host", "posix") SYSROOT = os.environ.get("HOOKSHOT_SYSROOT", "") EXE = "hookshot" if POSIX_HOST else "hookshot.exe" @@ -57,7 +57,7 @@ def target(): """toolchain.native_target(), with the sysroot on Linux""" - target = ["--target=x86_64-apple-macos13.3"] if MACOS else toolchain.native_target() + target = toolchain.macos_target_flags() if MACOS else toolchain.native_target() if MACOS: target += toolchain.macos_sysroot_flags() return target + (["--sysroot=" + SYSROOT] if LINUX and SYSROOT else []) @@ -258,16 +258,18 @@ def host_march(): return "x86-64-v3" -MARCH = os.environ.get("RX_MARCH") or ("x86-64" if MACOS else host_march()) +MARCH = os.environ.get("RX_MARCH") or ("apple-m1" if MACOS else host_march()) if not MACOS and MARCH != "x86-64-v3": print("build: this CPU has no AVX2: the game is built for %s (a little slower)" % MARCH, flush=True) -CPU = ["-march=" + MARCH, "-ffp-contract=off"] +CPU = [("-mcpu=" if MACOS else "-march=") + MARCH, "-ffp-contract=off"] +ATOMIC = [] if MACOS else ["-mcx16"] def flags(gen, rt64, opt, polls): return target() + CPU + [ "-I" + rt64, "-I" + gen, "-I" + os.path.join(kit.KIT, "rt"), "-I" + NX2_RT, "-Wno-everything", - "-fno-strict-aliasing", "-mcx16", "-" + opt, "-g0", "-mllvm", "-rotation-max-header-size=0", + "-fno-strict-aliasing"] + ATOMIC + [ + "-" + opt, "-g0", "-mllvm", "-rotation-max-header-size=0", "-ffunction-sections", "-mllvm", "-capture-tracking-max-uses-to-explore=5000"] + \ (["-DA64_POLLING"] if polls else []) + WRITE_TRACK @@ -418,7 +420,8 @@ def main(): shim_deps if os.path.dirname(s) == NX2_RT else deps) for s in srcs] # the runtime: ordinary C at -O2, with its own headers (any host/*.h change rebuilds all of it) host_fl = target() + CPU + (["-I" + POSIX, "-D_GNU_SOURCE"] if POSIX_HOST else []) + \ - ["-I" + HOST, "-I" + rt64, "-I" + gen, "-Wno-everything", "-O2", "-g0", "-mcx16", "-DA64_POLLING"] + WRITE_TRACK + ["-I" + HOST, "-I" + rt64, "-I" + gen, "-Wno-everything", "-O2", "-g0"] + ATOMIC + \ + ["-DA64_POLLING"] + WRITE_TRACK host_deps = deps + b"".join(open(os.path.join(HOST, f), "rb").read() for f in sorted(os.listdir(HOST)) if f.endswith(".h")) if POSIX_HOST: diff --git a/nx2/make_game.py b/nx2/make_game.py index 69c13666..166ceb8b 100644 --- a/nx2/make_game.py +++ b/nx2/make_game.py @@ -17,6 +17,7 @@ import argparse import filecmp import os +import platform import re import shutil import stat @@ -288,16 +289,18 @@ def check_linux_compiler(): def check_macos_toolchain(): - """Validate the x86-64 Rosetta compiler and graphics/audio libraries before the long lift.""" + """Validate the native Apple Silicon compiler and graphics/audio libraries before the long lift.""" wgpu = os.environ.get("WGPU_NATIVE", os.path.join(os.path.expanduser("~"), "wgpu-native")) sdl = os.environ.get("HOOKSHOT_SDL2", "") ffmpeg = os.environ.get("HOOKSHOT_FFMPEG", "") + if platform.machine() not in ("arm64", "aarch64"): + fail("The macOS builder requires a native Apple Silicon (arm64) process; Rosetta is not supported.") if not os.path.isfile(os.path.join(wgpu, "include", "webgpu", "webgpu.h")): - fail("macOS needs x86-64 wgpu-native with Metal support: set WGPU_NATIVE to its package directory.") + fail("macOS needs arm64 wgpu-native with Metal support: set WGPU_NATIVE to its package directory.") if not os.path.isfile(os.path.join(wgpu, "lib", "libwgpu_native.dylib")): fail("macOS needs libwgpu_native.dylib in WGPU_NATIVE/lib.") if not sdl or not os.path.isfile(sdl): - fail("macOS needs an x86-64 SDL2 dylib: set HOOKSHOT_SDL2 to libSDL2-2.0.0.dylib.") + fail("macOS needs an arm64 SDL2 dylib: set HOOKSHOT_SDL2 to libSDL2-2.0.0.dylib.") if not shutil.which("lipo"): fail("Install Xcode Command Line Tools to verify macOS dependency architectures.") libraries = [sdl, os.path.join(wgpu, "lib", "libwgpu_native.dylib")] @@ -312,10 +315,10 @@ def check_macos_toolchain(): fail("macOS needs the Hookshot static FFmpeg package; missing: %s" % ", ".join(missing)) libraries += [os.path.join(ffmpeg, "lib", l) for l in static_libs] for library in libraries: - r = subprocess.run(["lipo", library, "-verify_arch", "x86_64"], + r = subprocess.run(["lipo", library, "-verify_arch", "arm64"], capture_output=True, text=True, encoding="utf-8", errors="replace") if r.returncode: - fail("macOS dependency must contain x86_64 code for Rosetta: %s" % library) + fail("macOS dependency must contain native arm64 code: %s" % library) sys.path.insert(0, os.path.join(ROOT, "third_party", "a64-kit", "tools")) import toolchain cc = toolchain.find("clang") @@ -325,19 +328,19 @@ def check_macos_toolchain(): src, exe = os.path.join(d, "probe.c"), os.path.join(d, "probe") with open(src, "w", encoding="utf-8") as f: f.write("int main(void) { return 0; }\n") - r = subprocess.run([cc, "--target=x86_64-apple-macos13.3"] + toolchain.macos_sysroot_flags() + + r = subprocess.run([cc] + toolchain.macos_target_flags() + toolchain.macos_sysroot_flags() + [src, "-o", exe], capture_output=True, text=True, encoding="utf-8", errors="replace") if r.returncode: - fail("Xcode Command Line Tools could not link an x86-64 macOS test program: %s" + fail("Xcode Command Line Tools could not link a native arm64 macOS test program: %s" % failure_reason((r.stdout + r.stderr).splitlines())) if not ffmpeg: if not shutil.which("pkg-config"): - fail("Install pkg-config and x86-64 FFmpeg development libraries for the macOS build.") + fail("Install pkg-config and arm64 FFmpeg development libraries for the macOS build.") r = subprocess.run(["pkg-config", "--exists", "libavcodec", "libswscale", "libswresample", "libavutil"], capture_output=True, text=True, encoding="utf-8", errors="replace") if r.returncode: - fail("macOS needs x86-64 FFmpeg development libraries visible through pkg-config (set PKG_CONFIG_PATH).") + fail("macOS needs arm64 FFmpeg development libraries visible through pkg-config (set PKG_CONFIG_PATH).") def free_memory_gb(): diff --git a/scripts/macos/Builder.applescript b/scripts/macos/Builder.applescript index f9f9babd..b43e391c 100644 --- a/scripts/macos/Builder.applescript +++ b/scripts/macos/Builder.applescript @@ -2,6 +2,6 @@ on run set appRoot to POSIX path of (path to me) set tools to appRoot & "Contents/Resources/tools/" set source to tools & "source" - set commandLine to "export HOOKSHOT_TOOLS=" & quoted form of source & " && export WGPU_NATIVE=" & quoted form of (tools & "wgpu-native") & " && export HOOKSHOT_SDL2=" & quoted form of (tools & "sdl2/lib/libSDL2-2.0.0.dylib") & " && export HOOKSHOT_FFMPEG=" & quoted form of (tools & "ffmpeg") & " && export RX_BACKEND=metal && exec arch -x86_64 " & quoted form of (tools & "python/bin/python3") & " -I " & quoted form of (source & "/builder/linux/hookshot_builder.py") + set commandLine to "export HOOKSHOT_TOOLS=" & quoted form of source & " && export WGPU_NATIVE=" & quoted form of (tools & "wgpu-native") & " && export HOOKSHOT_SDL2=" & quoted form of (tools & "sdl2/lib/libSDL2-2.0.0.dylib") & " && export HOOKSHOT_FFMPEG=" & quoted form of (tools & "ffmpeg") & " && export RX_BACKEND=metal && exec " & quoted form of (tools & "python/bin/python3") & " -I " & quoted form of (source & "/builder/linux/hookshot_builder.py") do shell script commandLine end run diff --git a/scripts/macos/README.txt b/scripts/macos/README.txt index 98bee379..34179b23 100644 --- a/scripts/macos/README.txt +++ b/scripts/macos/README.txt @@ -1,12 +1,12 @@ Hookshot Builder for macOS (Apple Silicon) -This package includes a macOS app and an x86-64 command-line builder that run -under Rosetta 2 and use WebGPU's Metal backend. It includes Hookshot source, Python +This package includes a native arm64 macOS app and command-line builder that use +WebGPU's Metal backend. It includes Hookshot source, Python runtime and modules, SDL2, static FFmpeg libraries, and wgpu-native. It does not include game data or a built game. You must provide your own -legally obtained .nup dump. Xcode Command Line Tools and Rosetta 2 are required -on the Mac where you build. +legally obtained .nup dump. Xcode Command Line Tools are required on the Mac +where you build. The generated executable requires macOS 13.3 or newer. Double-click HookshotBuilder.app to open the Hookshot Builder interface. It diff --git a/scripts/macos/check-arm64-translation.py b/scripts/macos/check-arm64-translation.py new file mode 100644 index 00000000..232b92f6 --- /dev/null +++ b/scripts/macos/check-arm64-translation.py @@ -0,0 +1,21 @@ +"""Verify native arm64 acquire/release forms become fenced translated operations.""" +import os +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(ROOT, "third_party", "a64-kit", "lift")) +import a64emit + +instructions = [ + (0x1000, 0xC85FFC20), # LDAXR X0, [X1] + (0x1004, 0xC802FC20), # STLXR W2, X0, [X1] + (0x1008, 0xC8608440), # LDAXP X0, X1, [X2] + (0x100C, 0xC82394C4), # STLXP W3, X4, X5, [X6] +] +texts, flags = a64emit.translate(instructions) +if any(flags): + raise SystemExit("arm64 acquire/release instruction was not translated") +for index, ordering in ((0, "ACQUIRE"), (1, "RELEASE"), (2, "ACQUIRE"), (3, "RELEASE")): + if "__atomic_thread_fence(__ATOMIC_%s)" % ordering not in texts[index]: + raise SystemExit("missing %s fence in translated instruction %#x" % (ordering, instructions[index][1])) +print("arm64 exclusive acquire/release instructions compile to fenced translated operations.") diff --git a/scripts/macos/check-renderer.py b/scripts/macos/check-renderer.py index 900583fd..2138697d 100644 --- a/scripts/macos/check-renderer.py +++ b/scripts/macos/check-renderer.py @@ -22,8 +22,7 @@ def main(): with open(os.path.join(generated, name), "w", encoding="utf-8") as f: f.write("/* Generated game-specific helpers are unused by renderer sources. */\n") flags = [ - "--target=x86_64-apple-macos13.3", "-std=c++20", "-O2", "-g0", "-DNDEBUG", "-Wno-everything", - *toolchain.macos_sysroot_flags(), + *build.target(), "-mcpu=apple-m1", "-std=c++20", "-g0", "-DNDEBUG", "-Wno-everything", "-DA64_WRITE_TRACK", "-I" + os.path.join(gpu, "compat"), "-I" + os.path.join(gpu, "third_party", "suyu"), diff --git a/scripts/macos/deps.sh b/scripts/macos/deps.sh index 4e7ada89..d295a02f 100644 --- a/scripts/macos/deps.sh +++ b/scripts/macos/deps.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Build the x86-64 libraries and Python runtime used by Hookshot's Rosetta builder. +# Build the native arm64 libraries and Python runtime used by Hookshot's macOS builder. set -euo pipefail cd "$(dirname "$0")/../.." @@ -13,7 +13,7 @@ PY_VER=3.12.16 SDL_VER=2.32.10 FFMPEG_VER=9.0.2 WGPU_VER=v29.0.1.1 -MIN_MACOS=10.15 +MIN_MACOS=13.3 fetch() { local url=$1 file=$2 @@ -21,11 +21,11 @@ fetch() { [ -s "$DL/$file" ] || mv -f "$DL/$file.part" "$DL/$file" } -# ---- x86-64 Python and the builder's modules ---- +# ---- arm64 Python and the builder's modules ---- if [ ! -x "$DEPS/python/bin/python3" ]; then - fetch "https://github.com/astral-sh/python-build-standalone/releases/download/$PY_TAG/cpython-$PY_VER%2B$PY_TAG-x86_64-apple-darwin-install_only.tar.gz" python.tar.gz + fetch "https://github.com/astral-sh/python-build-standalone/releases/download/$PY_TAG/cpython-$PY_VER%2B$PY_TAG-aarch64-apple-darwin-install_only.tar.gz" python.tar.gz tar -xzf "$DL/python.tar.gz" -C "$DEPS" - arch -x86_64 "$DEPS/python/bin/python3" -m pip install --no-cache-dir --only-binary :all: \ + "$DEPS/python/bin/python3" -m pip install --no-cache-dir --only-binary :all: \ --disable-pip-version-check -q \ "lz4==4.4.5" "numpy==2.5.4" "capstone==5.0.9" "unicorn==2.1.4" "zstandard==0.25.0" rm -rf "$DEPS/python/lib/python3.12/test" "$DEPS/python/lib/python3.12/idlelib" \ @@ -33,14 +33,14 @@ if [ ! -x "$DEPS/python/bin/python3" ]; then find "$DEPS/python" -name __pycache__ -prune -exec rm -rf {} + fi -# ---- SDL2, built as an x86-64 dylib ---- +# ---- SDL2, built as a native arm64 dylib ---- if [ ! -f "$DEPS/sdl2/lib/libSDL2-2.0.0.dylib" ]; then fetch "https://github.com/libsdl-org/SDL/releases/download/release-$SDL_VER/SDL2-$SDL_VER.tar.gz" sdl2.tar.gz rm -rf "$DEPS/src-sdl2" "$DEPS/sdl2" mkdir -p "$DEPS/src-sdl2" tar -xzf "$DL/sdl2.tar.gz" -C "$DEPS/src-sdl2" --strip-components=1 cmake -S "$DEPS/src-sdl2" -B "$DEPS/src-sdl2/build" -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_OSX_ARCHITECTURES=x86_64 -DCMAKE_OSX_DEPLOYMENT_TARGET="$MIN_MACOS" \ + -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET="$MIN_MACOS" \ -DSDL_SHARED=ON -DSDL_STATIC=OFF -DSDL_TEST=OFF cmake --build "$DEPS/src-sdl2/build" --parallel "$JOBS" cmake --install "$DEPS/src-sdl2/build" --prefix "$DEPS/sdl2" @@ -57,9 +57,9 @@ if [ ! -f "$P/lib/libavcodec.a" ]; then tar -xJf "$DL/ffmpeg.tar.xz" -C "$DEPS/src-ffmpeg" --strip-components=1 ( cd "$DEPS/src-ffmpeg" - CFLAGS="-arch x86_64 -mmacosx-version-min=$MIN_MACOS -fPIC" \ - LDFLAGS="-arch x86_64 -mmacosx-version-min=$MIN_MACOS" \ - ./configure --prefix="$P" --arch=x86_64 --target-os=darwin \ + CFLAGS="-arch arm64 -mcpu=apple-m1 -mmacosx-version-min=$MIN_MACOS -fPIC" \ + LDFLAGS="-arch arm64 -mmacosx-version-min=$MIN_MACOS" \ + ./configure --prefix="$P" --arch=aarch64 --cpu=apple-m1 --target-os=darwin \ --cc=clang --enable-static --disable-shared --enable-pic \ --disable-programs --disable-doc --disable-network --disable-autodetect \ --disable-everything --disable-avformat --disable-avfilter --disable-avdevice \ @@ -70,19 +70,19 @@ if [ ! -f "$P/lib/libavcodec.a" ]; then ) rm -rf "$DEPS/src-ffmpeg" "$P/share" for lib in libavcodec.a libswscale.a libswresample.a libavutil.a; do - lipo "$P/lib/$lib" -verify_arch x86_64 + lipo "$P/lib/$lib" -verify_arch arm64 done fi -# ---- Metal-enabled x86-64 wgpu-native release ---- +# ---- Metal-enabled arm64 wgpu-native release ---- if [ ! -f "$DEPS/wgpu-native/lib/libwgpu_native.dylib" ]; then - fetch "https://github.com/gfx-rs/wgpu-native/releases/download/$WGPU_VER/wgpu-macos-x86_64-release.zip" wgpu.zip + fetch "https://github.com/gfx-rs/wgpu-native/releases/download/$WGPU_VER/wgpu-macos-aarch64-release.zip" wgpu.zip rm -rf "$DEPS/wgpu-native" mkdir -p "$DEPS/wgpu-native" unzip -q "$DL/wgpu.zip" -d "$DEPS/wgpu-native" fi -lipo "$DEPS/sdl2/lib/libSDL2-2.0.0.dylib" -verify_arch x86_64 -lipo "$DEPS/wgpu-native/lib/libwgpu_native.dylib" -verify_arch x86_64 +lipo "$DEPS/sdl2/lib/libSDL2-2.0.0.dylib" -verify_arch arm64 +lipo "$DEPS/wgpu-native/lib/libwgpu_native.dylib" -verify_arch arm64 # Keep the notices for the binary dependencies in the downloadable package. L=$DEPS/licenses diff --git a/scripts/macos/package-builder.sh b/scripts/macos/package-builder.sh index 09b15f1e..19dd8bf5 100644 --- a/scripts/macos/package-builder.sh +++ b/scripts/macos/package-builder.sh @@ -13,6 +13,12 @@ test -x "$DEPS/python/bin/python3" test -f "$DEPS/sdl2/lib/libSDL2-2.0.0.dylib" test -f "$DEPS/ffmpeg/lib/libavcodec.a" test -f "$DEPS/wgpu-native/lib/libwgpu_native.dylib" +command -v lipo >/dev/null +for binary in "$DEPS/python/bin/python3" "$DEPS/sdl2/lib/libSDL2-2.0.0.dylib" \ + "$DEPS/wgpu-native/lib/libwgpu_native.dylib" "$DEPS/ffmpeg/lib/libavcodec.a" \ + "$DEPS/ffmpeg/lib/libswscale.a" "$DEPS/ffmpeg/lib/libswresample.a" "$DEPS/ffmpeg/lib/libavutil.a"; do + lipo "$binary" -verify_arch arm64 +done rm -rf "$STAGE" mkdir -p "$SOURCE" "$ROOT/tools" git archive --format=tar HEAD | tar -xf - -C "$SOURCE" @@ -28,7 +34,7 @@ export WGPU_NATIVE="$TOOLS/wgpu-native" export HOOKSHOT_SDL2="$TOOLS/sdl2/lib/libSDL2-2.0.0.dylib" export HOOKSHOT_FFMPEG="$TOOLS/ffmpeg" export RX_BACKEND=metal -exec arch -x86_64 "$TOOLS/python/bin/python3" -I "$TOOLS/source/nx2/make_game.py" "$@" +exec "$TOOLS/python/bin/python3" -I "$TOOLS/source/nx2/make_game.py" "$@" EOF chmod +x "$ROOT/HookshotBuilder.command" cp scripts/macos/README.txt "$ROOT/README.txt" @@ -38,8 +44,9 @@ osacompile -o "$APP" scripts/macos/Builder.applescript mkdir -p "$APP/Contents/Resources" ln -s ../../../tools "$APP/Contents/Resources/tools" test -x "$APP/Contents/MacOS/applet" +lipo "$APP/Contents/MacOS/applet" -verify_arch arm64 mkdir -p "$OUT" cd "$STAGE" -ditto -c -k --sequesterRsrc --keepParent HookshotBuilder "$OUT/HookshotBuilder-macos-x86_64.zip" -echo "Created $OUT/HookshotBuilder-macos-x86_64.zip" +ditto -c -k --sequesterRsrc --keepParent HookshotBuilder "$OUT/HookshotBuilder-macos-arm64.zip" +echo "Created $OUT/HookshotBuilder-macos-arm64.zip" diff --git a/tests/test_macos_build_path.py b/tests/test_macos_build_path.py index 23797fb8..6d0a4cc0 100644 --- a/tests/test_macos_build_path.py +++ b/tests/test_macos_build_path.py @@ -27,14 +27,14 @@ def test_macos_toolchain_uses_the_xcrun_sdk(self): subprocess.run([sys.executable, "-I", "-c", script, str(source)], check=True, capture_output=True, text=True) - def test_selects_x86_64_posix_and_metal_build_target(self): + def test_selects_native_arm64_posix_and_metal_build_target(self): source = ROOT / "nx2" / "build.py" script = ( "import json, runpy, sys; " "sys.platform = 'darwin'; " "b = runpy.run_path(sys.argv[1]); " "b['toolchain'].macos_sysroot_flags = lambda: ['-isysroot', '/MacOSX.sdk']; " - "print(json.dumps({k: b[k] for k in ('WINDOWS', 'LINUX', 'MACOS', 'POSIX_HOST', 'EXE', 'MARCH')} " + "print(json.dumps({k: b[k] for k in ('WINDOWS', 'LINUX', 'MACOS', 'POSIX_HOST', 'EXE', 'MARCH', 'CPU')} " "| {'target': b['target']()}))" ) result = subprocess.run([sys.executable, "-I", "-c", script, str(source)], @@ -46,8 +46,9 @@ def test_selects_x86_64_posix_and_metal_build_target(self): "MACOS": True, "POSIX_HOST": True, "EXE": "hookshot", - "MARCH": "x86-64", - "target": ["--target=x86_64-apple-macos13.3", "-isysroot", "/MacOSX.sdk"], + "MARCH": "apple-m1", + "CPU": ["-mcpu=apple-m1", "-ffp-contract=off"], + "target": ["--target=arm64-apple-macos13.3", "-isysroot", "/MacOSX.sdk"], }) def test_game_builder_selects_macos_bundle_files(self): @@ -89,15 +90,17 @@ def run(command, **kwargs): commands.append(command) return types.SimpleNamespace(returncode=0, stdout="", stderr="") toolchain = types.SimpleNamespace(find=lambda name: sys.executable, + macos_target_flags=lambda: ["--target=arm64-apple-macos13.3"], macos_sysroot_flags=lambda: ["-isysroot", "/MacOSX.sdk"]) with patch.dict(os.environ, {"WGPU_NATIVE": str(wgpu), "HOOKSHOT_SDL2": str(sdl), "HOOKSHOT_FFMPEG": ""}), \ patch("shutil.which", return_value="tool"), \ patch("subprocess.run", side_effect=run), \ + patch("platform.machine", return_value="arm64"), \ patch.dict(sys.modules, {"toolchain": toolchain}): builder["check_macos_toolchain"]() assert [c for c in commands if c[0] == "lipo"] == [ - ["lipo", str(sdl), "-verify_arch", "x86_64"], - ["lipo", str(library), "-verify_arch", "x86_64"], + ["lipo", str(sdl), "-verify_arch", "arm64"], + ["lipo", str(library), "-verify_arch", "arm64"], ] """ subprocess.run([sys.executable, "-I", "-c", script, str(source)], check=True, @@ -131,15 +134,17 @@ def run(command, **kwargs): commands.append(command) return types.SimpleNamespace(returncode=0, stdout="", stderr="") toolchain = types.SimpleNamespace(find=lambda name: sys.executable, + macos_target_flags=lambda: ["--target=arm64-apple-macos13.3"], macos_sysroot_flags=lambda: ["-isysroot", "/MacOSX.sdk"]) env = {"WGPU_NATIVE": str(wgpu), "HOOKSHOT_SDL2": str(sdl), "HOOKSHOT_FFMPEG": str(ffmpeg)} with patch.dict(os.environ, env), patch("shutil.which", return_value="tool"), \ - patch("subprocess.run", side_effect=run), patch.dict(sys.modules, {"toolchain": toolchain}): + patch("subprocess.run", side_effect=run), patch("platform.machine", return_value="arm64"), \ + patch.dict(sys.modules, {"toolchain": toolchain}): builder["check_macos_toolchain"]() assert [c for c in commands if c[0] == "lipo"] == [ - ["lipo", str(sdl), "-verify_arch", "x86_64"], - ["lipo", str(wgpu / "lib/libwgpu_native.dylib"), "-verify_arch", "x86_64"], - ] + [["lipo", str(ffmpeg / "lib" / lib), "-verify_arch", "x86_64"] for lib in libs] + ["lipo", str(sdl), "-verify_arch", "arm64"], + ["lipo", str(wgpu / "lib/libwgpu_native.dylib"), "-verify_arch", "arm64"], + ] + [["lipo", str(ffmpeg / "lib" / lib), "-verify_arch", "arm64"] for lib in libs] assert not any(c[0] == "pkg-config" for c in commands) """ subprocess.run([sys.executable, "-I", "-c", script, str(source)], check=True, @@ -149,6 +154,14 @@ def test_macos_runtime_supplies_non_linux_profiler_entry_point(self): source = (ROOT / "host" / "posix" / "macos_stubs.c").read_text(encoding="utf-8") self.assertIn("void hos_prof_start(void)", source) + def test_native_arm64_path_has_host_context_and_memory_ordering_support(self): + context = (ROOT / "host" / "hos_setjmp.c").read_text(encoding="utf-8") + lifter = (ROOT / "third_party" / "a64-kit" / "lift" / "suyu" / "arm64_to_c.h").read_text(encoding="utf-8") + self.assertIn("#elif defined(__aarch64__) || defined(__arm64__)", context) + self.assertIn("stp x19, x20", context) + self.assertIn("__atomic_thread_fence(__ATOMIC_ACQUIRE)", lifter) + self.assertIn("__atomic_thread_fence(__ATOMIC_RELEASE)", lifter) + def test_macos_renderer_uses_sdl_pixel_dimensions(self): renderer = (ROOT / "gpu" / "render" / "rx_core.cpp").read_text(encoding="utf-8") window = (ROOT / "host" / "posix" / "display_sdl.c").read_text(encoding="utf-8") diff --git a/third_party/a64-kit/lift/suyu/arm64_to_c.h b/third_party/a64-kit/lift/suyu/arm64_to_c.h index 1996ad9f..66acf50e 100644 --- a/third_party/a64-kit/lift/suyu/arm64_to_c.h +++ b/third_party/a64-kit/lift/suyu/arm64_to_c.h @@ -1629,10 +1629,9 @@ inline bool Translate(u32 i, u64 pc, std::string& out, bool* unhandled = nullptr // Load/store exclusive and acquire/release. // - // The non-exclusive acquire/release forms (LDAR/STLR, o2 set) are ordinary - // loads and stores as far as this backend is concerned - suyu's memory - // accessors are already atomic at these widths, and there is no weaker - // ordering here to fence against - so they are translated directly. + // The non-exclusive acquire/release forms (LDAR/STLR, o2 set) use the + // backend's normal atomic accessors, with explicit fences for AArch64's + // acquire/release ordering on weakly ordered hosts. // // The genuinely exclusive forms (LDXR/LDAXR/STXR/STLXR, o2 clear) are not. // They used to become a plain load/store with STXR unconditionally @@ -1644,6 +1643,7 @@ inline bool Translate(u32 i, u64 pc, std::string& out, bool* unhandled = nullptr // to the fallback engine, which owns the kernel's real exclusive monitor. if ((i & 0x3F000000) == 0x08000000) { const u32 size = i >> 30, o2 = (i >> 23) & 1, L = (i >> 22) & 1, o1 = (i >> 21) & 1; + const u32 o0 = (i >> 15) & 1; const u32 rt2 = (i >> 10) & 31, rn = (i >> 5) & 31, rt = i & 31; // Pair forms: LDXP/LDAXP/STXP/STLXP. size 00/01 is unallocated here, // so only the 32- and 64-bit register widths exist. @@ -1663,6 +1663,7 @@ inline bool Translate(u32 i, u64 pc, std::string& out, bool* unhandled = nullptr // doubleword at the address. std::string s2 = "{ uint64_t _lo,_hi; recomp_ldxp(c," + addr + "," + sz + ",&_lo,&_hi); "; + if (o0) s2 += "__atomic_thread_fence(__ATOMIC_ACQUIRE); "; // A W-register destination is written zero-extended. const char* cast = (rbytes == 4) ? "(uint32_t)" : ""; if (rt != 31) { @@ -1680,7 +1681,9 @@ inline bool Translate(u32 i, u64 pc, std::string& out, bool* unhandled = nullptr (rbytes == 4) ? ("(uint64_t)(uint32_t)" + Xz(rt)) : Xz(rt); const std::string hi = (rbytes == 4) ? ("(uint64_t)(uint32_t)" + Xz(rt2)) : Xz(rt2); - std::string s2 = "{ uint32_t _st = recomp_stxp(c," + addr + "," + sz + "," + + std::string s2 = "{ "; + if (o0) s2 += "__atomic_thread_fence(__ATOMIC_RELEASE); "; + s2 += "uint32_t _st = recomp_stxp(c," + addr + "," + sz + "," + lo + "," + hi + "); "; if (rs != 31) { s2 += "c->x[" + std::to_string(rs) + "] = _st; "; @@ -1697,25 +1700,26 @@ inline bool Translate(u32 i, u64 pc, std::string& out, bool* unhandled = nullptr // own exclusive monitor, so an exclusive taken here is visible // to a thread running on the fallback JIT. // - // Acquire/release ordering (the LDAXR/STLXR spelling, o0 set) - // is not modelled separately: suyu's memory accessors are - // already atomic at these widths and the host is x86-64, whose - // ordering is strong enough that the fence these imply is a - // no-op in practice. Noted rather than silently assumed. const std::string addr = "c->x[" + std::to_string(rn) + "]"; if (L) { // LDXR/LDAXR: mark and load. + std::string s = "{ uint64_t _v = recomp_ldxr(c," + addr + "," + + std::to_string(bits / 8) + "); "; + if (o0) s += "__atomic_thread_fence(__ATOMIC_ACQUIRE); "; if (rt != 31) { - put("c->x[" + std::to_string(rt) + "] = recomp_ldxr(c," + addr + "," + - std::to_string(bits / 8) + ");"); + s += "c->x[" + std::to_string(rt) + "] = _v; "; } else { - put("(void)recomp_ldxr(c," + addr + "," + std::to_string(bits / 8) + ");"); + s += "(void)_v; "; } + s += "}"; + put(s); } else { // STXR/STLXR: Rs receives the status, 0 on success. Rs is // held in the Rt2 field's neighbour (bits 20:16). const u32 rs = (i >> 16) & 31; - std::string s2 = "{ uint32_t _st = recomp_stxr(c," + addr + "," + + std::string s2 = "{ "; + if (o0) s2 += "__atomic_thread_fence(__ATOMIC_RELEASE); "; + s2 += "uint32_t _st = recomp_stxr(c," + addr + "," + std::to_string(bits / 8) + "," + Xz(rt) + "); "; if (rs != 31) { s2 += "c->x[" + std::to_string(rs) + "] = _st; "; @@ -1729,12 +1733,14 @@ inline bool Translate(u32 i, u64 pc, std::string& out, bool* unhandled = nullptr if (L) { // LDAR if (rt != 31) { - put("c->x[" + std::to_string(rt) + "] = recomp_load" + - std::to_string(bits) + "(c," + addr + ");"); + put("{ uint64_t _v = recomp_load" + std::to_string(bits) + "(c," + addr + + "); __atomic_thread_fence(__ATOMIC_ACQUIRE); c->x[" + + std::to_string(rt) + "] = _v; }"); } } else { // STLR - no status register. - put("recomp_store" + std::to_string(bits) + "(c," + addr + "," + Xz(rt) + ");"); + put("{ __atomic_thread_fence(__ATOMIC_RELEASE); recomp_store" + + std::to_string(bits) + "(c," + addr + "," + Xz(rt) + "); }"); } return true; } @@ -6557,7 +6563,7 @@ inline const char* FpxH() { #if defined(RECOMP_NO_NATIVE_FP) || defined(__FAST_MATH__) || defined(_M_FP_FAST) || \ (defined(FLT_EVAL_METHOD) && FLT_EVAL_METHOD != 0) #define RECOMP_FPX_HOST 0 /* compiled out: every op takes the exact body */ -#elif defined(__aarch64__) && (defined(__GNUC__) || defined(__clang__)) +#elif (defined(__aarch64__) || defined(__arm64__)) && (defined(__GNUC__) || defined(__clang__)) #define RECOMP_FPX_HOST 1 /* the host FPU is the guest's */ #elif ((defined(__x86_64__) || defined(_M_X64)) && !defined(_M_ARM64EC)) || (defined(__wasm__) && defined(__SSE2__)) #define RECOMP_FPX_HOST 2 /* SSE2 (wasm: Emscripten's SSE2 on wasm SIMD; its float ops are IEEE round-to-nearest too) */ diff --git a/third_party/a64-kit/tools/toolchain.py b/third_party/a64-kit/tools/toolchain.py index 3a7cc23f..420b138d 100644 --- a/third_party/a64-kit/tools/toolchain.py +++ b/third_party/a64-kit/tools/toolchain.py @@ -55,6 +55,12 @@ def macos_sysroot_flags(): return ["-isysroot", sdk] +def macos_target_flags(): + if sys.platform != "darwin": + return [] + return ["--target=arm64-apple-macos13.3"] + + def need(path): if not os.path.isfile(path): raise SystemExit("%s not found: set EMSDK to an emsdk checkout (now %s), or put %s on PATH" From 18467bbe8b6de67c70bbeae182f86986ab114f3f Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:41:26 +0100 Subject: [PATCH 23/29] Build macOS instruction translator with native SDK Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- third_party/a64-kit/lift/build_emit.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/third_party/a64-kit/lift/build_emit.py b/third_party/a64-kit/lift/build_emit.py index 6fec2482..7b6fcc8a 100644 --- a/third_party/a64-kit/lift/build_emit.py +++ b/third_party/a64-kit/lift/build_emit.py @@ -1,6 +1,6 @@ """Build a64emit (suyu's AArch64 -> C instruction translator) as a shared library for a64_lift.py. -Usage: python build_emit.py -> a64emit.dll (Windows) / liba64emit.so (elsewhere), next to this file +Usage: python build_emit.py -> a64emit.dll (Windows) / liba64emit.dylib (macOS) / liba64emit.so (elsewhere) Rebuilds only when a64emit.cpp or suyu/arm64_to_c.h changed. """ import hashlib, os, subprocess, sys @@ -10,7 +10,8 @@ import toolchain SRC = [os.path.join(HERE, "a64emit.cpp"), os.path.join(HERE, "suyu", "arm64_to_c.h")] -OUT = os.path.join(HERE, "a64emit.dll" if os.name == "nt" else "liba64emit.so") +OUT = os.path.join(HERE, "a64emit.dll" if os.name == "nt" else + "liba64emit.dylib" if sys.platform == "darwin" else "liba64emit.so") def build(force=False): @@ -18,8 +19,11 @@ def build(force=False): stamp = OUT + ".sha1" if not force and os.path.exists(OUT) and os.path.exists(stamp) and open(stamp).read() == key: return OUT - cmd = [toolchain.need(toolchain.find("clang++")), "-std=c++20", "-O2", "-shared", "-o", OUT, SRC[0], - "-Wno-everything"] + toolchain.native_target() + toolchain.native_link() + target = (toolchain.macos_target_flags() + toolchain.macos_sysroot_flags() + if sys.platform == "darwin" else toolchain.native_target()) + cmd = [toolchain.need(toolchain.find("clang++")), "-std=c++20", "-O2", + "-dynamiclib" if sys.platform == "darwin" else "-shared", "-o", OUT, SRC[0], + "-Wno-everything"] + target + toolchain.native_link() if os.name != "nt": cmd += ["-fPIC"] print("building", os.path.basename(OUT)) From d3c46d884dfa735246b38848b5fed096796ee33c Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:48:34 +0100 Subject: [PATCH 24/29] Support Apple Silicon POSIX exception contexts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 3 +-- host/hos_paths.h | 6 ++++-- host/posix/win32_posix.c | 22 +++++++++++++++++++--- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index 3ad3353a..e3efde39 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -45,8 +45,7 @@ jobs: python -c "import platform; assert platform.machine() == 'arm64', platform.machine()" clang -target arm64-apple-macos13.3 -mcpu=apple-m1 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ - host/posix/win32_posix.c host/hos_main.c host/rx_backend.c host/hos_setjmp.c \ - host/hos_thread.c host/hos_svc.c host/nn_os_sync.c + host/posix/win32_posix.c host/hos_main.c host/rx_backend.c host/hos_setjmp.c clang -target arm64-apple-macos13.3 -mcpu=apple-m1 -fsyntax-only -D_GNU_SOURCE -Wno-everything \ -I"$RUNNER_TEMP/a64-generated" -Ihost/posix -Ihost -Ithird_party/a64-kit/rt \ host/posix/display_sdl.c host/posix/macos_stubs.c diff --git a/host/hos_paths.h b/host/hos_paths.h index 10f3687c..f2ec0bf6 100644 --- a/host/hos_paths.h +++ b/host/hos_paths.h @@ -6,9 +6,11 @@ #pragma once #include #include -#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif #include - +#ifdef _WIN32 static inline DWORD hos_game_path_a(char *buf, DWORD size) { DWORD n = GetModuleFileNameA(NULL, buf, size); if (!n || n >= size) return n; diff --git a/host/posix/win32_posix.c b/host/posix/win32_posix.c index f357dc9c..80c3b79e 100644 --- a/host/posix/win32_posix.c +++ b/host/posix/win32_posix.c @@ -1251,7 +1251,14 @@ static struct sigaction previous[32]; static pthread_mutex_t handlers_mu = PTHREAD_MUTEX_INITIALIZER; static void context_from(CONTEXT *c, const ucontext_t *uc) { -#ifdef __APPLE__ +#if defined(__APPLE__) && (defined(__aarch64__) || defined(__arm64__)) + const arm_thread_state64_t *g = &uc->uc_mcontext->__ss; + c->Rax = g->__x[0], c->Rcx = g->__x[1], c->Rdx = g->__x[2], c->Rbx = g->__x[3]; + c->Rsp = g->__sp, c->Rbp = g->__fp, c->Rsi = g->__x[4], c->Rdi = g->__x[5]; + c->R8 = g->__x[6], c->R9 = g->__x[7], c->R10 = g->__x[8], c->R11 = g->__x[9]; + c->R12 = g->__x[10], c->R13 = g->__x[11], c->R14 = g->__x[12], c->R15 = g->__x[13]; + c->Rip = g->__pc, c->EFlags = g->__cpsr, c->ContextFlags = CONTEXT_FULL; +#elif defined(__APPLE__) const x86_thread_state64_t *g = &uc->uc_mcontext->__ss; c->Rax = g->__rax, c->Rcx = g->__rcx, c->Rdx = g->__rdx, c->Rbx = g->__rbx; c->Rsp = g->__rsp, c->Rbp = g->__rbp, c->Rsi = g->__rsi, c->Rdi = g->__rdi; @@ -1268,7 +1275,14 @@ static void context_from(CONTEXT *c, const ucontext_t *uc) { #endif } static void context_to(ucontext_t *uc, const CONTEXT *c) { -#ifdef __APPLE__ +#if defined(__APPLE__) && (defined(__aarch64__) || defined(__arm64__)) + arm_thread_state64_t *g = &uc->uc_mcontext->__ss; + g->__x[0] = c->Rax, g->__x[1] = c->Rcx, g->__x[2] = c->Rdx, g->__x[3] = c->Rbx; + g->__sp = c->Rsp, g->__fp = c->Rbp, g->__x[4] = c->Rsi, g->__x[5] = c->Rdi; + g->__x[6] = c->R8, g->__x[7] = c->R9, g->__x[8] = c->R10, g->__x[9] = c->R11; + g->__x[10] = c->R12, g->__x[11] = c->R13, g->__x[12] = c->R14, g->__x[13] = c->R15; + g->__pc = c->Rip, g->__cpsr = c->EFlags; +#elif defined(__APPLE__) x86_thread_state64_t *g = &uc->uc_mcontext->__ss; g->__rax = c->Rax, g->__rcx = c->Rcx, g->__rdx = c->Rdx, g->__rbx = c->Rbx; g->__rsp = c->Rsp, g->__rbp = c->Rbp, g->__rsi = c->Rsi, g->__rdi = c->Rdi; @@ -1296,7 +1310,9 @@ static void on_signal(int sig, siginfo_t *si, void *uctx) { rec.ExceptionAddress = (void *)(uintptr_t)ctx.Rip; if (sig == SIGSEGV || sig == SIGBUS) { rec.NumberParameters = 2; -#ifdef __APPLE__ +#if defined(__APPLE__) && (defined(__aarch64__) || defined(__arm64__)) + rec.ExceptionInformation[0] = (uc->uc_mcontext->__es.__esr & (1u << 6)) ? 1 : 0; +#elif defined(__APPLE__) rec.ExceptionInformation[0] = (uc->uc_mcontext->__es.__err & 2) ? 1 : 0; #else rec.ExceptionInformation[0] = (uc->uc_mcontext.gregs[REG_ERR] & 2) ? 1 : 0; /* (1: a write) */ From 6aefdaa5462063089be9c60253dbd84b9dd8674a Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:51:48 +0100 Subject: [PATCH 25/29] Fix output staging test scope Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/test_finish_output.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_finish_output.py b/tests/test_finish_output.py index 6e8fc632..a43e7196 100644 --- a/tests/test_finish_output.py +++ b/tests/test_finish_output.py @@ -66,7 +66,10 @@ def clear_fixture_readonly_modes(self): def finish(self): with mock.patch.object(make_game, "BESIDE", ["hookshot.exe"]): - exec(FINISH, make_game.__dict__, {"built": str(self.built), "out": str(self.out), "work": str(self.work)}) + scope = make_game.__dict__.copy() + scope.update(built=str(self.built), out=str(self.out), work=str(self.work), + game_dir=str(self.out), launcher=None) + exec(FINISH, scope, scope) def assert_installed_unchanged(self): self.assertEqual(self.destination.read_bytes(), b"existing synthetic executable") From 3674f6ccf68b1f331c887d094aa6f45f5df819e6 Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:03:05 +0100 Subject: [PATCH 26/29] Run source regressions for macOS changes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/source-regressions.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/source-regressions.yml b/.github/workflows/source-regressions.yml index ce19daf1..738ae138 100644 --- a/.github/workflows/source-regressions.yml +++ b/.github/workflows/source-regressions.yml @@ -9,6 +9,15 @@ on: - nx2/build.py - nx2/unpack.py - third_party/a64-kit/tools/toolchain.py + - third_party/a64-kit/lift/** + - host/hos_setjmp.c + - host/hos_thread.c + - host/hos_paths.h + - host/posix/** + - gpu/render/** + - scripts/macos/** + - tests/test_macos_build_path.py + - .github/workflows/macos-platform.yml - host/msvc_oldnames.c - tests/test_msvc_oldnames.py - nx2/test_nup_paths.py @@ -127,6 +136,15 @@ on: - nx2/build.py - nx2/unpack.py - third_party/a64-kit/tools/toolchain.py + - third_party/a64-kit/lift/** + - host/hos_setjmp.c + - host/hos_thread.c + - host/hos_paths.h + - host/posix/** + - gpu/render/** + - scripts/macos/** + - tests/test_macos_build_path.py + - .github/workflows/macos-platform.yml - host/msvc_oldnames.c - tests/test_msvc_oldnames.py - nx2/test_nup_paths.py @@ -302,6 +320,8 @@ jobs: run: python -I -B tests/test_child_unicode.py - name: Check packaged CPM discovery run: python -I -B tests/test_cpm_discovery.py + - name: Check native macOS build configuration + run: python -I -B tests/test_macos_build_path.py - name: Check safe installation of built files run: python -I -B tests/test_finish_output.py - name: Check a failed step's error line From 026926bdc958dbf15ef2a7ebfbe391279ab0a2ff Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:43:37 +0100 Subject: [PATCH 27/29] Fix cross-platform builder regressions Fix host compiler selection for native helpers and give ZBIC its macOS dynamic-library build path. Make the regression runner distinguish Windows, Linux, and macOS, guard platform-specific packaging scripts, and remove an undefined stale Windows preflight branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/source-regressions.yml | 7 ++++ nx2/make_game.py | 6 ++-- nx2/zbic.py | 7 ++-- scripts/linux/appimage.sh | 4 +++ scripts/linux/deps.sh | 4 +++ scripts/macos/deps.sh | 4 +++ scripts/macos/package-builder.sh | 4 +++ tests/run_all.py | 27 ++++++++++----- tests/test_cpu_target.py | 3 +- tests/test_toolchain_platform.py | 43 ++++++++++++++++++++++++ third_party/a64-kit/tools/toolchain.py | 15 ++++++--- 11 files changed, 105 insertions(+), 19 deletions(-) create mode 100644 tests/test_toolchain_platform.py diff --git a/.github/workflows/source-regressions.yml b/.github/workflows/source-regressions.yml index 577d4992..1b8d2a61 100644 --- a/.github/workflows/source-regressions.yml +++ b/.github/workflows/source-regressions.yml @@ -13,6 +13,13 @@ on: - nx2/build.py - nx2/unpack.py - third_party/a64-kit/tools/toolchain.py + - nx2/zbic.py + - tests/run_all.py + - tests/test_toolchain_platform.py + - scripts/linux/appimage.sh + - scripts/linux/deps.sh + - scripts/macos/deps.sh + - scripts/macos/package-builder.sh - third_party/a64-kit/lift/** - host/hos_setjmp.c - host/hos_thread.c diff --git a/nx2/make_game.py b/nx2/make_game.py index 723c8d39..3f59eae0 100644 --- a/nx2/make_game.py +++ b/nx2/make_game.py @@ -35,8 +35,11 @@ ROOT = os.path.dirname(HERE) PY = [sys.executable, "-I", "-u", "-X", "utf8"] # (utf8: every step's output in UTF-8 whatever the console's code page: lift.py crashed on a letter, #180) # (-u: the steps' output as it comes, not in 8 KB blocks minutes apart) BUILD_ID = "e0f3ccfa" # (the version the patches, the hot list and the profile were made from) +WINDOWS = sys.platform == "win32" LINUX = sys.platform.startswith("linux") MACOS = sys.platform == "darwin" +if not (WINDOWS or LINUX or MACOS): + raise SystemExit("Unsupported host platform: " + sys.platform) BESIDE = (["hookshot", "hookshot_vk.so", "libSDL2-2.0.so.0"] if LINUX else ["hookshot", "libSDL2-2.0.0.dylib", "libwgpu_native.dylib"] if MACOS else ["hookshot.exe", "hookshot_vk.dll", "SDL2.dll", "SDL3.dll", "wgpu_native.dll"]) @@ -552,9 +555,6 @@ def check_windows_sdk(): return check_linux_compiler() if bundled_mingw(): return check_mingw() - if CROSS: - fail("Building for Windows on Linux needs llvm-mingw: unpack its Linux release (github.com/mstorsjo/llvm-mingw) and set " - "HOOKSHOT_MINGW to its folder.") sys.path.insert(0, os.path.join(ROOT, "third_party", "a64-kit", "tools")) import toolchain # (an INCLUDE set for the whole machine, by an old Visual Studio or another tool, is all clang reads: without the Windows SDK's C diff --git a/nx2/zbic.py b/nx2/zbic.py index 0179d06b..8b9ea0b5 100644 --- a/nx2/zbic.py +++ b/nx2/zbic.py @@ -13,7 +13,9 @@ HERE = os.path.dirname(os.path.abspath(__file__)) SRC = os.path.join(os.path.dirname(HERE), "third_party", "zbic") -DLL = os.path.join(SRC, "zbic.dll" if os.name == "nt" else "zbic.so") +WINDOWS = os.name == "nt" +MACOS = sys.platform == "darwin" +DLL = os.path.join(SRC, "zbic.dll" if WINDOWS else "zbic.dylib" if MACOS else "zbic.so") _lib = None @@ -24,7 +26,8 @@ def _build(): return srcs = [os.path.join(SRC, "zstd.c"), os.path.join(SRC, "zbic_api.c")] cmd = [toolchain.need(toolchain.find("clang", host=True)), *toolchain.native_target(host=True), *toolchain.native_link(host=True), - "-O2", "-shared", *([] if os.name == "nt" else ["-fPIC"]), "-DZSTD_ZBIC_SUPPORT=1", "-I", SRC, "-o", DLL, *srcs] + "-O2", *(["-dynamiclib"] if MACOS else ["-shared"]), + *([] if WINDOWS or MACOS else ["-fPIC"]), "-DZSTD_ZBIC_SUPPORT=1", "-I", SRC, "-o", DLL, *srcs] print("building", os.path.basename(DLL), file=sys.stderr) subprocess.run(cmd, check=True) diff --git a/scripts/linux/appimage.sh b/scripts/linux/appimage.sh index 9b5676d1..2105372a 100755 --- a/scripts/linux/appimage.sh +++ b/scripts/linux/appimage.sh @@ -10,6 +10,10 @@ # game it builds runs on glibc 2.35 and newer. work/ (hot_funcs.txt, hot_order.txt, hookshot_pieces.prof, # image/chunks.txt: addresses and counts from playing, no game code) is carried along when it is there. set -euo pipefail +if [[ "$(uname -s)" != "Linux" || "$(uname -m)" != "x86_64" ]]; then + echo "The Linux AppImage build requires a Linux x86_64 host." >&2 + exit 1 +fi cd "$(dirname "$0")/../.." ROOT=$PWD B=$ROOT/build-linux diff --git a/scripts/linux/deps.sh b/scripts/linux/deps.sh index cc796e6b..edaa7499 100755 --- a/scripts/linux/deps.sh +++ b/scripts/linux/deps.sh @@ -14,6 +14,10 @@ # runtime-x86_64 the AppImage runtime it puts in front of the image (pinned: appimagetool's own download of the # 'continuous' one, at packing time, fails whenever GitHub answers 504) set -euo pipefail +if [[ "$(uname -s)" != "Linux" || "$(uname -m)" != "x86_64" ]]; then + echo "Linux AppImage dependencies require a Linux x86_64 host." >&2 + exit 1 +fi cd "$(dirname "$0")/../.." DEPS=${DEPS:-$PWD/build-linux/deps} DL=$DEPS/downloads diff --git a/scripts/macos/deps.sh b/scripts/macos/deps.sh index d295a02f..d4a11496 100644 --- a/scripts/macos/deps.sh +++ b/scripts/macos/deps.sh @@ -1,6 +1,10 @@ #!/bin/bash # Build the native arm64 libraries and Python runtime used by Hookshot's macOS builder. set -euo pipefail +if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then + echo "The macOS builder dependencies require an Apple Silicon macOS host." >&2 + exit 1 +fi cd "$(dirname "$0")/../.." DEPS=${DEPS:-$PWD/build-macos/deps} diff --git a/scripts/macos/package-builder.sh b/scripts/macos/package-builder.sh index 19dd8bf5..de318a31 100644 --- a/scripts/macos/package-builder.sh +++ b/scripts/macos/package-builder.sh @@ -1,6 +1,10 @@ #!/bin/bash # Assemble a downloadable macOS CLI builder; no game dump or game build is included. set -euo pipefail +if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then + echo "The macOS builder package requires an Apple Silicon macOS host." >&2 + exit 1 +fi cd "$(dirname "$0")/../.." DEPS=${DEPS:-$PWD/build-macos/deps} diff --git a/tests/run_all.py b/tests/run_all.py index 390af4fb..af5e638e 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -20,8 +20,19 @@ import time ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -WINDOWS = os.name == "nt" -OS = "windows" if WINDOWS else "linux" + + +def host_os(): + if sys.platform == "win32": + return "windows" + if sys.platform == "darwin": + return "macos" + if sys.platform.startswith("linux"): + return "linux" + raise RuntimeError("Unsupported host platform: " + sys.platform) + + +OS = host_os() # Arguments some tests need. {name} is a tool or folder found below (find_tools); a check whose tools are missing is # skipped, or fails with --strict. Tests not listed run with no arguments and their own defaults. @@ -56,14 +67,14 @@ # Checks that cannot run in CI, with why, by OS. Each still runs with --no-skip (or by name). SKIP = { # (the game: these run the lifted code of a real dump against Unicorn) - "nx2/test_insn.py": ({"linux", "windows"}, "needs the game's modules (work/dump) and Unicorn"), - "nx2/test_leaf.py": ({"linux", "windows"}, "needs the game's modules (work/dump) and Unicorn"), - "nx2/test_dll.py": ({"linux", "windows"}, "needs a full game build (work/build/hookshot_game.dll) and Unicorn"), + "nx2/test_insn.py": ({"linux", "windows", "macos"}, "needs the game's modules (work/dump) and Unicorn"), + "nx2/test_leaf.py": ({"linux", "windows", "macos"}, "needs the game's modules (work/dump) and Unicorn"), + "nx2/test_dll.py": ({"linux", "windows", "macos"}, "needs a full game build (work/build/hookshot_game.dll) and Unicorn"), # (a GPU) - "nx2/test_bc_storage_views.py": ({"linux", "windows"}, "needs a Windows Vulkan device"), + "nx2/test_bc_storage_views.py": ({"linux", "windows", "macos"}, "needs a Windows Vulkan device"), # (Linux only) - "tests/test_hookshot_launch.py": ({"windows"}, "the Linux launcher, a POSIX shell script"), - "tests/test_linux_update.py": ({"windows"}, "the Linux builder's update and folder launcher, POSIX files and shell scripts"), + "tests/test_hookshot_launch.py": ({"windows", "macos"}, "the Linux launcher, a POSIX shell script"), + "tests/test_linux_update.py": ({"windows", "macos"}, "the Linux builder's update and folder launcher, POSIX files and shell scripts"), } # (spirv-val: SPIRV-Tools comes with the Linux runner's packages, not on Windows) for _name in ("nx2/test_default_point_size.py", "nx2/test_draw_parameters.py", "nx2/test_coop_spirv.py", diff --git a/tests/test_cpu_target.py b/tests/test_cpu_target.py index c4d2f1ec..f73c6093 100644 --- a/tests/test_cpu_target.py +++ b/tests/test_cpu_target.py @@ -69,7 +69,8 @@ def __init__(self, present): def test_build_and_appimage_use_it(self): build = (ROOT / "nx2" / "build.py").read_text(encoding="utf-8") self.assertIn("from host_cpu import host_march", build) - self.assertIn('MARCH = os.environ.get("RX_MARCH") or host_march()', build) + self.assertIn('MARCH = os.environ.get("RX_MARCH") or ("apple-m1" if MACOS else host_march())', build) + self.assertIn('if not MACOS and MARCH != "x86-64-v3":', build) appimage = (ROOT / "scripts" / "linux" / "appimage.sh").read_text(encoding="utf-8") self.assertIn("RX_MARCH=x86-64-v3 RX_VK_ONLY=1", appimage) # (its prebuilt renderer: not the CI runner's CPU) diff --git a/tests/test_toolchain_platform.py b/tests/test_toolchain_platform.py new file mode 100644 index 00000000..57550fd3 --- /dev/null +++ b/tests/test_toolchain_platform.py @@ -0,0 +1,43 @@ +"""Host-versus-target tool selection for native helpers and cross-builds.""" +import importlib.util +from pathlib import Path +import sys +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +TOOLCHAIN = ROOT / "third_party" / "a64-kit" / "tools" / "toolchain.py" +SPEC = importlib.util.spec_from_file_location("hookshot_toolchain_platform_test", TOOLCHAIN) +toolchain = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(toolchain) + +RUNNER = ROOT / "tests" / "run_all.py" +RUNNER_SPEC = importlib.util.spec_from_file_location("hookshot_regression_runner_platform_test", RUNNER) +runner = importlib.util.module_from_spec(RUNNER_SPEC) +RUNNER_SPEC.loader.exec_module(runner) + + +class ToolchainPlatformTests(unittest.TestCase): + def test_find_uses_host_tool_for_native_helper_during_cross_build(self): + cross = str(ROOT / "fake-llvm-mingw") + cross_clang = toolchain.os.path.join(cross, "bin", "clang.exe") + + def mingw(host=False): + return "" if host else cross + + with mock.patch.object(toolchain.sys, "platform", "linux"), \ + mock.patch.object(toolchain, "mingw", side_effect=mingw), \ + mock.patch.object(toolchain.os.path, "isfile", side_effect=lambda path: path == cross_clang), \ + mock.patch.object(toolchain.shutil, "which", side_effect=lambda name: "host-" + name): + self.assertEqual(toolchain.find("clang"), cross_clang) + self.assertEqual(toolchain.find("clang", host=True), "host-clang") + + def test_regression_runner_identifies_each_supported_host(self): + for platform, expected in (("win32", "windows"), ("linux", "linux"), ("darwin", "macos")): + with self.subTest(platform=platform), mock.patch.object(runner.sys, "platform", platform): + self.assertEqual(runner.host_os(), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/third_party/a64-kit/tools/toolchain.py b/third_party/a64-kit/tools/toolchain.py index d5e8644f..3e8c392a 100644 --- a/third_party/a64-kit/tools/toolchain.py +++ b/third_party/a64-kit/tools/toolchain.py @@ -7,10 +7,14 @@ def emsdk(): return os.environ.get("EMSDK") or (r"C:\emsdk" if os.name == "nt" else os.path.expanduser("~/emsdk")) +def macos(): + return sys.platform == "darwin" + + def windows(): """(hookshot) the game is built for Windows: on Windows, or with RX_TARGET=windows on another system (a cross build, with llvm-mingw's release for that system in HOOKSHOT_MINGW)""" - return os.name == "nt" or os.environ.get("RX_TARGET", "").lower() == "windows" + return sys.platform == "win32" or os.environ.get("RX_TARGET", "").lower() == "windows" def mingw(host=False): @@ -20,8 +24,8 @@ def mingw(host=False): return os.environ.get("HOOKSHOT_MINGW", "") if windows() and not (host and os.name != "nt") else "" -def find(tool): - if sys.platform == "darwin" and tool in ("clang", "clang++"): +def find(tool, host=False): + if macos() and tool in ("clang", "clang++") and not mingw(host): xcrun = shutil.which("xcrun") if xcrun: result = subprocess.run([xcrun, "--find", tool], capture_output=True, text=True) @@ -29,8 +33,9 @@ def find(tool): path = result.stdout.strip() if path: return path - if mingw() and (tool in ("clang", "clang++", "lld") or tool.startswith("llvm-")): - path = os.path.join(mingw(), "bin", ("ld.lld" if tool == "lld" else tool) + ".exe") + mingw_toolchain = mingw(host) + if mingw_toolchain and (tool in ("clang", "clang++", "lld") or tool.startswith("llvm-")): + path = os.path.join(mingw_toolchain, "bin", ("ld.lld" if tool == "lld" else tool) + ".exe") if os.path.isfile(path): return path root = emsdk() From 17e206d365f8ca4f15f57dffb2e99a454945ddef Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:56:12 +0100 Subject: [PATCH 28/29] Publish macOS builder in tagged releases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index e3efde39..ad747a0c 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -1,6 +1,8 @@ name: macOS platform on: + push: + tags: ['v*'] pull_request: workflow_dispatch: @@ -110,3 +112,25 @@ jobs: name: HookshotBuilder-macos-arm64 path: build-macos/HookshotBuilder-macos-arm64.zip if-no-files-found: error + + release: + name: macOS release asset + if: startsWith(github.ref, 'refs/tags/v') + needs: macos-builder + runs-on: ubuntu-24.04 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: HookshotBuilder-macos-arm64 + path: dist + - name: macOS builder to the release + run: scripts/release_upload.sh "$GITHUB_REF_NAME" dist/HookshotBuilder-macos-arm64.zip From f54b28c50bc0a6a1c79ead002b06bdea5873e72e Mon Sep 17 00:00:00 2001 From: BED <73585863+yeetbot90@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:57:08 +0100 Subject: [PATCH 29/29] Release the macOS app bundle Package the standalone HookshotBuilder.app with its tools inside Resources and publish an app-only zip asset for version tags. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/macos-platform.yml | 9 +++++++-- scripts/macos/package-builder.sh | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml index ad747a0c..657ca649 100644 --- a/.github/workflows/macos-platform.yml +++ b/.github/workflows/macos-platform.yml @@ -103,6 +103,9 @@ jobs: - name: Verify packaged builder starts run: | test -d build-macos/package/HookshotBuilder/HookshotBuilder.app + test -x build-macos/package/HookshotBuilder/HookshotBuilder.app/Contents/Resources/tools/python/bin/python3 + test -f build-macos/package/HookshotBuilder/HookshotBuilder.app/Contents/Resources/tools/source/builder/linux/hookshot_builder.py + test ! -L build-macos/package/HookshotBuilder/HookshotBuilder.app/Contents/Resources/tools lipo build-macos/package/HookshotBuilder/HookshotBuilder.app/Contents/MacOS/applet -verify_arch arm64 cd build-macos/package/HookshotBuilder ./HookshotBuilder.command --help @@ -110,7 +113,9 @@ jobs: - uses: actions/upload-artifact@v4 with: name: HookshotBuilder-macos-arm64 - path: build-macos/HookshotBuilder-macos-arm64.zip + path: | + build-macos/HookshotBuilder-macos-arm64.zip + build-macos/HookshotBuilder-macos-arm64.app.zip if-no-files-found: error release: @@ -133,4 +138,4 @@ jobs: name: HookshotBuilder-macos-arm64 path: dist - name: macOS builder to the release - run: scripts/release_upload.sh "$GITHUB_REF_NAME" dist/HookshotBuilder-macos-arm64.zip + run: scripts/release_upload.sh "$GITHUB_REF_NAME" dist/HookshotBuilder-macos-arm64.app.zip diff --git a/scripts/macos/package-builder.sh b/scripts/macos/package-builder.sh index de318a31..fa88f9a5 100644 --- a/scripts/macos/package-builder.sh +++ b/scripts/macos/package-builder.sh @@ -46,11 +46,14 @@ cp scripts/macos/README.txt "$ROOT/README.txt" APP=$ROOT/HookshotBuilder.app osacompile -o "$APP" scripts/macos/Builder.applescript mkdir -p "$APP/Contents/Resources" -ln -s ../../../tools "$APP/Contents/Resources/tools" +mv "$ROOT/tools" "$APP/Contents/Resources/tools" +ln -s "HookshotBuilder.app/Contents/Resources/tools" "$ROOT/tools" test -x "$APP/Contents/MacOS/applet" lipo "$APP/Contents/MacOS/applet" -verify_arch arm64 mkdir -p "$OUT" cd "$STAGE" ditto -c -k --sequesterRsrc --keepParent HookshotBuilder "$OUT/HookshotBuilder-macos-arm64.zip" +ditto -c -k --sequesterRsrc --keepParent "$ROOT/HookshotBuilder.app" "$OUT/HookshotBuilder-macos-arm64.app.zip" echo "Created $OUT/HookshotBuilder-macos-arm64.zip" +echo "Created $OUT/HookshotBuilder-macos-arm64.app.zip"