diff --git a/.github/workflows/macos-platform.yml b/.github/workflows/macos-platform.yml new file mode 100644 index 00000000..657ca649 --- /dev/null +++ b/.github/workflows/macos-platform.yml @@ -0,0 +1,141 @@ +name: macOS platform + +on: + push: + tags: ['v*'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + macos-posix: + # Apple Silicon runner: verify the native arm64 game and runtime target. + runs-on: macos-latest + 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 + python -I third_party/a64-kit/lift/build_emit.py + python -I scripts/macos/check-arm64-translation.py + + - name: Link native arm64 macOS executable + run: | + printf 'int main(void) { return 0; }\n' > "$RUNNER_TEMP/macos-probe.c" + 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: | + 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" + 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 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 + + macos-builder: + # 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 + key: macos-builder-deps-${{ hashFiles('scripts/macos/deps.sh') }} + + - name: Install dependency build tools + run: brew install cmake nasm + + - name: Build builder dependencies + run: 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: | + 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']()" + 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 + + - 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 + + - uses: actions/upload-artifact@v4 + with: + name: HookshotBuilder-macos-arm64 + path: | + build-macos/HookshotBuilder-macos-arm64.zip + build-macos/HookshotBuilder-macos-arm64.app.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.app.zip diff --git a/builder/linux/hookshot_builder.py b/builder/linux/hookshot_builder.py index b24044e7..4affc4db 100644 --- a/builder/linux/hookshot_builder.py +++ b/builder/linux/hookshot_builder.py @@ -28,7 +28,8 @@ import make_game # noqa: E402 (LANGUAGES, BUILD_ID) import toolchain # noqa: E402 (default_jobs: RX_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"} @@ -88,6 +89,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): @@ -607,7 +610,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=self.px(4)) - ttk.Button(self.buttons, text="Open folder", command=lambda: subprocess.Popen(["xdg-open", folder])).pack(side="right", padx=self.px(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=self.px(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=self.px(4)) diff --git a/docs/development.md b/docs/development.md index e26b14d9..f8b4bde2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,6 +45,43 @@ 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) + +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. 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-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 +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 +``` + +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/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 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. +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 workflow packages and validates builder dependencies without running a game build; it is not a +notarized app release. + ## Layout | Path | What | @@ -53,7 +90,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_buffer.cpp b/gpu/render/rx_buffer.cpp index 4bcf1e50..f155f89e 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 @@ -362,8 +362,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 +392,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 +449,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 +689,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 +713,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/gpu/render/rx_core.cpp b/gpu/render/rx_core.cpp index dde25884..b4635239 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 @@ -53,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; } @@ -340,12 +347,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 +395,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 +410,7 @@ bool init_device(HWND hwnd) { WGPUSurfaceDescriptor sd{}; sd.nextInChain = &src.chain; surface = wgpuInstanceCreateSurface(dev.instance, &sd); +#endif } #endif @@ -560,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); @@ -1344,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(); @@ -1353,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 18c7f091..de36c4db 100644 --- a/gpu/render/rx_internal.h +++ b/gpu/render/rx_internal.h @@ -11,6 +11,22 @@ #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__ diff --git a/host/hos_main.c b/host/hos_main.c index 8bca54d2..19455b09 100644 --- a/host/hos_main.c +++ b/host/hos_main.c @@ -25,11 +25,11 @@ #include #ifdef __linux__ #include +#include #endif #include #include #include -#include #endif /* (linux) the separator of the paths the executable's folder is joined with */ #ifdef _WIN32 @@ -136,10 +136,11 @@ static int reserve_guest(void) { hos_commit_init(); return VirtualAlloc((void *)A64_HOST_BASE, GUEST_SPACE, MEM_RESERVE | watch, PAGE_NOACCESS) == (void *)A64_HOST_BASE; #else - /* (linux) the whole window readable and writable at once, its pages made as they are first touched (no commit + /* (POSIX) the whole window readable and writable at once, its pages made as they are first touched (no commit step: MAP_NORESERVE). Decommitting is madvise (host/posix/win32_posix.c VirtualFree): the pages read as zero. */ void hos_commit_init(void); hos_commit_init(); /* (the crash report) */ +#ifdef __linux__ /* The window is a memfd's, shared, so the Vulkan back end can hand the GPU the game's own pages as a dma-buf (udmabuf, gpu/vk/vk_memory.cpp import_dma_buf) instead of a host pointer: amdgpu checks every page of a host-pointer import again at each command submission, 15-45 ms a submission in Kokiri Forest. Pages of a shared mapping are freed @@ -184,14 +185,23 @@ static int reserve_guest(void) { hos_log("mem: no memfd for the guest window (errno %d): anonymous memory", errno); } } - if (mmap((void *)A64_HOST_BASE, GUEST_SPACE, PROT_READ | PROT_WRITE, - MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE | MAP_NORESERVE, -1, 0) != (void *)A64_HOST_BASE) +#endif + void *guest = mmap((void *)A64_HOST_BASE, GUEST_SPACE, PROT_READ | PROT_WRITE, +#ifdef __linux__ + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE | MAP_NORESERVE, +#else + MAP_PRIVATE | MAP_ANONYMOUS, +#endif + -1, 0); + if (guest != (void *)A64_HOST_BASE) { + if (guest != MAP_FAILED) munmap(guest, GUEST_SPACE); return 0; + } /* (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 */ - if (!getenv("HOS_THP")) madvise((void *)A64_HOST_BASE, GUEST_SPACE, MADV_NOHUGEPAGE); #ifdef __linux__ + if (!getenv("HOS_THP")) madvise((void *)A64_HOST_BASE, GUEST_SPACE, MADV_NOHUGEPAGE); /* (HOS_NO_THP=1, experiment: none outside the window either. With THP "always" 400-450 MB of the host heap's and the binary's bss are huge pages, each resident whole when any of its 2 MB is touched) */ if (getenv("HOS_NO_THP")) prctl(PR_SET_THP_DISABLE, 1, 0, 0, 0); @@ -206,7 +216,12 @@ static int reserve_guest(void) { int hos_guest_memfd = -1; int hos_posix_guest_memfd(void) { return hos_guest_memfd; } int hos_posix_guest_discard(void *p, size_t n) { +#ifdef __linux__ return madvise(p, n, hos_guest_memfd >= 0 ? MADV_REMOVE : MADV_DONTNEED); +#else + memset(p, 0, n); + return madvise(p, n, MADV_DONTNEED); +#endif } #endif diff --git a/host/hos_paths.h b/host/hos_paths.h index 5bf7cc5d..4d932ec5 100644 --- a/host/hos_paths.h +++ b/host/hos_paths.h @@ -6,15 +6,11 @@ #pragma once #include #include -#ifdef _WIN32 -#ifndef NOMINMAX -#define NOMINMAX -#endif #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/hos_sdl.c b/host/hos_sdl.c index cb8fcc57..11ea3376 100644 --- a/host/hos_sdl.c +++ b/host/hos_sdl.c @@ -15,7 +15,7 @@ static struct { /* window */ void *CreateWindow, *SetWindowPosition, *SetWindowFullscreen, *SetRelativeMouseMode, *GetPrimaryDisplay, *GetDesktopDisplayMode, *Vulkan_CreateSurface, *PixelSize, *GetKeyboardState, *GetMouseState, *WaitEventTimeout, - *PollEvent, *LoadPNG, *SetWindowIcon, *DestroySurface; + *PollEvent, *LoadPNG, *SetWindowIcon, *DestroySurface, *Metal_CreateView, *Metal_GetLayer; /* sound */ void *OpenAudioDevice, *PauseAudioDevice, *OpenAudioDeviceStream, *ResumeAudioStreamDevice, *PutAudioStreamData; /* controllers */ @@ -43,8 +43,14 @@ static int get_all(HMODULE m, int three) { GET(SetWindowFullscreen, "SDL_SetWindowFullscreen"); GET2(SetRelativeMouseMode, "SDL_SetWindowRelativeMouseMode", "SDL_SetRelativeMouseMode"); GET(GetDesktopDisplayMode, "SDL_GetDesktopDisplayMode"); +#ifdef __APPLE__ + GET(Metal_CreateView, "SDL_Metal_CreateView"); + GET(Metal_GetLayer, "SDL_Metal_GetLayer"); + GET2(PixelSize, "SDL_GetWindowSizeInPixels", "SDL_GetWindowSizeInPixels"); +#else GET(Vulkan_CreateSurface, "SDL_Vulkan_CreateSurface"); GET2(PixelSize, "SDL_GetWindowSizeInPixels", "SDL_Vulkan_GetDrawableSize"); +#endif GET(GetKeyboardState, "SDL_GetKeyboardState"); GET(GetMouseState, "SDL_GetMouseState"); GET(WaitEventTimeout, "SDL_WaitEventTimeout"); @@ -126,13 +132,21 @@ void hos_sdl_identity(const char *name, const char *app_id) { const char *hos_sdl_error(void) { return version ? CALL(const char *(*)(void), GetError)() : "no SDL"; } /* ---- the window ---- */ -enum { WIN_SHOWN2 = 0x4, WIN_RESIZABLE = 0x20, WIN_HIGH_DENSITY = 0x2000, WIN_VULKAN = 0x10000000, CENTRED = 0x2FFF0000 }; +enum { + WIN_SHOWN2 = 0x4, WIN_RESIZABLE = 0x20, WIN_HIGH_DENSITY = 0x2000, WIN_VULKAN = 0x10000000, + WIN_METAL = 0x20000000, CENTRED = 0x2FFF0000 +}; void *hos_sdl_window(const char *title, int width, int height) { +#ifdef __APPLE__ + const uint64_t flags = WIN_RESIZABLE | WIN_HIGH_DENSITY | WIN_METAL; +#else + const uint64_t flags = WIN_RESIZABLE | WIN_HIGH_DENSITY | WIN_VULKAN; +#endif if (version == 2) return CALL(void *(*)(const char *, int, int, int, int, uint32_t), CreateWindow)( - title, CENTRED, CENTRED, width, height, WIN_SHOWN2 | WIN_RESIZABLE | WIN_HIGH_DENSITY | WIN_VULKAN); + title, CENTRED, CENTRED, width, height, WIN_SHOWN2 | (uint32_t)flags); void *w = CALL(void *(*)(const char *, int, int, uint64_t), CreateWindow)(title, width, height, - WIN_RESIZABLE | WIN_HIGH_DENSITY | WIN_VULKAN); + flags); if (w) CALL(_Bool (*)(void *, int, int), SetWindowPosition)(w, CENTRED, CENTRED); return w; } @@ -169,6 +183,10 @@ void hos_sdl_pixel_size(void *window, int *width, int *height) { if (version == 3) CALL(_Bool (*)(void *, int *, int *), PixelSize)(window, width, height); else CALL(void (*)(void *, int *, int *), PixelSize)(window, width, height); } +#ifdef __APPLE__ +void *hos_sdl_metal_create_view(void *window) { return CALL(void *(*)(void *), Metal_CreateView)(window); } +void *hos_sdl_metal_get_layer(void *view) { return CALL(void *(*)(void *), Metal_GetLayer)(view); } +#endif const uint8_t *hos_sdl_keys(int *count) { /* (SDL3's bool and SDL2's Uint8: a byte each) */ return CALL(const uint8_t *(*)(int *), GetKeyboardState)(count); } diff --git a/host/hos_sdl.h b/host/hos_sdl.h index f2f88288..4285112d 100644 --- a/host/hos_sdl.h +++ b/host/hos_sdl.h @@ -23,6 +23,10 @@ void hos_sdl_relative_mouse(void *window, int on); int hos_sdl_desktop_size(int *width, int *height); int hos_sdl_vulkan_surface(void *window, void *instance, void *surface_out); /* (VkInstance, VkSurfaceKHR *): 1 if made */ void hos_sdl_pixel_size(void *window, int *width, int *height); +#ifdef __APPLE__ +void *hos_sdl_metal_create_view(void *window); +void *hos_sdl_metal_get_layer(void *view); +#endif const uint8_t *hos_sdl_keys(int *count); /* the keyboard by scancode (USB HID usage), 1 a key down */ uint32_t hos_sdl_mouse_buttons(void); /* 1 left, 2 middle, 4 right */ void hos_sdl_icon(void *window, const char *png); /* (SDL 3.4's PNG loader: nothing on SDL2 or an older SDL3) */ 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/display_sdl.c b/host/posix/display_sdl.c index 65eaa2cf..8f3c8d07 100644 --- a/host/posix/display_sdl.c +++ b/host/posix/display_sdl.c @@ -9,6 +9,9 @@ start so). Mouse: a click in the window captures it (relative motion, for nn_hid.c), Esc or leaving releases it. */ #include "../hos.h" #include "../hos_sdl.h" +#ifdef __APPLE__ +#include +#endif /* (the window's app id: the applications menu's entry names it as its StartupWMClass, builder/linux/hookshot_builder.py) */ #define HOS_APP_ID "io.github.NotRexed.Hookshot" @@ -16,7 +19,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); @@ -74,13 +79,26 @@ static void keys_update(void) { /* ---- the window ---- */ static void *window; +#ifdef __APPLE__ +static void *metal_view, *metal_layer; +#endif 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 hos_sdl_vulkan_surface(w, instance, surface); } static void drawable_size(void *w, int *width, int *height) { hos_sdl_pixel_size(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) { + hos_sdl_pixel_size(w, width, height); +} +#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; @@ -98,13 +116,17 @@ static void set_fullscreen(int on) { } static DWORD WINAPI renderer_main(void *p) { +#ifdef __APPLE__ + const int ok = p ? rx_start(p) : 0; +#else (void)p; 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; - hos_sdl_pixel_size(window, &w, &h); + window_pixel_size(window, &w, &h); if (w > 0 && h > 0) rx_resize(w, h); } SetEvent(renderer_up); @@ -186,7 +208,9 @@ void hos_display_loop(void) { SetEvent(renderer_up); for (;;) Sleep(1000); } +#ifndef __APPLE__ vk_window.window = window; +#endif hos_posix_window[0] = 1; { /* the window's icon: hookshot.png beside the game (make_game.py puts it there), for X11 and the compositors that take one from the window; the others find it through the menu entry */ @@ -197,7 +221,14 @@ void hos_display_loop(void) { hos_sdl_icon(window, png); } if (getenv("HOS_FULLSCREEN") && atoi(getenv("HOS_FULLSCREEN"))) set_fullscreen(1); +#ifdef __APPLE__ + metal_view = hos_sdl_metal_create_view(window); + metal_layer = metal_view ? hos_sdl_metal_get_layer(metal_view) : NULL; + if (!metal_layer) hos_log("display: SDL_Metal_CreateView did not provide a Metal layer"); + CloseHandle(CreateThread(NULL, 0, renderer_main, metal_layer, 0, NULL)); +#else CloseHandle(CreateThread(NULL, 0, renderer_main, NULL, 0, NULL)); +#endif for (;;) { hos_sdl_pads_pump(); if (hos_sdl_wait_event(&e, 4)) { @@ -211,7 +242,7 @@ void hos_display_loop(void) { case HOS_SDL_EV_RESIZED: if (renderer) { int w = 0, h = 0; - hos_sdl_pixel_size(window, &w, &h); + window_pixel_size(window, &w, &h); rx_resize(w, h); } break; 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 94191cc9..f03f5509 100644 --- a/host/posix/win32_posix.c +++ b/host/posix/win32_posix.c @@ -1,6 +1,10 @@ -/* (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 +#ifdef __APPLE__ +#define _XOPEN_SOURCE 700 +#define _DARWIN_C_SOURCE +#endif #include #include @@ -9,10 +13,17 @@ #include #include #include +#ifdef __APPLE__ +#include +#include +#include +#include +#else #include +#include +#endif #include #include -#include #include #include @@ -50,9 +61,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 +85,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,14 +103,87 @@ static void monitor_init(pthread_mutex_t *mu, pthread_cond_t *cv) { } \ } while (0) -/* ---- futexes: condition variables, WaitOnAddress ---- */ -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; } + +/* ---- 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); +} void WakeConditionVariable(CONDITION_VARIABLE *cv) { __atomic_add_fetch(&cv->seq, 1, __ATOMIC_SEQ_CST); futex(&cv->seq, FUTEX_WAKE_PRIVATE, 1, NULL); @@ -135,6 +234,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 +295,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 +319,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 +374,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 +386,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 +402,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 +423,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 +473,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 +494,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 +506,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 +606,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; @@ -645,7 +777,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) discard_pages((void *)lo, hi - lo); + if (hi > lo) { +#ifdef __APPLE__ + memset((void *)lo, 0, hi - lo); +#endif + discard_pages((void *)lo, hi - lo); + } return addr; } if (addr && (type & MEM_COMMIT) && !(type & MEM_RESERVE)) { @@ -671,6 +808,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 discard_pages((void *)lo, hi - lo) == 0 ? TRUE : fail_errno(); } if (type & MEM_RELEASE) { @@ -705,6 +845,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; @@ -730,18 +890,33 @@ 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; -} -/* a Windows library name, as the Linux one: SDL3.dll -> libSDL3.so.0, 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) { +#endif +} +/* Windows library names mapped to their POSIX equivalents. */ +static const char *posix_name(const char *base) { +#ifdef __APPLE__ + static const char *map[][2] = {{"sdl3.dll", "libSDL3.dylib"}, {"sdl2.dll", "libSDL2-2.0.0.dylib"}, + {"vulkan-1.dll", "libvulkan.1.dylib"}}; +#else static const char *map[][2] = {{"sdl3.dll", "libSDL3.so.0"}, {"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; @@ -753,14 +928,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); @@ -799,7 +978,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); @@ -1082,20 +1263,52 @@ static struct sigaction previous[32]; static pthread_mutex_t handlers_mu = PTHREAD_MUTEX_INITIALIZER; static void context_from(CONTEXT *c, const ucontext_t *uc) { +#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; + 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) { +#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; + 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; @@ -1109,7 +1322,13 @@ 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; +#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) */ +#endif rec.ExceptionInformation[1] = (ULONG_PTR)si->si_addr; } EXCEPTION_POINTERS ep = {&rec, &ctx}; @@ -1232,8 +1451,13 @@ 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 *); /* (SDL3's and SDL2's alike; its result unread) */ +#ifdef __APPLE__ + void *sdl = dlopen("libSDL3.dylib", RTLD_NOW | RTLD_NOLOAD); + if (!sdl) sdl = dlopen("libSDL2-2.0.0.dylib", RTLD_NOW | RTLD_NOLOAD); +#else void *sdl = dlopen("libSDL3.so.0", RTLD_NOW | RTLD_NOLOAD); if (!sdl) 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..98963e42 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__) || defined(__arm64__) +#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 ebd912a9..6133f8e7 100644 --- a/host/rx_backend.c +++ b/host/rx_backend.c @@ -228,6 +228,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"; @@ -240,6 +243,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 21a2bae1..bb8747b8 100644 --- a/nx2/build.py +++ b/nx2/build.py @@ -46,20 +46,23 @@ 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 = not toolchain.windows() # (hookshot: RX_TARGET=windows on Linux builds the Windows game, with llvm-mingw) +WINDOWS = sys.platform == "win32" +LINUX = sys.platform.startswith("linux") +MACOS = sys.platform == "darwin" +POSIX_HOST = LINUX or MACOS +# 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 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 = 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 []) def no_throttle(handle=None): @@ -85,18 +88,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(): @@ -332,7 +337,7 @@ def ffmpeg_linux(): return None -if LINUX: +if POSIX_HOST: FFMPEG_LX = ffmpeg_linux() FFMPEG = None @@ -343,7 +348,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: @@ -370,16 +375,18 @@ def ffmpeg_version_key(): # 0xc000001d. The game is built on the PC it runs on: a CPU without x86-64-v3's features gets x86-64-v2, or x86-64) from host_cpu import host_march # (make_game.py asks it too: the AppImage's renderer) -MARCH = os.environ.get("RX_MARCH") or host_march() -if MARCH != "x86-64-v3": +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 @@ -606,9 +613,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) @@ -642,18 +651,20 @@ def main(): fp(deps), fp(norm(fl)), fp(norm(hot_fl)), code.hexdigest()[:10], sum(1 for x in srcs if os.path.basename(x).startswith("code_"))), flush=True) # 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 []) + \ - ["-I" + HOST, "-I" + rt64, "-I" + gen, "-Wno-everything", "-O2", "-g0", "-mcx16", "-DA64_POLLING"] + WRITE_TRACK + host_fl = target() + CPU + (["-I" + POSIX, "-D_GNU_SOURCE"] if POSIX_HOST else []) + \ + ["-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 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: @@ -665,13 +676,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] @@ -682,7 +697,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 @@ -793,25 +808,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() @@ -831,17 +859,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) - 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) - # (hookshot) a shared FFmpeg's DLLs beside it (an LGPL shared build, as cross builds find: the static one needs none) - for l in FFMPEG_LIBS if FFMPEG and os.path.isdir(os.path.join(FFMPEG, "bin")) else (): + if wgpu: import shutil - for f in os.listdir(os.path.join(FFMPEG, "bin")): - if f.startswith(l + "-") and f.endswith(".dll"): - shutil.copyfile(os.path.join(FFMPEG, "bin", f), os.path.join(out, f)) + 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") @@ -855,7 +881,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 53617f67..bcb42383 100644 --- a/nx2/make_game.py +++ b/nx2/make_game.py @@ -24,6 +24,7 @@ import hashlib import json import os +import platform import re import shutil import stat @@ -39,12 +40,15 @@ STARTED = time.time() # (the build's total time, kept in build_times.json and said as @@times) sys.path[:0] = [HERE, os.path.join(ROOT, "third_party", "a64-kit", "tools")] # (python -I leaves the script folder off) import toolchain as _toolchain -# (hookshot) RX_TARGET=windows on Linux: the Windows game, with llvm-mingw's Linux release (HOOKSHOT_MINGW, or llvm-mingw -# beside the builder), to run under Proton or copy to a Windows PC -LINUX, CROSS = not _toolchain.windows(), _toolchain.windows() and os.name != "nt" -# (SDL2.dll is sdl2-compat: it loads SDL3.dll from beside the exe. Left out of bin\ since v2.0.0, it said "Failed loading SDL3 -# library" three times and no controller worked: Discord #bug-reporting, 11-08) -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"] +WINDOWS = _toolchain.windows() +MACOS = sys.platform == "darwin" and not WINDOWS +LINUX = sys.platform.startswith("linux") and not WINDOWS +CROSS = WINDOWS and os.name != "nt" +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"]) 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)"), @@ -84,7 +88,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 @@ -411,6 +417,61 @@ 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 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 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 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")] + if ffmpeg: + headers = ("libavcodec/avcodec.h", "libswscale/swscale.h", "libswresample/swresample.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") + 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", "arm64"], + capture_output=True, text=True, encoding="utf-8", errors="replace") + if r.returncode: + 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") + 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] + 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 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 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 arm64 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: @@ -429,6 +490,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 @@ -514,13 +586,12 @@ 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(): 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 cc = toolchain.find("clang") @@ -581,7 +652,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") @@ -661,8 +732,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: - _drop_stale_linux(work) + if os.name != "nt": return sys.path.insert(0, os.path.join(ROOT, "third_party", "a64-kit", "tools")) import hashlib @@ -862,8 +932,9 @@ def parse_args(): 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-windows" if CROSS else "hookshot-builder") # (the objects apart) + 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-windows" if CROSS else "hookshot-builder") if os.name != "nt" 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) 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 eef77882..4452ea30 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 194d56c3..39ae7e8e 100755 --- a/scripts/linux/deps.sh +++ b/scripts/linux/deps.sh @@ -16,6 +16,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/Builder.applescript b/scripts/macos/Builder.applescript new file mode 100644 index 00000000..b43e391c --- /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 " & 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 new file mode 100644 index 00000000..34179b23 --- /dev/null +++ b/scripts/macos/README.txt @@ -0,0 +1,20 @@ +Hookshot Builder for macOS (Apple Silicon) + +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 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 +watch the build progress. For command-line use, 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-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 new file mode 100644 index 00000000..2138697d --- /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 = [ + *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"), + "-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..d4a11496 --- /dev/null +++ b/scripts/macos/deps.sh @@ -0,0 +1,101 @@ +#!/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} +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 +WGPU_VER=v29.0.1.1 +MIN_MACOS=13.3 + +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" +} + +# ---- 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-aarch64-apple-darwin-install_only.tar.gz" python.tar.gz + tar -xzf "$DL/python.tar.gz" -C "$DEPS" + "$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 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=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" + cp "$DEPS/src-sdl2/LICENSE.txt" "$DEPS/sdl2/" + rm -rf "$DEPS/src-sdl2" +fi + +# ---- FFmpeg, static and position-independent for the game executable ---- +P=$DEPS/ffmpeg +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" + 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 \ + --enable-avcodec --enable-swscale --enable-swresample --enable-avutil \ + --enable-decoder=vp8,vp9,av1 --enable-parser=vp8,vp9,av1 + make -j"$JOBS" + make install + ) + 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 arm64 + done +fi + +# ---- 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-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 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 +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/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..fa88f9a5 --- /dev/null +++ b/scripts/macos/package-builder.sh @@ -0,0 +1,59 @@ +#!/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} +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" +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" +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 "$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" + +APP=$ROOT/HookshotBuilder.app +osacompile -o "$APP" scripts/macos/Builder.applescript +mkdir -p "$APP/Contents/Resources" +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" diff --git a/tests/run_all.py b/tests/run_all.py index b61f843b..72b0b61e 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. @@ -57,14 +68,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_finish_output.py b/tests/test_finish_output.py index b1d9ef58..af04b6b5 100644 --- a/tests/test_finish_output.py +++ b/tests/test_finish_output.py @@ -70,8 +70,8 @@ def finish(self, launcher=False): with mock.patch.object(make_game, "BESIDE", ["hookshot.exe"]): # Comprehensions use globals. Keep one namespace, as in production, and discover only authored setup files. namespace = dict(make_game.__dict__, built=str(self.built), out=str(self.out), work=str(self.work), - ROOT=str(self.root / "tools"), LINUX=False if launcher else make_game.LINUX) - exec(FINISH, namespace) + game_dir=str(self.out), launcher=None, ROOT=str(self.root / "tools"), LINUX=False) + exec(FINISH, namespace, namespace) def assert_installed_unchanged(self): self.assertEqual(self.destination.read_bytes(), b"existing synthetic executable") diff --git a/tests/test_macos_build_path.py b/tests/test_macos_build_path.py new file mode 100644 index 00000000..b857293a --- /dev/null +++ b/tests/test_macos_build_path.py @@ -0,0 +1,189 @@ +"""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_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_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', 'CPU')} " + "| {'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": "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): + 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_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, + 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", "arm64"], + ["lipo", str(library), "-verify_arch", "arm64"], + ] +""" + 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") + 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) + 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, + 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("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", "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, + 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) + + 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") + sdl = (ROOT / "host" / "hos_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) + renderer_thread = window[window.index("static DWORD WINAPI renderer_main"):window.index("void hos_renderer_wait")] + display_loop = window[window.index("void hos_display_loop"): ] + self.assertNotIn("hos_sdl_metal_create_view", renderer_thread) + self.assertLess(display_loop.index("hos_sdl_metal_create_view(window)"), display_loop.index("CreateThread")) + self.assertIn('GET(Metal_CreateView, "SDL_Metal_CreateView")', sdl) + self.assertIn("WIN_METAL", sdl) + + def test_linux_guest_memory_optimizations_are_guarded_from_macos(self): + source = (ROOT / "host" / "hos_main.c").read_text(encoding="utf-8") + self.assertIn("#ifdef __linux__\n /* The window is a memfd's", source) + self.assertIn("#ifdef __linux__\n MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE | MAP_NORESERVE,", source) + self.assertIn("#ifdef __linux__\n if (!getenv(\"HOS_THP\")) madvise", source) + self.assertIn("#ifdef __linux__\n return madvise(p, n, hos_guest_memfd >= 0 ? MADV_REMOVE", source) + self.assertIn(" return 1;\n#endif\n}", source) + + +if __name__ == "__main__": + unittest.main(verbosity=2) 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/lift/build_emit.py b/third_party/a64-kit/lift/build_emit.py index d077ce5f..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++", host=True)), "-std=c++20", "-O2", "-shared", "-o", OUT, SRC[0], - "-Wno-everything"] + toolchain.native_target(host=True) + toolchain.native_link(host=True) + 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)) 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 450d4fd6..3e8c392a 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__)) @@ -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): @@ -21,8 +25,17 @@ def mingw(host=False): def find(tool, host=False): - if mingw(host) and (tool in ("clang", "clang++", "lld") or tool.startswith("llvm-")): - path = os.path.join(mingw(host), "bin", ("ld.lld" if tool == "lld" else tool) + (".exe" if os.name == "nt" else "")) + 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) + if result.returncode == 0: + path = result.stdout.strip() + if path: + return path + 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() @@ -36,6 +49,30 @@ def find(tool, host=False): 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 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"