diff --git a/.github/workflows/macos-dmg.yml b/.github/workflows/macos-dmg.yml new file mode 100644 index 0000000000..a65b7f8947 --- /dev/null +++ b/.github/workflows/macos-dmg.yml @@ -0,0 +1,53 @@ +name: macOS build and package + +on: + push: + paths-ignore: ['**.md'] + pull_request: + paths: ['source/**', 'port/**', 'tools/**', 'configure.py', '.github/workflows/macos-dmg.yml'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: macos-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: macos-26 + timeout-minutes: 45 + env: + HALO_MACOS_LLVM_BIN: /opt/homebrew/opt/llvm@22/bin + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Public toolchain + run: | + test "$(uname -m)" = arm64 + brew install llvm@22 lld@22 sdl3 ninja + printf '%s\n' /opt/homebrew/opt/lld@22/bin >> "$GITHUB_PATH" + python3 tools/macos_setup.py + - name: Build app + run: >- + python3 tools/macos_build.py --repository "$GITHUB_REPOSITORY" + --build-number "$GITHUB_RUN_NUMBER.$GITHUB_RUN_ATTEMPT" + - name: Create and verify ad-hoc DMG + run: | + python3 tools/macos_release.py local-dmg --output dist/macos/Halo-CE-Universal-macos-arm64.dmg + hdiutil verify dist/macos/Halo-CE-Universal-macos-arm64.dmg + dmg_mount="$(mktemp -d "$RUNNER_TEMP/halo-dmg.XXXXXX")" + hdiutil attach dist/macos/Halo-CE-Universal-macos-arm64.dmg -mountpoint "$dmg_mount" -nobrowse -readonly + trap 'hdiutil detach "$dmg_mount"' EXIT + codesign --verify --deep --strict "$dmg_mount/Halo CE Universal.app" + lipo "$dmg_mount/Halo CE Universal.app/Contents/MacOS/halo" -verify_arch arm64 + - uses: actions/upload-artifact@v4 + with: + name: halo-macos-arm64-dmg + path: dist/macos/Halo-CE-Universal-macos-arm64.dmg + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/README.md b/README.md index 2db745cc1f..0da6547d4f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# Halo: Combat Evolved for Linux, Windows and Android +# Halo: Combat Evolved for Linux, Windows, Android and macOS [![Join our Discord](https://invidget.switchblade.xyz/9gqcHyr5km)](https://discord.gg/9gqcHyr5km) This project is a port of the Halo: Combat Evolved decompilation to Linux, -Windows and Android. The decompilation is of the Xbox build 2342 +Windows, Android and Apple Silicon macOS. The decompilation is of the Xbox build 2342 (`cachebeta.exe`, SHA-256 `4cc87b45f721270392a96f1674ed2b5cd4a7bb4355faeab4531d1cf1884d9520`). @@ -22,12 +22,17 @@ builds of the latest release: | Linux | [halo-linux-release.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-linux-release.zip) | [halo-linux-debug.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-linux-debug.zip) | | Windows | [halo-windows-release.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-windows-release.zip) | [halo-windows-debug.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-windows-debug.zip) | | Android | [halo-android-release.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-android-release.zip) | [halo-android-debug.zip](https://github.com/cybersecurity/halo-ce-universal/releases/latest/download/halo-android-debug.zip) | +| macOS (Apple Silicon) | [Mac app and DMG builds](../../actions/workflows/macos-dmg.yml) | [Build locally](port/macos/README.md#build) | + +For Mac, open a successful **macOS build and package** workflow run and download its DMG +artifact, then drag the app to Applications. The app asks for your own game data +on first launch. Automated artifacts are ad-hoc signed and are not notarized. Use the release build to play. The debug build stops at the first failed assertion and writes it to the log. Use the debug build to find and report problems. -The game updates itself. At start-up it looks for a newer release, and asks +On Linux, Windows and Android, the game updates itself. At start-up it looks for a newer release, and asks if you want to install it. Refer to "Updates" in [port/linux/README.md](port/linux/README.md#updates). @@ -51,6 +56,9 @@ so players of the two versions can play together. On Linux and Windows, the game puts `maps/` next to the executable. On Android, copy the disc image to the phone first. The app puts `maps/` in its data folder. Refer to [port/android/README.md](port/android/README.md). +On Mac, the native chooser can import a disc image or select an extracted game +folder. Imported data and saves stay in Application Support, outside the app. +See [the Mac instructions](port/macos/README.md#install-and-game-data). ## Platforms @@ -61,6 +69,7 @@ Each platform has its own instructions: | Linux (32-bit x86 executable, OpenGL 4.5, SDL3) | [port/linux/README.md](port/linux/README.md) | | Windows (32-bit x86 executable, OpenGL 4.5, SDL3) | [port/windows/README.md](port/windows/README.md) | | Android (arm64 app, OpenGL ES 3, SDL3) | [port/android/README.md](port/android/README.md) | +| macOS (Apple Silicon app, ANGLE/Metal, SDL3) | [port/macos/README.md](port/macos/README.md) | The Linux README also gives the controls, the settings and the multiplayer functions. These are almost the same on all platforms. @@ -95,6 +104,7 @@ To build the game: | `ninja linux` | `build/linux/halo` | | `ninja windows` (on Windows) | `build/windows/halo.exe` and `SDL3.dll` | | `ninja android_apk` | `port/android/app/build/outputs/apk/debug/app-debug.apk` | +| `python3 tools/macos_build.py` (after [Mac setup](port/macos/README.md#build)) | `build/macos/Halo CE Universal.app` | If you enter `ninja` without a target, ninja builds the game for the computer that you use. diff --git a/configure.py b/configure.py index 3bff193b0b..92c31f55e5 100644 --- a/configure.py +++ b/configure.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # root build script: writes build.ninja for the native ports (Linux, Windows, -# Android) +# Android, macOS) import argparse import io @@ -73,6 +73,11 @@ type=str, help="clang with the arm64_32 target for the Android guest (default: clang)", ) +parser.add_argument("--macos", action="store_true", help="generate the rebased Apple Silicon guest target") +parser.add_argument("--android-guest-only", action="store_true", help="build the portable ARM image without an Android host") +parser.add_argument("--android-guest-llvm-bin", type=Path, help="directory containing llvm-ar and ld.lld") +parser.add_argument("--android-guest-gl-include", type=Path, help="directory containing Khronos GLES headers") +parser.add_argument("--android-guest-builtins", type=Path, help="optional AArch64 compiler builtins archive") args = parser.parse_args() # the settings the builds read @@ -86,7 +91,12 @@ port_pgo=args.pgo, port_pgo_profile=args.pgo_profile, android_ndk=args.android_ndk, - android_guest_cc=args.android_guest_cc, + android_guest_cc="tools/macos_guest_cc.py" if args.macos else args.android_guest_cc, + android_guest_only=args.android_guest_only or args.macos, + android_guest_llvm_bin=args.android_guest_llvm_bin, + android_guest_gl_include=args.android_guest_gl_include, + android_guest_builtins=args.android_guest_builtins, + macos=args.macos, ) @@ -135,7 +145,7 @@ def is_windows() -> bool: # the build for this computer, where it could be generated (the Windows # build is left out when SDL cannot be fetched, for instance) -default = "windows" if is_windows() else "linux" +default = "macos_guest" if args.macos else "windows" if is_windows() else "linux" if f"\nbuild {default}: " in out.getvalue(): n.comment("Default rule: the build for this computer") n.default(default) diff --git a/port/android/guest/runtime/guest_host.h b/port/android/guest/runtime/guest_host.h index 5a9babafc7..229f4005c8 100644 --- a/port/android/guest/runtime/guest_host.h +++ b/port/android/guest/runtime/guest_host.h @@ -59,7 +59,15 @@ long long host_sdl_ticks(void); long long host_sdl_thread_id(void); unsigned int host_sdl_create_window(const char *title, int width, int height, long long flags); void host_sdl_window_size_in_pixels(unsigned int window, int *width, int *height); +void host_sdl_window_size(unsigned int window, int *width, int *height); +void host_sdl_warp_mouse(unsigned int window, float x, float y); int host_sdl_set_relative_mouse(unsigned int window, int enabled); +#ifdef HALO_MACOS +/* Native display actions and the PC menu share the guest's display.mode. */ +int host_sdl_display_mode(void); +int host_sdl_apply_display(int mode, int width, int height); +void host_sdl_request_quit(void); +#endif int host_sdl_gl_set_attribute(int attribute, int value); unsigned int host_sdl_gl_create_context(unsigned int window); int host_sdl_gl_make_current(unsigned int window, unsigned int context); diff --git a/port/android/guest/runtime/guest_sdl.c b/port/android/guest/runtime/guest_sdl.c index 7bd89689a6..5bff6362df 100644 --- a/port/android/guest/runtime/guest_sdl.c +++ b/port/android/guest/runtime/guest_sdl.c @@ -128,6 +128,21 @@ bool SDL_SetWindowRelativeMouseMode(SDL_Window *window, bool enabled) return host_sdl_set_relative_mouse((unsigned int)window, enabled) != 0; } +bool SDL_GetWindowSize(SDL_Window *window, int *width, int *height) +{ + int w = 0, h = 0; + + host_sdl_window_size((unsigned int)window, &w, &h); + if (width) *width = w; + if (height) *height = h; + return w > 0 && h > 0; +} + +void SDL_WarpMouseInWindow(SDL_Window *window, float x, float y) +{ + host_sdl_warp_mouse((unsigned int)window, x, y); +} + bool SDL_GL_SetAttribute(SDL_GLAttr attribute, int value) { return host_sdl_gl_set_attribute((int)attribute, value) != 0; diff --git a/port/android/host/host.h b/port/android/host/host.h index eef727c6a7..77ac4a926d 100644 --- a/port/android/host/host.h +++ b/port/android/host/host.h @@ -9,6 +9,10 @@ port/android/include/halo_android_abi.h for the guest contract. #ifndef __HALO_ANDROID_HOST_H #define __HALO_ANDROID_HOST_H +#ifdef HALO_MACOS +#include "../../macos/host/host.h" +#else + #include #include #include @@ -93,4 +97,7 @@ void *host_resolve_import(const char *name); void *host_gl_resolve(const char *name); +#define guest_code_pointer(address) ((void *)(uintptr_t)(address)) + +#endif /* HALO_MACOS */ #endif diff --git a/port/android/host/host_gl.c b/port/android/host/host_gl.c index 62d1d0a23e..cdf74733e0 100644 --- a/port/android/host/host_gl.c +++ b/port/android/host/host_gl.c @@ -15,6 +15,12 @@ already have host types by then. Only strings need copying back. void *host_gl_resolve(const char *name) { +#ifdef HALO_MACOS + /* Darwin packs stack arguments differently from the guest's Linux ABI. */ + extern void *host_gl_bridge(const char *name); + void *function = host_gl_bridge(name); + return function ? function : (void *)eglGetProcAddress(name); +#else static void *library; void *function = NULL; @@ -25,6 +31,7 @@ void *host_gl_resolve(const char *name) if (!function) function = (void *)eglGetProcAddress(name); return function; +#endif } void host_gl_get_string(uint32_t name, int index, char *buffer, uint32_t size) @@ -124,7 +131,11 @@ because host_gl_wait_frame releases the ring slot first. */ void host_gl_buffer_write(uint32_t target, uint32_t offset, uint32_t size, const void *data) { void *mapping = glMapBufferRange(target, offset, size, - GL_MAP_WRITE_BIT | GL_MAP_UNSYNCHRONIZED_BIT); + GL_MAP_WRITE_BIT | GL_MAP_UNSYNCHRONIZED_BIT +#ifdef HALO_MACOS + | GL_MAP_INVALIDATE_RANGE_BIT +#endif + ); if (!mapping) { diff --git a/port/android/host/host_sdl.c b/port/android/host/host_sdl.c index 0167e4531e..9007bfd155 100644 --- a/port/android/host/host_sdl.c +++ b/port/android/host/host_sdl.c @@ -83,10 +83,12 @@ static void *handle_get(uint32_t handle, int type) /* ---------- general */ +#ifndef HALO_MACOS int host_sdl_init(uint32_t flags) { return SDL_Init((SDL_InitFlags)flags); } +#endif int host_sdl_set_hint(const char *name, const char *value) { @@ -120,10 +122,12 @@ int64_t host_sdl_thread_id(void) /* ---------- video */ +#ifndef HALO_MACOS uint32_t host_sdl_create_window(const char *title, int width, int height, int64_t flags) { return handle_new(_handle_window, SDL_CreateWindow(title, width, height, (SDL_WindowFlags)flags)); } +#endif void host_sdl_window_size_in_pixels(uint32_t window, int *width, int *height) { @@ -135,13 +139,29 @@ void host_sdl_window_size_in_pixels(uint32_t window, int *width, int *height) SDL_GetWindowSizeInPixels(object, width, height); } +#ifndef HALO_MACOS int host_sdl_set_relative_mouse(uint32_t window, int enabled) { SDL_Window *object = handle_get(window, _handle_window); return object ? SDL_SetWindowRelativeMouseMode(object, enabled != 0) : 0; } +#endif +void host_sdl_window_size(uint32_t window, int *width, int *height) +{ + SDL_Window *object = handle_get(window, _handle_window); + *width = *height = 0; + if (object) SDL_GetWindowSize(object, width, height); +} + +void host_sdl_warp_mouse(uint32_t window, float x, float y) +{ + SDL_Window *object = handle_get(window, _handle_window); + if (object) SDL_WarpMouseInWindow(object, x, y); +} + +#ifndef HALO_MACOS int host_sdl_gl_set_attribute(int attribute, int value) { return SDL_GL_SetAttribute((SDL_GLAttr)attribute, value); @@ -170,9 +190,11 @@ int host_sdl_gl_swap_window(uint32_t window) return object ? SDL_GL_SwapWindow(object) : 0; } +#endif /* ---------- events */ +#ifndef HALO_MACOS int host_sdl_poll_event(void *event) { SDL_Event host_event; @@ -184,6 +206,7 @@ int host_sdl_poll_event(void *event) memcpy(event, &host_event, sizeof(host_event)); return 1; } +#endif /* ---------- gamepads */ @@ -342,6 +365,8 @@ uint32_t host_sdl_open_audio_stream(uint32_t device, const void *spec, uint32_t struct audio_binding *binding = SDL_calloc(1, sizeof(*binding)); SDL_AudioStream *stream; + if (!binding) + return 0; binding->callback = callback; binding->userdata = userdata; pthread_mutex_init(&binding->lock, NULL); @@ -394,14 +419,18 @@ void host_sdl_get_clipboard_text(char *buffer, uint32_t size) SDL_free(text); } +#ifndef HALO_MACOS int host_sdl_show_toast(const char *message, int duration, int gravity, int x, int y) { return SDL_ShowAndroidToast(message, duration, gravity, x, y) ? 1 : 0; } +#endif /* ---------- a message for the player (a host of another network version) */ +#ifndef HALO_MACOS int host_sdl_show_simple_message_box(uint32_t flags, const char *title, const char *message) { return SDL_ShowSimpleMessageBox((SDL_MessageBoxFlags)flags, title, message, NULL) ? 1 : 0; } +#endif diff --git a/port/android/host/host_thread.c b/port/android/host/host_thread.c index bc939b8b22..14b5f95dcc 100644 --- a/port/android/host/host_thread.c +++ b/port/android/host/host_thread.c @@ -58,7 +58,11 @@ static int on_guest_stack(void) { uint64_t sp = (uint64_t)__builtin_frame_address(0); +#ifdef HALO_MACOS + return (sp & ~UINT64_C(0xffffffff)) == HALO_MACOS_BIAS; +#else return sp < 0x100000000ULL; +#endif } /* ---------- calling into the guest */ @@ -70,13 +74,13 @@ uint32_t host_call_guest(uint32_t function, uint32_t a, uint32_t b, uint32_t c, if (!on_guest_stack()) host_fatal("guest code called on a thread without a guest stack"); if (!guest_tp) - ((guest_function)(uintptr_t)host_image.header->thread_attach)(0, 0, 0, 0); - return ((guest_function)(uintptr_t)function)(a, b, c, d); + ((guest_function)guest_code_pointer(host_image.header->thread_attach))(0, 0, 0, 0); + return ((guest_function)guest_code_pointer(function))(a, b, c, d); } void host_run_guest_main(uint32_t boot) { - ((void (*)(uint32_t))(uintptr_t)host_image.header->start)(boot); + ((void (*)(uint32_t))guest_code_pointer(host_image.header->start))(boot); host_fatal("the guest returned from __guest_start"); } diff --git a/port/android/host_imports.list b/port/android/host_imports.list index bb00cc892a..4dda6e0d75 100644 --- a/port/android/host_imports.list +++ b/port/android/host_imports.list @@ -52,3 +52,5 @@ host_gl_wait_frame host_android_path host_errno +host_sdl_window_size +host_sdl_warp_mouse diff --git a/port/android/include/halo_android_abi.h b/port/android/include/halo_android_abi.h index baa28789c5..55a73c509e 100644 --- a/port/android/include/halo_android_abi.h +++ b/port/android/include/halo_android_abi.h @@ -35,7 +35,12 @@ keeps its heaps low in the address space and fills it upwards */ #define HALO_GUEST_WINDOW_SIZE 0x08000000u #define HALO_GUEST_MAGIC 0x4f4c4148u /* 'HALO' */ +#ifdef HALO_MACOS +/* Darwin rebases guest pointers; Android hosts must reject this image. */ +#define HALO_GUEST_ABI_VERSION 0x10001 +#else #define HALO_GUEST_ABI_VERSION 1 +#endif /* at HALO_GUEST_IMAGE_BASE */ struct halo_guest_header diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c index 8ae83bea79..6d0be75318 100644 --- a/port/linux/game/menu_functions.c +++ b/port/linux/game/menu_functions.c @@ -85,6 +85,10 @@ int config_write(char const *name, char const *value); int config_default(char const *name, char *text, unsigned int size); char const *config_string(char const *name); void platform_display_apply(void); +#ifdef HALO_MACOS +char const *platform_display_mode_name(void); +int platform_display_mode_save(char const *value); +#endif void platform_binding_capture_begin(void); int platform_binding_capture_poll(int *input); void halo_input_name(int input, char *name, unsigned int size); @@ -298,6 +302,13 @@ static boolean setting_text(char const *name, char *text, unsigned int size, boo if (strncmp(name, "profile.", 8)) { +#ifdef HALO_MACOS + if (!default_value && !strcmp(name, "display.mode")) + { + snprintf(text, size, "%s", platform_display_mode_name()); + return TRUE; + } +#endif if (!(default_value ? config_default(name, text, size) : config_text(name, text, size))) return FALSE; /* (display.mode empty: display.fullscreen's, as the window has it: @@ -336,6 +347,10 @@ static boolean setting_write(char const *name, char const *value) { short index; +#ifdef HALO_MACOS + if (!strcmp(name, "display.mode")) + return platform_display_mode_save(value); +#endif if (strncmp(name, "profile.", 8)) return config_write(name, value); for (index = 0; index < NUMBEROF(profile_settings); index++) diff --git a/port/linux/include/StdDef.h b/port/linux/include/StdDef.h index 3465d16218..fef96e174e 100644 --- a/port/linux/include/StdDef.h +++ b/port/linux/include/StdDef.h @@ -1,2 +1,3 @@ -/* the game includes ; Linux file names are case sensitive */ -#include +/* The game includes . Skip this shim when looking up the C + * library header, including on case-insensitive build hosts such as macOS. */ +#include_next diff --git a/port/linux/src/d3d8_gl.c b/port/linux/src/d3d8_gl.c index 43c924d24c..a0530f4305 100644 --- a/port/linux/src/d3d8_gl.c +++ b/port/linux/src/d3d8_gl.c @@ -201,6 +201,7 @@ struct draw_uniforms float bump_matrix[4][4]; float bump_luminance[4][4]; float texture_scale[4][4]; + float texture_border_color[4][4]; float screen_offset; float texture_lod_bias[4]; }; @@ -218,6 +219,7 @@ struct program_entry GLint ps_c0, ps_c1, ps_final_c0, ps_final_c1; GLint fog_color, fog_parameters, alpha_reference; GLint bump_matrix, bump_luminance, texture_scale; + GLint texture_border_color; GLint texture_lod_bias; GLint screen_offset; @@ -346,8 +348,12 @@ struct gl_device unsigned long index_offset; GLuint samplers[D3DTSS_MAXSTAGES]; - GLuint queries[VISIBILITY_TEST_SLOTS]; + /* One extra query is scratch space; result slot zero belongs to the game. */ + GLuint queries[VISIBILITY_TEST_SLOTS + 1]; BOOL query_pending[VISIBILITY_TEST_SLOTS]; +#ifdef HALO_MACOS + GLuint query_results[VISIBILITY_TEST_SLOTS]; +#endif /* the pixels each of the game's pixels covered in the test's target (render_target_get), which its count is divided by */ float query_area[VISIBILITY_TEST_SLOTS]; @@ -951,7 +957,7 @@ static void gl_initialize(void) glBufferData(GL_ELEMENT_ARRAY_BUFFER, INDEX_BUFFER_SIZE, NULL, GL_STREAM_DRAW); #endif glGenSamplers(D3DTSS_MAXSTAGES, device.samplers); - glGenQueries(VISIBILITY_TEST_SLOTS, device.queries); + glGenQueries(VISIBILITY_TEST_SLOTS + 1, device.queries); #ifndef HALO_ANDROID glGenBuffers(1, &device.visibility_results_buffer); glBindBuffer(GL_QUERY_BUFFER, device.visibility_results_buffer); @@ -1151,7 +1157,7 @@ HRESULT WINAPI Direct3D_CreateDevice(UINT adapter, D3DDEVTYPE device_type, void /* ---------- the menus' pointer */ -#ifdef HALO_ANDROID +#if defined(HALO_ANDROID) && !defined(HALO_MACOS) int halo_ui_pointer_update(int menus_active, struct halo_ui_pointer *pointer) { (void)pointer; @@ -1365,6 +1371,25 @@ void WINAPI D3DDevice_InsertCallback(D3DCALLBACKTYPE type, D3DCALLBACK callback, /* ---------- visibility (occlusion) tests */ +#ifdef HALO_MACOS +/* Match the desktop query-buffer path: use the last completed result rather +than spin on a Metal command buffer. Poll before recycling a slot too, since +transparent geometry asks for a result immediately after submitting it. */ +static void visibility_collect(unsigned long index) +{ + GLuint available = 0, samples; + if (!device.query_pending[index]) + return; + glGetQueryObjectuiv(device.queries[index], GL_QUERY_RESULT_AVAILABLE, &available); + if (available) + { + glGetQueryObjectuiv(device.queries[index], GL_QUERY_RESULT, &samples); + device.query_results[index] = samples ? VISIBILITY_ALL_SAMPLES : 0; + device.query_pending[index] = FALSE; + } +} +#endif + void WINAPI D3DDevice_BeginVisibilityTest(void) { if (!device.gl_ready || device.visibility_test_active) @@ -1385,7 +1410,7 @@ void WINAPI D3DDevice_BeginVisibilityTest(void) return; } #endif - glBeginQuery(VISIBILITY_QUERY, device.queries[0]); + glBeginQuery(VISIBILITY_QUERY, device.queries[VISIBILITY_TEST_SLOTS]); } HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index) @@ -1396,8 +1421,6 @@ HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index) return S_OK; device.visibility_test_active = FALSE; index %= VISIBILITY_TEST_SLOTS; - if (!index) - index = 1; #ifdef HALO_ANDROID if (xgpu_capabilities.atomic_counters) { @@ -1407,14 +1430,17 @@ HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index) } #endif glEndQuery(VISIBILITY_QUERY); +#ifdef HALO_MACOS + visibility_collect(index); +#endif /* the target's pixels to a game pixel: the result is a count of the game's pixels (visibility_unscaled), which the game divides by its own test's area (lens flares, rasterizer_lights.c), a split-screen window's or the screen's alike */ device.query_area[index] = target_scale[0] * target_scale[1]; /* swap the scratch query into the requested slot */ - scratch = device.queries[0]; - device.queries[0] = device.queries[index]; + scratch = device.queries[VISIBILITY_TEST_SLOTS]; + device.queries[VISIBILITY_TEST_SLOTS] = device.queries[index]; device.queries[index] = scratch; device.query_pending[index] = TRUE; #ifndef HALO_ANDROID @@ -1446,12 +1472,14 @@ HRESULT WINAPI D3DDevice_GetVisibilityTestResult(DWORD index, UINT *result, ULON if (time_stamp) *time_stamp = 0; index %= VISIBILITY_TEST_SLOTS; - if (!index) - index = 1; if (!device.gl_ready || !device.query_pending[index]) { if (result) +#ifdef HALO_MACOS + *result = device.gl_ready ? device.query_results[index] : 0; +#else *result = 0; +#endif return S_OK; } #ifdef HALO_ANDROID @@ -1465,6 +1493,12 @@ HRESULT WINAPI D3DDevice_GetVisibilityTestResult(DWORD index, UINT *result, ULON return S_OK; } #endif +#ifdef HALO_MACOS + visibility_collect(index); + if (result) + *result = device.query_results[index]; + return S_OK; +#endif #ifndef HALO_ANDROID if (device.visibility_results) { @@ -1975,6 +2009,7 @@ static struct program_entry *program_get(GLuint vertex_shader, GLuint fragment_s entry->bump_matrix = glGetUniformLocation(entry->program, "bump_matrix"); entry->bump_luminance = glGetUniformLocation(entry->program, "bump_luminance"); entry->texture_scale = glGetUniformLocation(entry->program, "texture_scale"); + entry->texture_border_color = glGetUniformLocation(entry->program, "texture_border_color"); entry->texture_lod_bias = glGetUniformLocation(entry->program, "texture_lod_bias"); entry->screen_offset = glGetUniformLocation(entry->program, "screen_offset"); for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++) @@ -2103,7 +2138,10 @@ static GLuint framebuffer_get(GLuint color, GLuint depth); static void copy_level_by_blit(GLuint source, GLuint destination, GLint level, GLsizei width, GLsizei height) { static GLuint draw_framebuffer; + GLint read_binding = 0, draw_binding = 0; + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read_binding); + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw_binding); if (!draw_framebuffer) glGenFramebuffers(1, &draw_framebuffer); glBindFramebuffer(GL_READ_FRAMEBUFFER, framebuffer_get(source, 0)); @@ -2111,7 +2149,9 @@ static void copy_level_by_blit(GLuint source, GLuint destination, GLint level, G glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, destination, level); glDisable(GL_SCISSOR_TEST); glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST); - glBindFramebuffer(GL_FRAMEBUFFER, 0); + /* (the framebuffers bound before, not 0) */ + glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)read_binding); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, (GLuint)draw_binding); /* the blit bypasses the cached state, so the next draw must re-apply it */ xgpu_gl_state_invalidate(); } @@ -2184,6 +2224,35 @@ static GLuint mip_composite_get(const struct xgpu_texture_description *descripti return composite->texture; } +/* Border emulation is exact for a single 2D level. Mipmapped, anisotropic, +3D and cube sampling need different footprints; do not apply a level-zero +coverage estimate to those paths. The shadow render targets have one level. */ +static void texture_border_key(struct nv2a_pixel_shader_key *key, int stage, + GLenum target, const struct xgpu_texture_description *description) +{ +#ifdef HALO_ANDROID + DWORD *state = D3D__TextureState[stage]; + DWORD min_filter = description->hires ? D3DTEXF_LINEAR : state[D3DTSS_MINFILTER]; + DWORD mag_filter = description->hires ? D3DTEXF_LINEAR : state[D3DTSS_MAGFILTER]; + + if (!xgpu_capabilities.border_clamp && target == GL_TEXTURE_2D && description->levels == 1 && + min_filter != D3DTEXF_ANISOTROPIC && mag_filter != D3DTEXF_ANISOTROPIC) + { + key->border_axes[stage] = (state[D3DTSS_ADDRESSU] == D3DTADDRESS_BORDER ? 1 : 0) | + (state[D3DTSS_ADDRESSV] == D3DTADDRESS_BORDER ? 2 : 0); + if (key->border_axes[stage]) + { + key->border_filter[stage] = (min_filter == D3DTEXF_POINT ? 0 : 1) | + (mag_filter == D3DTEXF_POINT ? 0 : 2); + /* A positive minimum LOD forces minification filtering even for + a single-level texture, as configured on the native sampler. */ + if (!description->hires && state[D3DTSS_MAXMIPLEVEL]) + key->border_filter[stage] = (key->border_filter[stage] & 1) ? 3 : 0; + } + } +#endif +} + static void bind_textures(struct nv2a_pixel_shader_key *key, float texture_scale[4][4]) { int stage; @@ -2238,6 +2307,7 @@ static void bind_textures(struct nv2a_pixel_shader_key *key, float texture_scale state_texture(stage, gl_target, gl_texture); state_sampler(stage, device.samplers[stage]); configure_sampler(stage, description.levels > 1, description.hires); + texture_border_key(key, stage, gl_target, &description); if (stage == 0) key->coverage_alpha = description.hires_coverage != FALSE; key->sampler_type[stage] = gl_target == GL_TEXTURE_CUBE_MAP ? _xgpu_sampler_cube : @@ -2469,7 +2539,7 @@ static void gl_check_errors(const char *where) /* the uniforms of the latest draws, converted from these inputs; the serial counts the conversions */ -#define DRAW_UNIFORM_INPUT_COUNT (4 + 4 + 16 + 1 + 16 + 2 + 4 + 1 + 1 + 7 * D3DTSS_MAXSTAGES) +#define DRAW_UNIFORM_INPUT_COUNT (4 + 4 + 16 + 1 + 16 + 2 + 4 + 1 + 1 + 8 * D3DTSS_MAXSTAGES) static DWORD draw_uniform_inputs[DRAW_UNIFORM_INPUT_COUNT]; static struct draw_uniforms draw_uniforms; @@ -2537,13 +2607,6 @@ static struct program_entry *prepare_draw(BOOL immediate) skip++; } } - if (!bind_targets(&has_depth)) - { - stats.skipped_no_target++; - return NULL; - } - apply_raster_state(has_depth); - memset(&key, 0, sizeof(key)); memcpy(key.combiner_state, D3D__RenderState, sizeof(key.combiner_state)); /* constants are uniforms, not part of the program */ @@ -2551,7 +2614,18 @@ static struct program_entry *prepare_draw(BOOL immediate) key.combiner_state[D3DRS_PSFINALCOMBINERCONSTANT0] = 0; key.combiner_state[D3DRS_PSFINALCOMBINERCONSTANT1] = 0; key.texture_modes = D3D__RenderState[D3DRS_PSTEXTUREMODES]; + /* the textures before the target: binding one can copy a render target's + levels into a mipmapped texture by blits (ES without copy image: ANGLE + on Metal), which bind framebuffers of their own and turn the scissor off + - after the target, the draw went to the wrong framebuffer (the water's + reflection, drawn with its ripple map's levels, never showed) */ bind_textures(&key, uniforms.texture_scale); + if (!bind_targets(&has_depth)) + { + stats.skipped_no_target++; + return NULL; + } + apply_raster_state(has_depth); for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++) { key.alpha_kill[stage] = D3D__TextureState[stage][D3DTSS_ALPHAKILL] == D3DTALPHAKILL_ENABLE; @@ -2666,6 +2740,7 @@ static struct program_entry *prepare_draw(BOOL immediate) inputs[count++] = state[D3DTSS_BUMPENVLSCALE]; inputs[count++] = state[D3DTSS_BUMPENVLOFFSET]; inputs[count++] = state[D3DTSS_MIPMAPLODBIAS]; + inputs[count++] = state[D3DTSS_BORDERCOLOR]; } if (!draw_uniforms_serial || memcmp(inputs, draw_uniform_inputs, sizeof(inputs))) { @@ -2703,6 +2778,7 @@ static struct program_entry *prepare_draw(BOOL immediate) converted->bump_luminance[stage][1] = dword_to_float(state[D3DTSS_BUMPENVLOFFSET]); converted->bump_luminance[stage][2] = converted->bump_luminance[stage][3] = 0.0f; converted->texture_lod_bias[stage] = dword_to_float(state[D3DTSS_MIPMAPLODBIAS]); + color_to_vec4(state[D3DTSS_BORDERCOLOR], converted->texture_border_color[stage]); } converted->screen_offset = (float)UI_OFFSET; } @@ -2724,6 +2800,7 @@ static struct program_entry *prepare_draw(BOOL immediate) uniform_vec4(entry->bump_matrix, entry->uniforms.bump_matrix[0], draw_uniforms.bump_matrix[0], 4); uniform_vec4(entry->bump_luminance, entry->uniforms.bump_luminance[0], draw_uniforms.bump_luminance[0], 4); uniform_vec4(entry->texture_scale, entry->uniforms.texture_scale[0], draw_uniforms.texture_scale[0], 4); + uniform_vec4(entry->texture_border_color, entry->uniforms.texture_border_color[0], draw_uniforms.texture_border_color[0], 4); uniform_float(entry->screen_offset, &entry->uniforms.screen_offset, draw_uniforms.screen_offset); uniform_vec4(entry->texture_lod_bias, entry->uniforms.texture_lod_bias, draw_uniforms.texture_lod_bias, 1); return entry; @@ -2978,7 +3055,13 @@ static BOOL mirror_range(unsigned long address, unsigned long size, GLuint *buff unsigned long segment, first, last, page, oldest = ~0UL, newest = 0; BOOL present = TRUE; - if (!size || address < PLATFORM_CONTIGUOUS_BASE || start + size > PLATFORM_CONTIGUOUS_SIZE) +#ifdef HALO_MACOS + /* Stream rebased guest geometry; write-watch can miss map replacements. */ + return FALSE; +#endif + + if (!size || address < PLATFORM_CONTIGUOUS_BASE || start >= PLATFORM_CONTIGUOUS_SIZE || + size > PLATFORM_CONTIGUOUS_SIZE - start) return FALSE; segment = start / MIRROR_SEGMENT_SIZE; if ((start + size - 1) / MIRROR_SEGMENT_SIZE != segment) @@ -3196,6 +3279,73 @@ static void attribute_format(const struct vertex_element *element, GLint *size, } } +#ifdef HALO_MACOS +/* Metal requires four-byte vertex offsets and strides. Xbox model declarations +put SHORT1 at byte 30 of a 32-byte vertex. ANGLE converts the entire backing +buffer for that attribute, invalidating it on each upload. Expand only this +draw's affected attribute instead. */ +static BOOL macos_attribute_needs_upload(const struct vertex_element *element) +{ + unsigned long stride = device.streams[element->stream].stride; + unsigned long address = device.streams[element->stream].data + element->offset; + return !stride || (stride & 3) || (address & 3); +} + +static unsigned long macos_attribute_upload(const struct vertex_element *element, + unsigned long first, unsigned long count) +{ + static float *scratch; + static unsigned long capacity; + unsigned long stride = device.streams[element->stream].stride; + const unsigned char *source = PLATFORM_PHYSICAL_TO_VIRTUAL(device.streams[element->stream].data); + GLint components; + GLenum type; + GLboolean normalized; + unsigned long vertex, component; + if (count > capacity) + { + free(scratch); + capacity = count + 256; + scratch = malloc(capacity * 4 * sizeof(float)); + if (!scratch) + abort(); + } + attribute_format(element, &components, &type, &normalized); + source += first * stride + element->offset; + for (vertex = 0; vertex < count; vertex++) + { + float *out = scratch + vertex * 4; + const unsigned char *in = source + vertex * stride; + out[0] = out[1] = out[2] = 0; + out[3] = 1; + if (element->type == D3DVSDT_NORMPACKED3) + { + memcpy(out, in, 4); + continue; + } + for (component = 0; component < (unsigned long)components; component++) + { + if (type == GL_FLOAT) + memcpy(out + component, in + component * 4, 4); + else if (type == GL_SHORT) + { + short value; + memcpy(&value, in + component * 2, 2); + out[component] = normalized ? (value == -32768 ? -1.0f : value / 32767.0f) : (float)value; + } + else + { + unsigned long channel = component; + if (element->type == D3DVSDT_D3DCOLOR && (component == 0 || component == 2)) + channel = 2 - component; + out[component] = normalized ? in[channel] / 255.0f : (float)in[channel]; + } + } + } + return stream_upload(scratch, count * 4 * sizeof(float)); +} +#endif + /* upload vertices [first, first + count) of every stream the declaration uses and point the attributes at them; attribute data then starts at vertex 0 of the uploaded range */ @@ -3233,6 +3383,10 @@ static void setup_streams(unsigned long first, unsigned long count) unsigned long bytes = stride ? stride * count : 64; unsigned long base; +#ifdef HALO_MACOS + if (device.streams[stream].data && element->type != D3DVSDT_NONE && macos_attribute_needs_upload(element)) + total += count * 4 * sizeof(float); +#endif if (!device.streams[stream].data || element->type == D3DVSDT_NONE || placed[stream]) continue; placed[stream] = TRUE; @@ -3258,6 +3412,18 @@ static void setup_streams(unsigned long first, unsigned long count) if (!device.streams[stream].data || element->type == D3DVSDT_NONE) continue; +#ifdef HALO_MACOS + if (macos_attribute_needs_upload(element)) + { + unsigned long offset = macos_attribute_upload(element, first, count); + BOOL packed = element->type == D3DVSDT_NORMPACKED3; + state_attribute_pointer(element->reg, device.stream_buffer, packed ? 1 : 4, + packed ? GL_UNSIGNED_INT : GL_FLOAT, GL_FALSE, packed, 4 * sizeof(float), offset); + enabled[element->reg] = TRUE; + stats.streamed_bytes += count * 4 * sizeof(float); + continue; + } +#endif if (!stream_buffers[stream]) { const unsigned char *base = PLATFORM_PHYSICAL_TO_VIRTUAL(device.streams[stream].data); @@ -3673,6 +3839,7 @@ void WINAPI D3DDevice_Present(CONST RECT *source_rectangle, CONST RECT *destinat { struct render_target_entry *back_buffer = render_target_get(&device.back_buffer); int window_width, window_height, width, height, x, y; + GLuint read_framebuffer; if (trace_frame()) platform_log("present back buffer %08lx texture %u", (unsigned long)device.back_buffer.Data, @@ -3691,16 +3858,20 @@ void WINAPI D3DDevice_Present(CONST RECT *source_rectangle, CONST RECT *destinat } x = (window_width - width) / 2; y = (window_height - height) / 2; + /* Creating an FBO binds both read and draw targets. Resolve it before + selecting the window, including on the first/loading frame. */ + read_framebuffer = framebuffer_get(back_buffer->target.texture, 0); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); glDisable(GL_SCISSOR_TEST); glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); glClearColor(0.0f, 0.0f, 0.0f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); - glBindFramebuffer(GL_READ_FRAMEBUFFER, framebuffer_get(back_buffer->target.texture, 0)); + glBindFramebuffer(GL_READ_FRAMEBUFFER, read_framebuffer); /* row 0 of the render target is the top of the picture */ glBlitFramebuffer(0, 0, (GLint)back_buffer->target.gl_width, (GLint)back_buffer->target.gl_height, x, y + height, x + width, y, GL_COLOR_BUFFER_BIT, GL_LINEAR); platform_video_swap(); + gl_check_errors("present"); xgpu_gl_state_invalidate(); xgpu_texture_cache_begin_frame(); #ifdef HALO_ANDROID diff --git a/port/linux/src/menu_files.c b/port/linux/src/menu_files.c index 09ebcc99ab..141febebda 100644 --- a/port/linux/src/menu_files.c +++ b/port/linux/src/menu_files.c @@ -23,6 +23,9 @@ high-res HUD's are (hud_hires.c). #include "platform.h" #include "port_config.h" #include "xgpu.h" +#ifdef HALO_MACOS +#include "posix.h" +#endif #include "expat.h" @@ -121,6 +124,52 @@ static unsigned char *file_read(const char *path, unsigned long *size) #endif } +#ifdef HALO_MACOS +/* SDL_GlobDirectory returns host-owned pointer arrays. Use the existing +ILP32-safe directory bridge for the same two desktop patterns instead. */ +static void files_gather_directory(const char *relative, int depth) +{ + char directory_path[1200], name[1024]; + void *directory; + int length = snprintf(directory_path, sizeof(directory_path), "%s/%s", folder, relative); + + if (length < 0 || length >= (int)sizeof(directory_path)) + return; + directory = posix_directory_open(directory_path); + while (directory && posix_directory_next(directory, name, sizeof(name))) + { + char relative_path[1200], path[1200]; + struct posix_file_information information; + unsigned long size = 0; + unsigned char *data; + size_t name_length = strlen(name); + + length = snprintf(relative_path, sizeof(relative_path), "%s%s%s", relative, *relative ? "/" : "", name); + if (length < 0 || length >= (int)sizeof(relative_path)) + continue; + length = snprintf(path, sizeof(path), "%s/%s", folder, relative_path); + if (length < 0 || length >= (int)sizeof(path) || posix_stat(path, &information) != 0) + continue; + if (information.flags & _posix_file_is_directory) + { + if (!depth) + files_gather_directory(relative_path, depth + 1); + continue; + } + if (name_length < 4 || strcmp(name + name_length - 4, ".xml") || file_find(relative_path) >= 0) + continue; + data = file_read(path, &size); + if (data) + { + platform_log("menus: adding %s", path); + file_add(relative_path, data, size, 1); + } + } + if (directory) + posix_directory_close(directory); +} +#endif + /* the embedded files, then those of the menus folder: one of the same name replaces an embedded one, and (on the desktop, where the folder can be listed) another .xml file is added */ @@ -149,7 +198,9 @@ static void files_gather(void) file_add(embedded->path, (const unsigned char *)embedded->data, embedded->size, 0); } } -#ifndef HALO_ANDROID +#ifdef HALO_MACOS + files_gather_directory("", 0); +#elif !defined(HALO_ANDROID) { /* (the folder's own and its folders': SDL's * does not cross a /) */ static const char *const patterns[] = { "*.xml", "*/*.xml" }; @@ -355,7 +406,7 @@ static int for_this_platform(struct reader *reader, const XML_Char **attributes) reader_error(reader, "platform=\"%s\" is not \"desktop\" or \"android\"", platform); return 1; } -#ifdef HALO_ANDROID +#if defined(HALO_ANDROID) && !defined(HALO_MACOS) return !strcmp(platform, "android"); #else return !strcmp(platform, "desktop"); diff --git a/port/linux/src/nv2a_psh.c b/port/linux/src/nv2a_psh.c index bbea0de6f4..9a988115ce 100644 --- a/port/linux/src/nv2a_psh.c +++ b/port/linux/src/nv2a_psh.c @@ -328,8 +328,44 @@ static void dot_input(struct xgpu_text *text, const DWORD *state, int stage) #define SHADER_VERSION "#version 450 core\n" #endif +/* A clamp-to-edge sample already has the correct interior/edge texel value. +For a single level, blending it with the border by the footprint's coverage +exactly restores clamp-to-border, including the half-texel linear fringe. +Only unsupported GLES border samplers receive this helper (d3d8_gl.c). */ +static void border_sample_function(struct xgpu_text *text, const struct nv2a_pixel_shader_key *key, int stage) +{ + unsigned char axes = key->border_axes[stage]; + unsigned char filtering = key->border_filter[stage]; + + if (!axes) + return; + xgpu_text_append(text, + "vec4 sample_border%d(vec2 uv)\n{\n" + "\tvec4 value = texture(tex%d, uv, texture_lod_bias[%d]);\n" + "\tvec2 size = vec2(textureSize(tex%d, 0));\n", stage, stage, stage, stage); + if (filtering == 1 || filtering == 2) + { + xgpu_text_append(text, + "\tvec2 dx = dFdx(uv) * size, dy = dFdy(uv) * size;\n" + "\tbool minifying = max(dot(dx, dx), dot(dy, dy)) * exp2(2.0 * texture_lod_bias[%d]) > 1.0;\n" + "\tbool linear_filter = %sminifying;\n", stage, filtering == 1 ? "" : "!"); + } + else + xgpu_text_append(text, "\tbool linear_filter = %s;\n", filtering == 3 ? "true" : "false"); + xgpu_text_append(text, + "\tvec2 coverage = linear_filter ? clamp(vec2(0.5) + min(uv, vec2(1.0) - uv) * size, 0.0, 1.0)\n" + "\t\t: step(vec2(0.0), uv) * (vec2(1.0) - step(vec2(1.0), uv));\n" + "\treturn mix(texture_border_color[%d], value, %s);\n}\n", stage, + axes == 3 ? "coverage.x * coverage.y" : axes == 1 ? "coverage.x" : "coverage.y"); +} + static void sample(struct xgpu_text *text, const struct nv2a_pixel_shader_key *key, int stage, const char *coordinates) { + if (key->border_axes[stage]) + { + xgpu_text_append(text, "sample_border%d((%s).xy * texture_scale[%d].xy)", stage, coordinates, stage); + return; + } switch (key->sampler_type[stage]) { case _xgpu_sampler_3d: @@ -556,6 +592,8 @@ char *nv2a_pixel_shader_to_glsl(const struct nv2a_pixel_shader_key *key) XGPU_PIXEL_UNIFORMS); for (stage = 0; stage < 4; stage++) xgpu_text_append(&text, "uniform %s tex%d;\n", sampler_declaration(key->sampler_type[stage]), stage); + for (stage = 0; stage < 4; stage++) + border_sample_function(&text, key, stage); xgpu_text_append(&text, "float signed_byte(float x)\n" "{\n" diff --git a/port/linux/src/p2p.c b/port/linux/src/p2p.c index cf9382dca4..d2de9088d4 100644 --- a/port/linux/src/p2p.c +++ b/port/linux/src/p2p.c @@ -2716,8 +2716,14 @@ static void poll_invite_file(void) if (!elapsed(checked_time, 1000)) return; checked_time = p2p_now(); +#ifdef HALO_MACOS + /* App instances can share maps while retaining independent save folders. */ + snprintf(path, sizeof(path), "%s/join_link.txt", platform_save_root()); + snprintf(taken, sizeof(taken), "%s/join_link.taken", platform_save_root()); +#else snprintf(path, sizeof(path), "%s/join_link.txt", platform_data_root()); snprintf(taken, sizeof(taken), "%s/join_link.taken", platform_data_root()); +#endif /* (taken first: a link the launcher writes while this reads is left for the next look, not removed unread) */ if (rename(path, taken) != 0) diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c index f03e37d6ee..67ccf8ae39 100644 --- a/port/linux/src/port_config.c +++ b/port/linux/src/port_config.c @@ -342,7 +342,9 @@ static const struct config_setting config_settings[] = #define NUMBER_OF_CONFIG_SETTINGS (sizeof(config_settings) / sizeof(config_settings[0])) -#ifdef HALO_ANDROID +#if defined(HALO_MACOS) +#define CONFIG_PLATFORM _platform_desktop +#elif defined(HALO_ANDROID) #define CONFIG_PLATFORM _platform_android #else #define CONFIG_PLATFORM _platform_desktop @@ -364,7 +366,12 @@ static pthread_mutex_t config_lock = PTHREAD_MUTEX_INITIALIZER; static void config_path(char *path, size_t size) { -#ifdef HALO_ANDROID +#ifdef HALO_MACOS + /* The app bundle is read-only; keep preferences with the user's saves. */ + const char *root = getenv("HALO_SAVE_ROOT"); + + snprintf(path, size, "%s/config.toml", root && *root ? root : "."); +#elif defined(HALO_ANDROID) /* the data folder, which the app names (port/android/host/host_main.c) */ const char *root = getenv("HALO_DATA_ROOT"); @@ -908,6 +915,7 @@ int config_write(const char *name, const char *value) long index = config_setting_index(name); char section[64], key[64], wanted[80], current[64] = "", line_text[600], path[1024]; struct config_text out = { 0 }; + struct config_value candidate = { 0 }; size_t size = 0; char *text; const char *line; @@ -918,20 +926,26 @@ int config_write(const char *name, const char *value) /* (the file read first, as the other settings are) */ config_value(name, config_settings[index].type); pthread_mutex_lock(&config_lock); - config_set_from_text(&config_values[index], config_settings[index].type, value); + /* Readers keep the previous value until the file has accepted this one. */ + config_set_from_text(&candidate, config_settings[index].type, value); + if (config_settings[index].type == _config_string && !candidate.string) + { + pthread_mutex_unlock(&config_lock); + return 0; + } snprintf(section, sizeof(section), "%.*s", (int)(dot - name), name); snprintf(key, sizeof(key), "%s", dot + 1); switch (config_settings[index].type) { case _config_boolean: - snprintf(line_text, sizeof(line_text), "%s = %s\n", key, config_values[index].boolean ? "true" : "false"); + snprintf(line_text, sizeof(line_text), "%s = %s\n", key, candidate.boolean ? "true" : "false"); break; case _config_integer: - snprintf(line_text, sizeof(line_text), "%s = %ld\n", key, config_values[index].integer); + snprintf(line_text, sizeof(line_text), "%s = %ld\n", key, candidate.integer); break; case _config_real: /* (with its point: TOML reads 1 as an integer) */ - snprintf(line_text, sizeof(line_text), "%s = %.15g", key, config_values[index].real); + snprintf(line_text, sizeof(line_text), "%s = %.15g", key, candidate.real); if (!strpbrk(line_text + strlen(key) + 3, ".en")) strcat(line_text, ".0"); strcat(line_text, "\n"); @@ -941,7 +955,7 @@ int config_write(const char *name, const char *value) char *end = line_text + snprintf(line_text, sizeof(line_text), "%s = \"", key); const char *character; - for (character = config_values[index].string; *character; character++) + for (character = candidate.string; *character; character++) { if (*character == '"' || *character == '\\') *end++ = '\\'; @@ -1001,7 +1015,16 @@ int config_write(const char *name, const char *value) config_append(&out, line_text); } succeeded = out.buffer && config_write_file(path, out.buffer); - config_change_count++; + if (succeeded) + { + /* Keep previously returned strings alive, as config_set_from_text does. */ + config_values[index] = candidate; + config_change_count++; + } + else + { + free(candidate.string); + } pthread_mutex_unlock(&config_lock); free(out.buffer); free(text); diff --git a/port/linux/src/posix_files.c b/port/linux/src/posix_files.c index 92f2f48695..40d6bd9cfb 100644 --- a/port/linux/src/posix_files.c +++ b/port/linux/src/posix_files.c @@ -30,6 +30,15 @@ static void fill_information(const struct stat *st, struct posix_file_informatio if (!(st->st_mode & S_IWUSR)) information->flags |= _posix_file_is_read_only; split64((unsigned long long)st->st_size, &information->size_low, &information->size_high); + /* Darwin exposes birth time; Linux uses the closest portable change time. */ +#ifdef HALO_MACOS + information->modification_seconds = (posix_ulong)st->st_mtimespec.tv_sec; + information->modification_nanoseconds = (posix_ulong)st->st_mtimespec.tv_nsec; + information->access_seconds = (posix_ulong)st->st_atimespec.tv_sec; + information->access_nanoseconds = (posix_ulong)st->st_atimespec.tv_nsec; + information->creation_seconds = (posix_ulong)st->st_birthtimespec.tv_sec; + information->creation_nanoseconds = (posix_ulong)st->st_birthtimespec.tv_nsec; +#else information->modification_seconds = (posix_ulong)st->st_mtim.tv_sec; information->modification_nanoseconds = (posix_ulong)st->st_mtim.tv_nsec; information->access_seconds = (posix_ulong)st->st_atim.tv_sec; @@ -37,6 +46,7 @@ static void fill_information(const struct stat *st, struct posix_file_informatio /* Linux has no portable creation time; the change time is the closest */ information->creation_seconds = (posix_ulong)st->st_ctim.tv_sec; information->creation_nanoseconds = (posix_ulong)st->st_ctim.tv_nsec; +#endif } int posix_stat(const char *path, struct posix_file_information *information) diff --git a/port/linux/src/posix_net.c b/port/linux/src/posix_net.c index a5e8f0ea89..d32d22ff9d 100644 --- a/port/linux/src/posix_net.c +++ b/port/linux/src/posix_net.c @@ -27,6 +27,9 @@ with the host ABI. #include #include #include +#ifdef HALO_MACOS +#include +#endif #include "posix.h" @@ -562,7 +565,12 @@ posix_ulong posix_resolve_ipv4(const char *host) int posix_command_line_argument(int index, char *buffer, posix_ulong size) { -#ifdef __ANDROID__ +#ifdef HALO_MACOS + if (index < 0 || index >= *_NSGetArgc() || !size) + return 0; + snprintf(buffer, size, "%s", (*_NSGetArgv())[index]); + return 1; +#elif defined(__ANDROID__) (void)index; (void)buffer; (void)size; @@ -658,7 +666,7 @@ int posix_user_secret(unsigned char *secret, int size) #endif } -#ifndef __ANDROID__ +#if !defined(__ANDROID__) && !defined(HALO_MACOS) /* runs a program with its arguments and waits for it; its exit status, or -1 */ static int run_program(char *const arguments[]) { @@ -676,10 +684,15 @@ static int run_program(char *const arguments[]) int posix_register_url_scheme(const char *scheme, const char *description) { -#ifdef __ANDROID__ +#if defined(__ANDROID__) || defined(HALO_MACOS) (void)scheme; (void)description; + /* Apple app bundles declare their handler in CFBundleURLTypes. */ +#ifdef HALO_MACOS + return 1; +#else return 0; +#endif #else /* a desktop entry declaring the executable as the scheme's handler, and the scheme's default application set to it (as xdg-open reads it) */ @@ -738,9 +751,12 @@ int posix_register_url_scheme(const char *scheme, const char *description) /* ---------- Discord's local socket */ +/* macOS supplies its native Unix socket implementation in host_discord.c; +the Winsock adapter's connect wrapper expects guest sockaddr layouts. */ +#ifndef HALO_MACOS int posix_discord_connect(void) { -#ifdef __ANDROID__ +#if defined(__ANDROID__) || defined(HALO_MACOS) return -1; #else /* where Discord (and its Flatpak and Snap packages) put discord-ipc-N */ @@ -807,6 +823,7 @@ int posix_discord_connect(void) return -1; #endif } +#endif int posix_discord_write(int handle, const void *buffer, int length) { diff --git a/port/linux/src/sdl_platform.c b/port/linux/src/sdl_platform.c index e7f7e10283..8908dfc476 100644 --- a/port/linux/src/sdl_platform.c +++ b/port/linux/src/sdl_platform.c @@ -15,6 +15,13 @@ and the debug keyboard that the game's console reads. #include "port_config.h" #include "p2p.h" #include "xiso.h" +#ifdef HALO_MACOS +#include "../../macos/native_events.h" +int host_sdl_apply_display(int mode, int width, int height); +int host_sdl_display_mode(void); +void host_sdl_request_quit(void); +void platform_show_message(const char *title, const char *message); +#endif #include #include @@ -36,7 +43,7 @@ static unsigned char keys_pressed[SDL_SCANCODE_COUNT]; /* likewise the mouse buttons pressed since the last read, so that a click quicker than a frame still counts */ static unsigned char mouse_buttons_pressed[PLATFORM_MOUSE_BUTTON_COUNT]; -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) || defined(HALO_MACOS) /* the menus' pointer (platform_ui_pointer_set_active), under input_lock */ static struct platform_ui_pointer ui_pointer; static float ui_pointer_wheel; @@ -348,7 +355,7 @@ int halo_interpolation_enabled(void) return enabled; } -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) || defined(HALO_MACOS) /* the display mode (display.mode, else display.fullscreen's: borderless or the window) */ enum @@ -377,7 +384,9 @@ static BOOL platform_fullscreen_setting(void) { return !config_boolean("debug.hidden_window") && platform_display_mode() != _display_mode_windowed; } +#endif +#ifndef HALO_ANDROID /* the window's fullscreen kind (display.mode): borderless, a window over the whole desktop (SDL's fullscreen without a mode), or fullscreen, the display taken at its desktop resolution. Either draws at the display's @@ -421,6 +430,50 @@ BOOL platform_screen_mode(long *width, long *height) /* the window's scale (display.window_scale, as the window was made or last resized: platform_display_apply) */ static long platform_window_scale = -1; +#ifdef HALO_MACOS +static int platform_applied_display_mode = -1; + +static void platform_macos_display_initialize(void) +{ + platform_applied_display_mode = platform_display_mode(); + if (!getenv("HALO_WINDOWED")) + host_sdl_apply_display(platform_applied_display_mode, 0, 0); +} + +int platform_display_mode_save(const char *value) +{ + if (!config_write("display.mode", value)) + return FALSE; + /* The selected mode may equal the persisted value while a launch-only + override is active. An explicit PC choice must still apply it. */ + platform_applied_display_mode = -1; + return TRUE; +} + +/* Show the live window mode even with a launch-only HALO_WINDOWED override. */ +const char *platform_display_mode_name(void) +{ + int mode = host_sdl_display_mode(); + + if (mode < _display_mode_windowed || mode > _display_mode_fullscreen) + mode = platform_display_mode(); + return mode == _display_mode_fullscreen ? "fullscreen" : + mode == _display_mode_borderless ? "borderless" : "windowed"; +} + +static void platform_native_display_changed(int mode) +{ + const char *name = mode == _display_mode_borderless ? "borderless" : "windowed"; + + if (mode == platform_display_mode() || config_write("display.mode", name)) + { + platform_applied_display_mode = mode; + return; + } + host_sdl_apply_display(platform_display_mode(), 0, 0); + platform_show_message("Display settings", "The display setting could not be saved. The previous mode was restored."); +} +#endif BOOL platform_video_initialize(unsigned long width, unsigned long height) { @@ -457,7 +510,7 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height) setenv("mesa_glthread", "true", 0); #endif -#ifdef HALO_ANDROID +#if defined(HALO_ANDROID) && !defined(HALO_MACOS) platform_window = SDL_CreateWindow("Halo", (int)(width * scale), (int)(height * scale), SDL_WINDOW_OPENGL | SDL_WINDOW_FULLSCREEN); #else @@ -477,6 +530,8 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height) } #ifndef HALO_ANDROID platform_fullscreen_kind_apply(); +#elif defined(HALO_MACOS) + platform_macos_display_initialize(); #endif platform_gl_context = SDL_GL_CreateContext(platform_window); #ifdef HALO_ANDROID @@ -500,7 +555,7 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height) (void)version; platform_event_thread = SDL_GetCurrentThreadID(); platform_log("OpenGL %s on %s", (const char *)glGetString(GL_VERSION), (const char *)glGetString(GL_RENDERER)); -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) || defined(HALO_MACOS) platform_mouse_capture(TRUE); #endif return TRUE; @@ -510,7 +565,29 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height) be resized) and display.vsync, as Settings has written them */ void platform_display_apply(void) { -#ifndef HALO_ANDROID +#ifdef HALO_MACOS + int mode = platform_display_mode(); + long scale = config_integer("display.window_scale"); + int apply_mode = mode != platform_applied_display_mode ? mode : -1; + BOOL resize = scale != platform_window_scale && scale >= 1; + + if (!platform_window) + return; + /* Saving an unrelated settings page does not undo a launch-only + window override or an F11 change that has already been saved. */ + if (apply_mode >= 0 || resize) + { + if (host_sdl_apply_display(apply_mode, resize ? (int)(640 * scale) : 0, + resize ? (int)(480 * scale) : 0)) + { + platform_applied_display_mode = mode; + if (resize) + platform_window_scale = scale; + } + else + platform_show_message("Display settings", "The display could not change modes."); + } +#elif !defined(HALO_ANDROID) BOOL fullscreen = platform_fullscreen_setting(); long scale = config_integer("display.window_scale"); @@ -597,6 +674,30 @@ void platform_mouse_capture(BOOL capture) SDL_SetWindowRelativeMouseMode(platform_window, capture ? true : false); } +#ifdef HALO_MACOS +/* Called with input_lock held. A native panel must not leave queued or held + * gameplay input behind, even if the OS never delivers its key-up events. */ +static void platform_native_input_clear(void) +{ + memset(input_state.keys, 0, sizeof(input_state.keys)); + memset(keys_pressed, 0, sizeof(keys_pressed)); + memset(input_state.mouse_buttons, 0, sizeof(input_state.mouse_buttons)); + memset(mouse_buttons_pressed, 0, sizeof(mouse_buttons_pressed)); + mouse_buttons_down = 0; + input_state.mouse_dx = input_state.mouse_dy = input_state.mouse_wheel = 0.0f; + keystroke_head = keystroke_count = 0; + ui_pointer.left_clicks = ui_pointer.right_clicks = ui_pointer.wheel_steps = 0; + ui_pointer_wheel = 0.0f; +} + +static void platform_native_mouse_release(void) +{ + platform_native_input_clear(); + input_state.mouse_released = TRUE; + platform_mouse_capture(FALSE); +} +#endif + /* ---------- keyboard translation */ /* Windows virtual key code for an SDL scancode (the Xbox debug keyboard @@ -794,7 +895,7 @@ static void platform_invite_clipboard(BOOL look) static char seen[256]; const char *invite = p2p_take_clipboard_text(); - if (invite) + if (invite && !*config_string("debug.network_test")) { SDL_SetClipboardText(invite); snprintf(seen, sizeof(seen), "%s", invite); @@ -884,7 +985,9 @@ menus' Quit: port/linux/game/menu_functions.c); Android's menus have none, as the system closes its apps */ void platform_request_quit(void) { -#ifndef HALO_ANDROID +#ifdef HALO_MACOS + host_sdl_request_quit(); +#elif !defined(HALO_ANDROID) SDL_Event event; memset(&event, 0, sizeof(event)); @@ -987,10 +1090,19 @@ void platform_pump_events(void) SDL_SetWindowFullscreen(platform_window, (SDL_GetWindowFlags(platform_window) & SDL_WINDOW_FULLSCREEN) ? false : true); } +#elif defined(HALO_MACOS) + if (event.key.down && !event.key.repeat && event.key.scancode == SDL_SCANCODE_F11) + { + int mode = host_sdl_display_mode() == _display_mode_windowed ? + _display_mode_borderless : _display_mode_windowed; + + if (host_sdl_apply_display(mode, 0, 0)) + platform_native_display_changed(mode); + } #endif break; case SDL_EVENT_MOUSE_MOTION: -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) || defined(HALO_MACOS) /* in the menus the mouse moves the pointer, not the view */ if (input_state.ui_pointer) { @@ -1021,7 +1133,7 @@ void platform_pump_events(void) binding_captured_input = INPUT_MOUSE + event.button.button; break; } -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) || defined(HALO_MACOS) /* clicks in the menus go to the pointer; a button held down when the menu closes stays up until pressed again, so the click that resumes the game does not also fire */ @@ -1039,6 +1151,18 @@ void platform_pump_events(void) } break; } +#endif +#ifdef HALO_MACOS + /* The first click after returning from a native panel captures the + * mouse; it must never also fire a weapon. */ + if (input_state.mouse_released) + { + platform_native_input_clear(); + if (event.button.down && event.button.button == SDL_BUTTON_LEFT && + SDL_SetWindowRelativeMouseMode(platform_window, true)) + input_state.mouse_released = FALSE; + break; + } #endif if (event.button.button < PLATFORM_MOUSE_BUTTON_COUNT) { @@ -1072,7 +1196,7 @@ void platform_pump_events(void) } break; } -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) || defined(HALO_MACOS) if (input_state.ui_pointer) { /* whole notches: smooth-scrolling wheels send fractions */ @@ -1093,9 +1217,13 @@ void platform_pump_events(void) input_state.mouse_wheel += event.wheel.y; break; case SDL_EVENT_WINDOW_FOCUS_LOST: +#ifdef HALO_MACOS + platform_native_mouse_release(); +#else memset(input_state.keys, 0, sizeof(input_state.keys)); memset(input_state.mouse_buttons, 0, sizeof(input_state.mouse_buttons)); memset(mouse_buttons_pressed, 0, sizeof(mouse_buttons_pressed)); +#endif input_state.focused = FALSE; break; case SDL_EVENT_WINDOW_FOCUS_GAINED: @@ -1106,6 +1234,12 @@ void platform_pump_events(void) platform_mouse_capture(TRUE); #endif break; +#ifdef HALO_MACOS + case SDL_EVENT_USER: + if (event.user.code == HALO_MACOS_MOUSE_RELEASE) + platform_native_mouse_release(); + break; +#endif case SDL_EVENT_GAMEPAD_ADDED: SDL_OpenGamepad(event.gdevice.which); break; @@ -1150,7 +1284,7 @@ int platform_binding_capture_poll(int *input) return result; } -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) || defined(HALO_MACOS) /* ---------- the menus' pointer */ /* While a menu is up the mouse is released, its pointer shows (centered when diff --git a/port/linux/src/sdl_platform.h b/port/linux/src/sdl_platform.h index 6ecd3b3609..cfcbd64832 100644 --- a/port/linux/src/sdl_platform.h +++ b/port/linux/src/sdl_platform.h @@ -76,7 +76,7 @@ void platform_mouse_capture(BOOL capture); void platform_pump_events(void); /* a snapshot of the input state; consume_motion resets the mouse deltas */ void platform_input_read(struct platform_input_state *state, BOOL consume_motion); -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) || defined(HALO_MACOS) /* the pointer in the menus (d3d8_gl.c, halo_ui_pointer_update) */ struct platform_ui_pointer { diff --git a/port/linux/src/xbox_kernel.c b/port/linux/src/xbox_kernel.c index 04fc3267a1..182ccf14a8 100644 --- a/port/linux/src/xbox_kernel.c +++ b/port/linux/src/xbox_kernel.c @@ -641,12 +641,33 @@ VOID WINAPI Sleep(DWORD milliseconds) /* ---------- time */ +#ifdef HALO_MACOS +/* The game keeps some clocks in signed 32-bit milliseconds. Count from app + * startup so a Mac up longer than 24.8 days cannot immediately time out peers. + * A ten-second origin matches the game's nonzero "already started" clock. */ +static unsigned long long platform_clock_nanoseconds(void) +{ + static unsigned long long start; + struct timespec now; + unsigned long long value, expected = 0; + + clock_gettime(CLOCK_MONOTONIC, &now); + value = (unsigned long long)now.tv_sec * 1000000000ULL + (unsigned long long)now.tv_nsec; + __atomic_compare_exchange_n(&start, &expected, value - 10000000000ULL, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED); + return value - __atomic_load_n(&start, __ATOMIC_RELAXED); +} +#endif + DWORD WINAPI GetTickCount(void) { +#ifdef HALO_MACOS + return (DWORD)(platform_clock_nanoseconds() / 1000000ULL); +#else struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now); return (DWORD)((unsigned long long)now.tv_sec * 1000ULL + (unsigned long long)now.tv_nsec / 1000000ULL); +#endif } /* The Xbox performance counter runs at the 733 MHz CPU clock. Report a @@ -656,11 +677,15 @@ arithmetic in the game stays in range, fine enough for frame timing. */ BOOL WINAPI QueryPerformanceCounter(LARGE_INTEGER *count) { +#ifdef HALO_MACOS + count->QuadPart = (LONGLONG)(platform_clock_nanoseconds() / (1000000000ULL / PLATFORM_PERFORMANCE_FREQUENCY)); +#else struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now); count->QuadPart = (LONGLONG)((unsigned long long)now.tv_sec * PLATFORM_PERFORMANCE_FREQUENCY + (unsigned long long)now.tv_nsec / (1000000000ULL / PLATFORM_PERFORMANCE_FREQUENCY)); +#endif return TRUE; } diff --git a/port/linux/src/xgpu.h b/port/linux/src/xgpu.h index 3016c4e7e9..d2e8ad3d4e 100644 --- a/port/linux/src/xgpu.h +++ b/port/linux/src/xgpu.h @@ -94,6 +94,10 @@ struct nv2a_pixel_shader_key /* D3DTSS_COLORSIGN: channels (bit 0 alpha ... bit 3 blue, as D3DTSIGN_*) that hold signed data in an unsigned texture format */ unsigned char color_sign[4]; + /* GLES without texture_border_clamp: non-mipmapped 2D textures only. + Bit 0/1 select U/V; filtering bit 0/1 selects linear min/magnification. */ + unsigned char border_axes[4]; + unsigned char border_filter[4]; /* D3DCMP_* function for the alpha test, or 0 when disabled */ unsigned long alpha_test_function; unsigned char fog_enable; @@ -130,6 +134,7 @@ C0/C1 of each stage and the final combiner, and texture constants */ "uniform vec4 bump_matrix[4];\n" \ "uniform vec4 bump_luminance[4];\n" \ "uniform vec4 texture_scale[4];\n" \ + "uniform vec4 texture_border_color[4];\n" \ XGPU_PIXEL_UNIFORMS_ES /* ---------- textures */ diff --git a/port/linux/src/xiso.c b/port/linux/src/xiso.c index 066b430060..9619daaeef 100644 --- a/port/linux/src/xiso.c +++ b/port/linux/src/xiso.c @@ -60,7 +60,20 @@ includes software developed by in . /* (the desktop ports only: the Android app imports the game data itself) */ #ifndef HALO_ANDROID +#ifdef HALO_MACOS +/* The Mac importer runs in the native host, outside the guest's XDK ABI. */ +#include +#include +static void platform_log(const char *format, ...) { + va_list arguments; + va_start(arguments, format); + vfprintf(stderr, format, arguments); + va_end(arguments); + fputc('\n', stderr); +} +#else #include "platform.h" +#endif #include "posix.h" #include "xiso.h" diff --git a/port/macos/README.md b/port/macos/README.md new file mode 100644 index 0000000000..fd382c8206 --- /dev/null +++ b/port/macos/README.md @@ -0,0 +1,71 @@ +# Apple Silicon macOS + +The app runs native ARM64 CPU code and uses SDL3 with ANGLE's Metal backend. +The game retains its 32-bit pointers and data layout inside a 64-bit Mac host: +`compiler/guest_rebase.cpp` rebases guest memory accesses into a reserved range. +This is not an x86-64 port. Shared guest/libc tools under `port/android/` do not +require an Android SDK or device. + +## Build + +On an Apple Silicon Mac, install Xcode Command Line Tools and the public tools: + +```sh +xcode-select --install +brew install python llvm@22 lld@22 sdl3 ninja +export PATH="$(brew --prefix lld@22)/bin:$PATH" +python3 tools/macos_setup.py +python3 tools/macos_build.py +open "build/macos/Halo CE Universal.app" +``` + +Use Python 3.10 or later. LLVM **22** builds the compiler pass; Apple's clang +builds the host. Keep Apple's compiler on `PATH`. The default LLVM path is +`/opt/homebrew/opt/llvm@22/bin`; an existing `HALO_MACOS_LLVM_BIN` override may +select another installation. [dependencies.json](dependencies.json) pins ANGLE +and Khronos headers by checksum. Setup needs no game files or private Xbox SDK. + +`--host-only` rebuilds the host after a full build; `--jobs N` sets guest build +parallelism. Packaging stages a fresh bundle and preserves previous bundles in +`build/macos/app-backups.noindex`. It never installs or publishes the app. + +## Install and game data + +Download **halo-macos-arm64-dmg** from a successful **macOS build and package** Actions run, +open the disk image, and drag the app to Applications. Artifacts expire after +14 days and downloading them requires a GitHub account. Builds are ad-hoc +signed, without notarization or an automatic updater. macOS may block first +launch; use Apple's [instructions for opening an app](https://support.apple.com/en-us/102445). +The host targets macOS 14 or later; newer bundled SDL builds may raise the +minimum version, which the app records in its `Info.plist`. + +First launch accepts your own original Xbox Halo disc image or an extracted +game/maps folder. The app checks version-5 cache headers from PAL build +`01.01.14.2342` or USA build `01.10.12.2276`, including matching `ui.map` and +`a10.map`. Other retail maps present must match that disc. Header validation +checks the supported format, not complete gameplay compatibility. + +Imports use a new directory each time; a failed import preserves existing data. +The Halo menu offers **Choose Game Data on Next Launch**, **Open Saves Folder**, +About and Quit. Display and controls use the game's settings. Imported maps, +`macos-settings.json`, `config.toml`, saves and `halo.log` live outside the app: + +```text +~/Library/Application Support/Halo CE Universal/ +``` + +## Package and verify + +```sh +python3 tools/macos_release.py local-dmg --output build/macos/Halo-local.dmg +``` + +The DMG contains the host, compiled guest, runtime libraries, build provenance +and dependency licenses. Bundle audits reject game data, local +paths and unexpected files, and verify ad-hoc signing before packaging. +`BuildInfo.txt` records the source revision and guest hash. + +The single Mac CI job builds the app, verifies the DMG checksum, ARM64 architecture +and strict code signatures, then uploads the DMG. It uses standard `macos-26`, +read-only repository permissions and no signing secrets. Maintainers can run it +from GitHub without a Mac. Live gameplay still needs Mac playtesting. diff --git a/port/macos/compiler/guest_rebase.cpp b/port/macos/compiler/guest_rebase.cpp new file mode 100644 index 0000000000..bc982cebaf --- /dev/null +++ b/port/macos/compiler/guest_rebase.cpp @@ -0,0 +1,216 @@ +/* Experimental LLVM 22 pass for the macOS ARM guest. + * Keep ILP32 layouts; dereferences use 64-bit address space 272 and a 1-TB + * virtual address bias. Run after IR optimization and before code generation. + * This is an ABI adapter, not a sandbox for untrusted code. + */ +#include "llvm/IR/IRBuilder.h" +#include "llvm/IR/IntrinsicInst.h" +#include "llvm/IR/InlineAsm.h" +#include "llvm/Passes/PassBuilder.h" +#include "llvm/Plugins/PassPlugin.h" +#include "llvm/Transforms/Utils/LowerMemIntrinsics.h" +using namespace llvm; +namespace { +constexpr uint64_t GuestBias = 0x10000000000ULL; +// Pointer-typed opaque integers must not acquire a memory address bias. +bool isHandle(StringRef name, unsigned argument) { + return (argument == 0 && + (name == "hostposix_directory_next" || name == "hostposix_directory_close")) || + (argument == 3 && + (name == "hostgl_glDrawElements" || name == "hostgl_glDrawElementsInstanced" || + name == "hostgl_glDrawElementsBaseVertex")) || + (argument == 5 && + (name == "hostgl_glVertexAttribPointer" || name == "hostgl_glDrawRangeElements")) || + (argument == 4 && name == "hostgl_glVertexAttribIPointer"); +} +struct RebasePass : PassInfoMixin { + PreservedAnalyses run(Module &module, ModuleAnalysisManager &) { + if (module.getDataLayout().getPointerSize(0) != 4 || + module.getDataLayout().getPointerSize(272) != 8) + report_fatal_error("Halo rebase requires arm64_32 with 64-bit address space 272"); + DenseMap hostFunctions; + SmallVector declarations; + for (Function &function : module) + if (function.isDeclaration() && function.getName().starts_with("host")) + declarations.push_back(&function); + for (Function *old : declarations) { + SmallVector parameters; + StringRef name = old->getName(); + unsigned index = 0; + for (Type *type : old->getFunctionType()->params()) { + parameters.push_back(type->isPointerTy() && !isHandle(name, index) + ? PointerType::get(module.getContext(), 272) + : type); + ++index; + } + std::string originalName = name.str(); + old->setName(originalName + ".guest_decl"); + Function *host = Function::Create( + FunctionType::get(old->getReturnType(), parameters, old->isVarArg()), + old->getLinkage(), originalName, module); + hostFunctions[old] = host; + } + for (Function &function : module) { + SmallVector patterns; + for (BasicBlock &block : function) + for (Instruction &instruction : block) + if (auto *pattern = dyn_cast(&instruction)) + patterns.push_back(pattern); + for (auto *pattern : patterns) { + expandMemSetPatternAsLoop(pattern); + pattern->eraseFromParent(); + } + SmallVector work; + for (BasicBlock &block : function) + for (Instruction &instruction : block) + work.push_back(&instruction); + for (Instruction *instruction : work) { + IRBuilder<> builder(instruction); + auto rebase = [&](Value *pointer, bool nullable = false) -> Value * { + Value *offset = builder.CreatePtrToInt(pointer, builder.getInt32Ty()); + Value *wide = builder.CreateZExt(offset, builder.getInt64Ty()); + // arm64_32 codegen assumes pointer registers have zero upper + // bits. Our real stack/global addresses can already contain + // the arena bias. In particular, splitting an unaligned i64 + // load can fold OR(bias)+4 into base+(bias+4), adding it twice. + // Make the machine-level truncation explicit, opaque to that + // combine. Constants already have canonical upper bits. + if (!isa(wide)) { + auto *truncate = InlineAsm::get( + FunctionType::get(builder.getInt64Ty(), {builder.getInt64Ty()}, false), + "and $0, $1, #0xffffffff", "=r,r", false); + wide = builder.CreateCall(truncate, {wide}); + } + Value *address = + builder.CreateOr(wide, builder.getInt64(GuestBias)); + if (nullable) + address = + builder.CreateSelect(builder.CreateICmpEQ(offset, builder.getInt32(0)), + builder.getInt64(0), address); + return builder.CreateIntToPtr(address, + PointerType::get(module.getContext(), 272)); + }; + if (auto *va = dyn_cast(instruction)) { + Type *pointer = PointerType::get(module.getContext(), 0); + Value *list = rebase(va->getPointerOperand()); + Value *cursor = builder.CreatePtrToInt(builder.CreateLoad(pointer, list), + builder.getInt32Ty()); + unsigned alignment = + module.getDataLayout().getABITypeAlign(va->getType()).value(); + uint64_t size = module.getDataLayout().getTypeAllocSize(va->getType()); + if (alignment > 4) + cursor = builder.CreateAnd( + builder.CreateAdd(cursor, builder.getInt32(alignment - 1)), + builder.getInt32(~(alignment - 1))); + Value *value = builder.CreateLoad( + va->getType(), rebase(builder.CreateIntToPtr(cursor, pointer))); + Value *next = builder.CreateIntToPtr( + builder.CreateAdd(cursor, builder.getInt32((size + 3) & ~3u)), pointer); + builder.CreateStore(next, list); + va->replaceAllUsesWith(value); + va->eraseFromParent(); + } else if (auto *intrinsic = dyn_cast(instruction); + intrinsic && (intrinsic->getIntrinsicID() == Intrinsic::vacopy || + intrinsic->getIntrinsicID() == Intrinsic::vastart || + intrinsic->getIntrinsicID() == Intrinsic::vaend || + intrinsic->getIntrinsicID() == Intrinsic::stackrestore)) { + if (intrinsic->getIntrinsicID() == Intrinsic::vacopy) { + Value *value = builder.CreateLoad(PointerType::get(module.getContext(), 0), + rebase(intrinsic->getArgOperand(1))); + builder.CreateStore(value, rebase(intrinsic->getArgOperand(0))); + } else if (intrinsic->getIntrinsicID() == Intrinsic::vastart) { + auto fn = Intrinsic::getOrInsertDeclaration( + &module, Intrinsic::vastart, + {PointerType::get(module.getContext(), 272)}); + builder.CreateCall(fn, {rebase(intrinsic->getArgOperand(0))}); + } + if (intrinsic->getIntrinsicID() == Intrinsic::stackrestore) { + auto fn = Intrinsic::getOrInsertDeclaration( + &module, Intrinsic::stackrestore, + {PointerType::get(module.getContext(), 272)}); + builder.CreateCall(fn, {rebase(intrinsic->getArgOperand(0))}); + } + intrinsic->eraseFromParent(); + } else if (auto *load = dyn_cast(instruction)) + load->setOperand(0, rebase(load->getPointerOperand())); + else if (auto *store = dyn_cast(instruction)) + store->setOperand(1, rebase(store->getPointerOperand())); + else if (auto *atomic = dyn_cast(instruction)) + atomic->setOperand(0, rebase(atomic->getPointerOperand())); + else if (auto *atomic = dyn_cast(instruction)) + atomic->setOperand(0, rebase(atomic->getPointerOperand())); + else if (auto *transfer = dyn_cast(instruction)) { + // The target cannot lower AS272 libcalls. Call the rebased guest libc + // using its original ILP32 arguments instead. + Type *pointer = PointerType::get(module.getContext(), 0); + auto callee = module.getOrInsertFunction( + isa(transfer) ? "memmove" : "memcpy", + FunctionType::get(pointer, {pointer, pointer, builder.getInt32Ty()}, + false)); + builder.CreateCall(callee, {transfer->getRawDest(), transfer->getRawSource(), + builder.CreateZExtOrTrunc(transfer->getLength(), + builder.getInt32Ty())}); + transfer->eraseFromParent(); + } else if (auto *set = dyn_cast(instruction)) { + Type *pointer = PointerType::get(module.getContext(), 0); + auto callee = module.getOrInsertFunction( + "memset", + FunctionType::get( + pointer, {pointer, builder.getInt32Ty(), builder.getInt32Ty()}, false)); + builder.CreateCall( + callee, + {set->getRawDest(), + builder.CreateZExt(set->getValue(), builder.getInt32Ty()), + builder.CreateZExtOrTrunc(set->getLength(), builder.getInt32Ty())}); + set->eraseFromParent(); + } else if (auto *call = dyn_cast(instruction)) { + auto host = hostFunctions.find(call->getCalledFunction()); + if (host != hostFunctions.end()) { + SmallVector arguments; + unsigned index = 0; + for (Value *argument : call->args()) { + arguments.push_back(argument->getType()->isPointerTy() && + !isHandle(host->second->getName(), index) + ? rebase(argument, true) + : argument); + ++index; + } + CallInst *replacement = builder.CreateCall(host->second, arguments); + replacement->setCallingConv(call->getCallingConv()); + call->replaceAllUsesWith(replacement); + call->eraseFromParent(); + } else if (call->isIndirectCall() && !call->isInlineAsm()) { + call->setCalledOperand(rebase(call->getCalledOperand())); + } + } + } + } + // Late instruction selection must not turn memcmp/string calls back into + // unrebased loads after this pass has handled the IR memory operations. + for (Function &function : module) + for (BasicBlock &block : function) + for (Instruction &instruction : block) + if (auto *call = dyn_cast(&instruction)) + if (!isa(call)) + call->addFnAttr(Attribute::NoBuiltin); + for (Function *old : declarations) { + if (!old->use_empty()) + report_fatal_error("Unsupported address-taking use of a Halo host import"); + old->eraseFromParent(); + } + return PreservedAnalyses::none(); + } +}; +} // namespace +extern "C" LLVM_ATTRIBUTE_WEAK PassPluginLibraryInfo llvmGetPassPluginInfo() { + return {LLVM_PLUGIN_API_VERSION, "HaloRebase", LLVM_VERSION_STRING, [](PassBuilder &builder) { + builder.registerPipelineParsingCallback([](StringRef name, + ModulePassManager &manager, + ArrayRef) { + if (name != "halo-rebase") + return false; + manager.addPass(RebasePass()); + return true; + }); + }}; +} diff --git a/port/macos/dependencies.json b/port/macos/dependencies.json new file mode 100644 index 0000000000..b61119df09 --- /dev/null +++ b/port/macos/dependencies.json @@ -0,0 +1,64 @@ +{ + "llvm_major": 22, + "angle": { + "url": "https://github.com/EdgeFirstAI/angle-package/releases/download/v2.1.28252/angle-xcframeworks-v2.1.28252.zip", + "sha256": "59e4b1f68956c92441cde4dca0e9eb1a835bbccd107cefdd1d3d3d60e27410be" + }, + "headers": [ + { + "path": "EGL/egl.h", + "url": "https://raw.githubusercontent.com/KhronosGroup/EGL-Registry/db3425b8246136faccb5e2782b5694960bd6edf1/api/EGL/egl.h", + "sha256": "a7c24c828bf2e1a4dfe6511b4f4812b948a285eb54787074015896976d4da076" + }, + { + "path": "EGL/eglext.h", + "url": "https://raw.githubusercontent.com/KhronosGroup/EGL-Registry/db3425b8246136faccb5e2782b5694960bd6edf1/api/EGL/eglext.h", + "sha256": "a5f574a0074001400c6c50232235ee00e9a67b2168bfd1c364ba0f6f52e5a75c" + }, + { + "path": "EGL/eglplatform.h", + "url": "https://raw.githubusercontent.com/KhronosGroup/EGL-Registry/db3425b8246136faccb5e2782b5694960bd6edf1/api/EGL/eglplatform.h", + "sha256": "25b5391655effcf363a38fa00c3154f356a3eab3d35dc067847875513cf1e441" + }, + { + "path": "GLES2/gl2.h", + "url": "https://raw.githubusercontent.com/KhronosGroup/OpenGL-Registry/1cdd228e34966dd6b95bd203e9f84faba0f371a1/api/GLES2/gl2.h", + "sha256": "ba5e8e1755642efeb2d0f34b4cfe261cdbf6471e7763a40c7de10dff4c3c4921" + }, + { + "path": "GLES2/gl2ext.h", + "url": "https://raw.githubusercontent.com/KhronosGroup/OpenGL-Registry/1cdd228e34966dd6b95bd203e9f84faba0f371a1/api/GLES2/gl2ext.h", + "sha256": "9afc725e9dda7c8b476e7337e75b22fa660ed462c169e203ec28c10e62f91f4c" + }, + { + "path": "GLES2/gl2platform.h", + "url": "https://raw.githubusercontent.com/KhronosGroup/OpenGL-Registry/1cdd228e34966dd6b95bd203e9f84faba0f371a1/api/GLES2/gl2platform.h", + "sha256": "f5da0747540a50be5f44aad264aae45bdf157a192c40f17487dd9a2f99c71b6c" + }, + { + "path": "GLES3/gl3.h", + "url": "https://raw.githubusercontent.com/KhronosGroup/OpenGL-Registry/1cdd228e34966dd6b95bd203e9f84faba0f371a1/api/GLES3/gl3.h", + "sha256": "a0e4880142bd059bd4d7446f257920b5020b8bbb0a86eb0149cbd1ea2fcf8cb0" + }, + { + "path": "GLES3/gl31.h", + "url": "https://raw.githubusercontent.com/KhronosGroup/OpenGL-Registry/1cdd228e34966dd6b95bd203e9f84faba0f371a1/api/GLES3/gl31.h", + "sha256": "bb17bfde4aeba912d3686a0cc68ac04710ddacbcc5df7354994927747357daf3" + }, + { + "path": "GLES3/gl32.h", + "url": "https://raw.githubusercontent.com/KhronosGroup/OpenGL-Registry/1cdd228e34966dd6b95bd203e9f84faba0f371a1/api/GLES3/gl32.h", + "sha256": "f203257863a7de3fce851efa61490907171db82082381d05f86f5d9bd39e8e17" + }, + { + "path": "GLES3/gl3platform.h", + "url": "https://raw.githubusercontent.com/KhronosGroup/OpenGL-Registry/1cdd228e34966dd6b95bd203e9f84faba0f371a1/api/GLES3/gl3platform.h", + "sha256": "a9e060dae5a2b11c5a889b679692b7089a10a7e03ebfbb6cf28217f6e322fb08" + }, + { + "path": "KHR/khrplatform.h", + "url": "https://raw.githubusercontent.com/KhronosGroup/EGL-Registry/db3425b8246136faccb5e2782b5694960bd6edf1/api/KHR/khrplatform.h", + "sha256": "7b1e01aaa7ad8f6fc34b5c7bdf79ebf5189bb09e2c4d2e79fc5d350623d11e83" + } + ] +} diff --git a/port/macos/host/entry.s b/port/macos/host/entry.s new file mode 100644 index 0000000000..7a09b8690a --- /dev/null +++ b/port/macos/host/entry.s @@ -0,0 +1,8 @@ +.text +.globl _macos_enter_guest_stack +.p2align 2 +_macos_enter_guest_stack: + mov sp, x0 + mov w0, w1 + bl _host_run_guest_main + brk #0 diff --git a/port/macos/host/host.h b/port/macos/host/host.h new file mode 100644 index 0000000000..8f1a4c4a9d --- /dev/null +++ b/port/macos/host/host.h @@ -0,0 +1,109 @@ +/* Native Darwin services for the rebased ILP32 ARM guest. + * The shared wire ABI is in port/android/include/halo_android_abi.h. + */ + +#ifndef __HALO_MACOS_HOST_H +#define __HALO_MACOS_HOST_H + +#include +#include +#include + +#include "halo_android_abi.h" + +#define HALO_MACOS_BIAS UINT64_C(0x10000000000) +#define HALO_ARENA_SIZE UINT64_C(0x100000000) +#define guest_code_pointer guest_pointer +#define HALO_MACOS_PAGE 16384u +static inline void *guest_pointer(uint64_t p) { + return p ? (void *)(HALO_MACOS_BIAS | (uint32_t)p) : NULL; +} +int host_linux_errno(int value); +void host_install_signal_handlers(void); + +/* ---------- logging */ + +void host_logf(int priority, const char *format, ...) __attribute__((format(printf, 2, 3))); +#define HOST_LOG_INFO 4 +#define HOST_LOG_WARN 5 +#define HOST_LOG_ERROR 6 +/* guest services also used inside the host (host_main.c) */ +void host_exit(int code) __attribute__((noreturn)); +int host_errno(void); +/* Queue an OS invite in this instance's save folder for the P2P thread. */ +int host_invite_received(const char *text); +/* Discord launches the app first, then delivers its invite through RPC. */ +int host_is_discord_launch_url(const char *text); + +/* logs, shows the message to the player and terminates */ +void host_fatal(const char *format, ...) __attribute__((format(printf, 1, 2), noreturn)); + +/* ---------- guest memory (host_memory.c) + +Guest pointers remain 32-bit offsets. The LLVM pass rebases dereferences +into a reserved 4 GB virtual arena above macOS's low-address guard. Physical +memory is committed on demand. Darwin allocations use 16 KB pages, with +separate 4 KB protection bookkeeping for Xbox allocations. */ + +/* reserves the fixed ranges; returns 0 on success */ +int host_memory_initialize(uint32_t image_base, uint32_t image_size); +/* page-granular allocations in the guest arena; NULL on failure */ +void *host_low_map(size_t size, int protection); +void host_low_unmap(void *address, size_t size); +/* 1 if [address, address + size) was handed out by host_low_map or is one of +the fixed ranges */ +int host_low_owns(uintptr_t address, size_t size); +/* the guest's mmap/munmap/mprotect/madvise/mremap (host_syscall.c) */ +long host_guest_mmap(uint64_t address, uint64_t size, int protection, int flags, int fd, + int64_t offset); +long host_guest_munmap(uint64_t address, uint64_t size); +long host_guest_mprotect(uint64_t address, uint64_t size, int protection); +void host_memory_watch_protect(uint32_t address, uint32_t size); +uint32_t host_memory_watch_generation(uint32_t address, uint32_t size); +void host_memory_watch_prepare_write(uint32_t address, uint32_t size); +void host_memory_watch_forget(uint32_t address, uint32_t size); + +/* ---------- the guest image (host_loader.c) */ + +struct host_guest_image { + const struct halo_guest_header *header; + uint32_t base, end; +}; + +extern struct host_guest_image host_image; + +/* maps the image from the ELF file in memory; returns 0 on success */ +int host_load_image(const void *elf, size_t size); + +/* ---------- entering guest code (host_thread.c) */ + +/* calls the guest function at address with up to four 32-bit arguments on +this thread, which must have been made by host_native_thread_create (giving +the thread a guest struct pthread first if it has none); returns the +guest's w0 */ +uint32_t host_call_guest(uint32_t function, uint32_t a, uint32_t b, uint32_t c, uint32_t d); +/* starts a thread running function(argument) with its stack in guest +memory, so that it can call guest code; the stack is freed after it exits. +Returns 0 or an errno value */ +int host_native_thread_create(void *(*function)(void *), void *argument, size_t stack_size); +/* runs the guest's __guest_start on the calling thread (one made by +host_native_thread_create); does not return */ +void host_run_guest_main(uint32_t boot) __attribute__((noreturn)); + +/* ---------- debugging (host_debug.c) */ + +void host_debug_thread_started(void); +void host_debug_thread_exited(void); +/* HALO_SAMPLE: seconds between samples of the guest threads, or NULL */ +void host_debug_start_sampler(const char *setting); + +/* ---------- import table (host_imports.c) */ + +/* the host function for an import name, or NULL */ +void *host_resolve_import(const char *name); + +/* ---------- SDL / GL (host_sdl.c, host_gl.c) */ + +void *host_gl_resolve(const char *name); + +#endif diff --git a/port/macos/host/host_discord.c b/port/macos/host/host_discord.c new file mode 100644 index 0000000000..97217b0b45 --- /dev/null +++ b/port/macos/host/host_discord.c @@ -0,0 +1,58 @@ +/* Discord RPC uses native Unix sockets, outside the guest Winsock adapter. */ +#ifndef HALO_IOS +#include "posix.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int posix_discord_connect(void) { + const char *directories[6]; + const char *variables[] = {"XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"}; + char user_temporary[1024]; + int count = 0; + for (size_t i = 0; i < sizeof(variables) / sizeof(*variables); i++) { + const char *value = getenv(variables[i]); + if (value && *value) + directories[count++] = value; + } + /* Finder/Discord launches need not inherit the shell's TMPDIR. */ + size_t size = confstr(_CS_DARWIN_USER_TEMP_DIR, user_temporary, sizeof(user_temporary)); + if (size > 0 && size <= sizeof(user_temporary)) + directories[count++] = user_temporary; + directories[count++] = "/tmp"; + for (int i = 0; i < count; i++) { + for (int number = 0; number < 10; number++) { + struct sockaddr_un address = {.sun_family = AF_UNIX}; + int length = snprintf(address.sun_path, sizeof(address.sun_path), + "%s/discord-ipc-%d", directories[i], number); + if (length < 0 || length >= (int)sizeof(address.sun_path) || + access(address.sun_path, F_OK)) + continue; + address.sun_len = (uint8_t)(offsetof(struct sockaddr_un, sun_path) + length + 1); + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) + return -1; + int enabled = 1, flags = fcntl(fd, F_GETFL); + if (flags >= 0 && fcntl(fd, F_SETFD, FD_CLOEXEC) == 0 && + fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0 && + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)) == 0 && + connect(fd, (const struct sockaddr *)&address, address.sun_len) == 0) { + /* Like upstream's SO_PEERCRED check, only share an invite + with a socket belonging to this user. */ + uid_t user; + gid_t group; + if (getpeereid(fd, &user, &group) == 0 && user == getuid()) + return fd; + } + close(fd); + } + } + return -1; +} +#endif diff --git a/port/macos/host/host_errno.c b/port/macos/host/host_errno.c new file mode 100644 index 0000000000..755a603a89 --- /dev/null +++ b/port/macos/host/host_errno.c @@ -0,0 +1,100 @@ +/* Darwin errno values translated to the guest's Linux/musl ABI. */ +#include +#include + +int host_linux_errno(int value) { + static const unsigned char linux_errors[] = { + [EPERM] = 1, + [ENOENT] = 2, + [ESRCH] = 3, + [EINTR] = 4, + [EIO] = 5, + [ENXIO] = 6, + [E2BIG] = 7, + [ENOEXEC] = 8, + [EBADF] = 9, + [ECHILD] = 10, + [EAGAIN] = 11, + [ENOMEM] = 12, + [EACCES] = 13, + [EFAULT] = 14, + [ENOTBLK] = 15, + [EBUSY] = 16, + [EEXIST] = 17, + [EXDEV] = 18, + [ENODEV] = 19, + [ENOTDIR] = 20, + [EISDIR] = 21, + [EINVAL] = 22, + [ENFILE] = 23, + [EMFILE] = 24, + [ENOTTY] = 25, + [ETXTBSY] = 26, + [EFBIG] = 27, + [ENOSPC] = 28, + [ESPIPE] = 29, + [EROFS] = 30, + [EMLINK] = 31, + [EPIPE] = 32, + [EDOM] = 33, + [ERANGE] = 34, + [EDEADLK] = 35, + [ENAMETOOLONG] = 36, + [ENOLCK] = 37, + [ENOSYS] = 38, + [ENOTEMPTY] = 39, + [ELOOP] = 40, + [ENOMSG] = 42, + [EIDRM] = 43, + [ENOSTR] = 60, + [ENODATA] = 61, + [ETIME] = 62, + [ENOSR] = 63, + [EREMOTE] = 66, + [ENOLINK] = 67, + [EPROTO] = 71, + [EMULTIHOP] = 72, + [EBADMSG] = 74, + [EOVERFLOW] = 75, + [EILSEQ] = 84, + [EUSERS] = 87, + [ENOTSOCK] = 88, + [EDESTADDRREQ] = 89, + [EMSGSIZE] = 90, + [EPROTOTYPE] = 91, + [ENOPROTOOPT] = 92, + [EPROTONOSUPPORT] = 93, + [ESOCKTNOSUPPORT] = 94, + [EOPNOTSUPP] = 95, + [EPFNOSUPPORT] = 96, + [EAFNOSUPPORT] = 97, + [EADDRINUSE] = 98, + [EADDRNOTAVAIL] = 99, + [ENETDOWN] = 100, + [ENETUNREACH] = 101, + [ENETRESET] = 102, + [ECONNABORTED] = 103, + [ECONNRESET] = 104, + [ENOBUFS] = 105, + [EISCONN] = 106, + [ENOTCONN] = 107, + [ESHUTDOWN] = 108, + [ETOOMANYREFS] = 109, + [ETIMEDOUT] = 110, + [ECONNREFUSED] = 111, + [EHOSTDOWN] = 112, + [EHOSTUNREACH] = 113, + [EALREADY] = 114, + [EINPROGRESS] = 115, + [ESTALE] = 116, + [EDQUOT] = 122, + [ECANCELED] = 125, + [EOWNERDEAD] = 130, + [ENOTRECOVERABLE] = 131, + }; + if (!value) + return 0; + if (value > 0 && (size_t)value < sizeof(linux_errors) && linux_errors[value]) + return linux_errors[value]; + return 5; /* EIO for host errors without a guest equivalent. */ +} diff --git a/port/macos/host/host_gl.c b/port/macos/host/host_gl.c new file mode 100644 index 0000000000..685b3d0e0e --- /dev/null +++ b/port/macos/host/host_gl.c @@ -0,0 +1,2 @@ +/* Share the existing host implementation; HALO_MACOS selects Darwin adapters. */ +#include "../../android/host/host_gl.c" diff --git a/port/macos/host/host_gl_bridge.c b/port/macos/host/host_gl_bridge.c new file mode 100644 index 0000000000..c2743a5f7e --- /dev/null +++ b/port/macos/host/host_gl_bridge.c @@ -0,0 +1,45 @@ +/* Darwin packs stack arguments; the Android guest bridge widens each slot. */ +#include +#include +static void bridge_glCopyImageSubData(GLuint a0, GLenum a1, GLint a2, GLint a3, GLint a4, GLint a5, + GLuint a6, GLenum a7, long long a8, long long a9, + long long a10, long long a11, long long a12, long long a13, + long long a14) { + glCopyImageSubData(a0, a1, a2, a3, a4, a5, a6, a7, (GLint)a8, (GLint)a9, (GLint)a10, (GLint)a11, + (GLsizei)a12, (GLsizei)a13, (GLsizei)a14); +} +static void bridge_glTexImage2D(GLenum a0, GLint a1, GLint a2, GLsizei a3, GLsizei a4, GLint a5, + GLenum a6, GLenum a7, long long a8) { + glTexImage2D(a0, a1, a2, a3, a4, a5, a6, a7, (const void *)a8); +} +static void bridge_glTexImage3D(GLenum a0, GLint a1, GLint a2, GLsizei a3, GLsizei a4, GLsizei a5, + GLint a6, GLenum a7, long long a8, long long a9) { + glTexImage3D(a0, a1, a2, a3, a4, a5, a6, a7, (GLenum)a8, (const void *)a9); +} +static void bridge_glTexSubImage2D(GLenum a0, GLint a1, GLint a2, GLint a3, GLsizei a4, GLsizei a5, + GLenum a6, GLenum a7, long long a8) { + glTexSubImage2D(a0, a1, a2, a3, a4, a5, a6, a7, (const void *)a8); +} +static void bridge_glCompressedTexImage3D(GLenum a0, GLint a1, GLenum a2, GLsizei a3, GLsizei a4, + GLsizei a5, GLint a6, GLsizei a7, long long a8) { + glCompressedTexImage3D(a0, a1, a2, a3, a4, a5, a6, a7, (const void *)a8); +} +static void bridge_glBlitFramebuffer(GLint a0, GLint a1, GLint a2, GLint a3, GLint a4, GLint a5, + GLint a6, GLint a7, long long a8, long long a9) { + glBlitFramebuffer(a0, a1, a2, a3, a4, a5, a6, a7, (GLbitfield)a8, (GLenum)a9); +} +void *host_gl_bridge(const char *name) { + if (!strcmp(name, "glCopyImageSubData")) + return (void *)bridge_glCopyImageSubData; + if (!strcmp(name, "glTexImage2D")) + return (void *)bridge_glTexImage2D; + if (!strcmp(name, "glTexImage3D")) + return (void *)bridge_glTexImage3D; + if (!strcmp(name, "glTexSubImage2D")) + return (void *)bridge_glTexSubImage2D; + if (!strcmp(name, "glCompressedTexImage3D")) + return (void *)bridge_glCompressedTexImage3D; + if (!strcmp(name, "glBlitFramebuffer")) + return (void *)bridge_glBlitFramebuffer; + return 0; +} diff --git a/port/macos/host/host_invite.c b/port/macos/host/host_invite.c new file mode 100644 index 0000000000..48ed5eedc9 --- /dev/null +++ b/port/macos/host/host_invite.c @@ -0,0 +1,51 @@ +/* Pass Cocoa URL events to the guest without exposing a native pointer. */ +#include "host.h" +#include "../../linux/src/p2p_internal.h" +#include +#include +#include +#include +#include + +int host_is_discord_launch_url(const char *text) { + static const char prefix[] = "discord-"; + if (!text || strncmp(text, prefix, sizeof(prefix) - 1)) + return 0; + const char *application = text + sizeof(prefix) - 1; + size_t length = strspn(application, "0123456789"); + return length > 0 && length < 32 && !strncmp(application + length, "://", 3); +} + +int host_invite_received(const char *text) { + static const char prefix[] = "halo://join/"; + const size_t prefix_size = sizeof(prefix) - 1; + const size_t code_size = 2 * (P2P_KEY_HASH_SIZE + P2P_TOKEN_SIZE); + if (!text || strncmp(text, prefix, prefix_size) || strlen(text) != prefix_size + code_size || + strspn(text + prefix_size, "0123456789abcdefABCDEF") != code_size) + return 0; + const char *saves = getenv("HALO_SAVE_ROOT"); + if (!saves || !*saves) + return 0; + char destination[4096], temporary[4096]; + int destination_size = snprintf(destination, sizeof(destination), "%s/join_link.txt", saves); + int temporary_size = snprintf(temporary, sizeof(temporary), "%s/join_link.XXXXXX", saves); + if (destination_size < 0 || destination_size >= (int)sizeof(destination) || + temporary_size < 0 || temporary_size >= (int)sizeof(temporary)) + return 0; + int descriptor = mkstemp(temporary); + if (descriptor < 0) + return 0; + size_t length = strlen(text), written = 0; + while (written < length) { + ssize_t n = write(descriptor, text + written, length - written); + if (n < 0 && errno == EINTR) + continue; + if (n <= 0) + break; + written += (size_t)n; + } + int result = close(descriptor) == 0 && written == length && rename(temporary, destination) == 0; + if (!result) + unlink(temporary); + return result; +} diff --git a/port/macos/host/host_loader.c b/port/macos/host/host_loader.c new file mode 100644 index 0000000000..4220f6bef2 --- /dev/null +++ b/port/macos/host/host_loader.c @@ -0,0 +1,93 @@ +/* Loader for the locally compiled rebased ARM game image. */ +#include "host.h" +#include +#include +#include +#include +typedef struct { + unsigned char ident[16]; + uint16_t type, machine; + uint32_t version; + uint64_t entry, phoff, shoff; + uint32_t flags; + uint16_t ehsize, phentsize, phnum, shentsize, shnum, shstrndx; +} Elf; +typedef struct { + uint32_t type, flags; + uint64_t offset, vaddr, paddr, filesz, memsz, align; +} Segment; +struct host_guest_image host_image; +static int load_failure(int line) { + host_logf(HOST_LOG_ERROR, "Loader failure at line %d (errno %d)", line, errno); + return -1; +} +static int extent(uint64_t a, uint64_t n, uint64_t size) { return a <= size && n <= size - a; } +int host_load_image(const void *data, size_t size) { + const Elf *elf = data; + uint64_t low = UINT64_MAX, high = 0; + if (size < sizeof(*elf) || memcmp(elf->ident, "\177ELF\2\1", 6) || elf->machine != 183 || + elf->type != 2 || elf->phentsize != sizeof(Segment) || + !extent(elf->phoff, (uint64_t)elf->phnum * sizeof(Segment), size)) + return load_failure(__LINE__); + const Segment *segments = (const void *)((const char *)data + elf->phoff); + for (unsigned i = 0; i < elf->phnum; i++) + if (segments[i].type == 1) { + const Segment *s = segments + i; + if (s->filesz > s->memsz || !extent(s->offset, s->filesz, size) || + !extent(s->vaddr, s->memsz, UINT64_C(0x100000000))) + return load_failure(__LINE__); + if (s->vaddr < low) + low = s->vaddr; + if (s->vaddr + s->memsz > high) + high = s->vaddr + s->memsz; + } + high = (high + HALO_MACOS_PAGE - 1) & ~(uint64_t)(HALO_MACOS_PAGE - 1); + if (low != HALO_GUEST_IMAGE_BASE || high <= low || high - low > 0x10000000) + return load_failure(__LINE__); + if (host_memory_initialize((uint32_t)low, (uint32_t)(high - low))) + return load_failure(__LINE__); + for (unsigned i = 0; i < elf->phnum; i++) + if (segments[i].type == 1) + memcpy(guest_pointer(segments[i].vaddr), (const char *)data + segments[i].offset, + segments[i].filesz); + const struct halo_guest_header *header = guest_pointer(low); + if (header->magic != HALO_GUEST_MAGIC || header->abi_version != HALO_GUEST_ABI_VERSION || + header->image_end > high) + return load_failure(__LINE__); + host_image = (struct host_guest_image){header, (uint32_t)low, (uint32_t)high}; + if (header->import_count < low || !extent(header->import_count, 4, high)) + return load_failure(__LINE__); + uint32_t count = *(uint32_t *)guest_pointer(header->import_count); + if (count > 4096 || header->import_table < low || + !extent(header->import_table, (uint64_t)count * 8, high) || header->import_names < low || + header->import_names >= high) + return load_failure(__LINE__); + uint64_t *table = guest_pointer(header->import_table); + const char *name = guest_pointer(header->import_names); + const char *end = guest_pointer(high); + for (uint32_t i = 0; i < count; i++) { + if (name >= end || !memchr(name, 0, (size_t)(end - name))) + return load_failure(__LINE__); + void *fn = host_resolve_import(name); + if (!fn && !strncmp(name, "hostgl_", 7)) + fn = host_gl_resolve(name + 7); + if (!fn) { + host_logf(HOST_LOG_ERROR, "missing import: %s", name); + return load_failure(__LINE__); + } + table[i] = (uintptr_t)fn; + name += strlen(name) + 1; + } + for (unsigned i = 0; i < elf->phnum; i++) + if (segments[i].type == 1 && (segments[i].flags & 1)) { + uint64_t start = segments[i].vaddr & ~(uint64_t)(HALO_MACOS_PAGE - 1); + uint64_t end = (segments[i].vaddr + segments[i].memsz + HALO_MACOS_PAGE - 1) & + ~(uint64_t)(HALO_MACOS_PAGE - 1); + sys_icache_invalidate(guest_pointer(start), end - start); + if (mprotect(guest_pointer(start), end - start, PROT_READ | PROT_EXEC)) + return load_failure(__LINE__); + } + host_logf(HOST_LOG_INFO, "Loaded ARM image %08x-%08x with %u host imports", host_image.base, + host_image.end, count); + return 0; +} diff --git a/port/macos/host/host_main.c b/port/macos/host/host_main.c new file mode 100644 index 0000000000..6ee08bf37c --- /dev/null +++ b/port/macos/host/host_main.c @@ -0,0 +1,204 @@ +/* Native macOS entry point. SDL video remains on the real main thread. */ +#include "host.h" +#include "../native/host_menu.h" +#define SDL_MAIN_HANDLED +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static char data_root[4096], save_root[4096]; +static int create_directories(const char *path) { + char buffer[4096]; + if (strlen(path) >= sizeof(buffer)) { + errno = ENAMETOOLONG; + return -1; + } + strcpy(buffer, path); + for (char *p = buffer + 1; *p; p++) + if (*p == '/') { + *p = 0; + if (mkdir(buffer, 0755) && errno != EEXIST) + return -1; + *p = '/'; + } + return mkdir(buffer, 0755) && errno != EEXIST ? -1 : 0; +} +void host_logf(int priority, const char *format, ...) { + (void)priority; + va_list a; + va_start(a, format); + vfprintf(stderr, format, a); + fputc('\n', stderr); + va_end(a); +} +void host_log(int priority, const char *text) { host_logf(priority, "%s", text); } +void host_fatal(const char *format, ...) { + va_list a; + va_start(a, format); + vfprintf(stderr, format, a); + fputc('\n', stderr); + va_end(a); + exit(1); +} +void host_abort(const char *reason) { host_fatal("guest abort: %s", reason); } +void host_exit(int code) { + host_logf(HOST_LOG_INFO, "Game exited (%d)", code); + host_menu_finish_game(code); + exit(code); +} +int host_errno(void) { return host_linux_errno(errno); } +void host_android_path(int which, char *buffer, uint32_t size) { + snprintf(buffer, size, "%s", which ? save_root : data_root); +} +extern char **environ; +static uint32_t make_boot(void) { + size_t size = 0x20000; + char *memory = host_low_map(size, PROT_READ | PROT_WRITE); + if (!memory) + host_fatal("No guest environment memory"); + struct halo_guest_boot *boot = (void *)memory; + uint32_t *args = (void *)(memory + sizeof(*boot)); + uint32_t *env = args + 2; + char *strings = (void *)(env + 256); + unsigned count = 0; + strcpy(strings, "halo"); + args[0] = (uint32_t)(uintptr_t)strings; + args[1] = 0; + strings += 5; + for (char **p = environ; *p && count < 255; p++) { + size_t n = strlen(*p) + 1; + if (strings + n > memory + size) + break; + memcpy(strings, *p, n); + env[count++] = (uint32_t)(uintptr_t)strings; + strings += n; + } + env[count] = 0; + *boot = (struct halo_guest_boot){1, (uint32_t)(uintptr_t)args, (uint32_t)(uintptr_t)env, + HALO_MACOS_PAGE}; + return (uint32_t)(uintptr_t)boot; +} +extern void macos_enter_guest_stack(void *top, uint32_t boot) __attribute__((noreturn)); +int main(int argc, char **argv) { + const char *image_argument = NULL; + for (int i = 1; i < argc; i++) { + if (!strncmp(argv[i], "halo://join/", 12) || host_is_discord_launch_url(argv[i])) + continue; + if (image_argument) { + fprintf(stderr, "Usage: %s [halo_guest.elf] [halo://join/invite]\n", argv[0]); + return 2; + } + image_argument = argv[i]; + } + setbuf(stderr, NULL); + host_install_signal_handlers(); + char executable[4096], full_executable[4096], resources[4096], default_image[4096], default_data[4096], + default_saves[4096]; + uint32_t executable_size = sizeof(executable); + if (_NSGetExecutablePath(executable, &executable_size)) + host_fatal("Executable path is too long"); + snprintf(full_executable, sizeof(full_executable), "%s", executable); + char *slash = strrchr(executable, '/'); + if (!slash) + host_fatal("Cannot locate application resources"); + *slash = 0; + snprintf(resources, sizeof(resources), "%s/../Resources", executable); + snprintf(default_image, sizeof(default_image), "%s/halo_guest.elf", resources); + snprintf(default_data, sizeof(default_data), "%s/GameData", resources); + if (!image_argument && access(default_data, F_OK)) { + char config[4096]; + snprintf(config, sizeof(config), "%s/GameDataPath.txt", resources); + FILE *setting = fopen(config, "r"); + if (setting) { + if (fgets(default_data, sizeof(default_data), setting)) + default_data[strcspn(default_data, "\r\n")] = 0; + fclose(setting); + } + } + const char *home = getenv("HOME"); + if (!home) + host_fatal("HOME is not set"); + snprintf(default_saves, sizeof(default_saves), + "%s/Library/Application Support/Halo CE Universal", home); + const char *saves = getenv("HALO_SAVE_ROOT"); + if (!saves) + saves = !image_argument ? default_saves : "build/macos/saves"; + if (create_directories(saves) || !realpath(saves, save_root)) + host_fatal("Cannot open saves folder: %s", saves); + if (!image_argument) host_menu_initialize_application(); + SDL_SetMainReady(); + SDL_SetHint(SDL_HINT_VIDEO_MAC_FULLSCREEN_SPACES, "0"); + SDL_SetHint(SDL_HINT_VIDEO_MAC_FULLSCREEN_MENU_VISIBILITY, "1"); + /* Keep native settings/file panels and the menu bar above borderless video. */ + SDL_SetHint(SDL_HINT_WINDOW_ALLOW_TOPMOST, "0"); + if (!SDL_InitSubSystem(SDL_INIT_VIDEO)) + host_fatal("Cannot initialize display: %s", SDL_GetError()); + const char *root = getenv("HALO_DATA_ROOT"); + if (!root) root = !image_argument ? default_data : "assets"; + char selected_data[4096]; + if (!image_argument) { + if (!host_menu_prepare(save_root, root, selected_data, sizeof(selected_data))) return 0; + root = selected_data; + } + if (!realpath(root, data_root)) + host_fatal("Game data folder is missing: %s", root); + if (!image_argument) { + char log_path[4096]; + snprintf(log_path, sizeof(log_path), "%s/halo.log", save_root); + freopen(log_path, "w", stderr); + setbuf(stderr, NULL); + } + host_logf(HOST_LOG_INFO, "App executable: %s", full_executable); + char build_info_path[4096], build_info_line[256]; + snprintf(build_info_path, sizeof(build_info_path), "%s/BuildInfo.txt", resources); + FILE *build_info = fopen(build_info_path, "r"); + if (build_info) { + while (fgets(build_info_line, sizeof(build_info_line), build_info)) { + build_info_line[strcspn(build_info_line, "\r\n")] = 0; + host_logf(HOST_LOG_INFO, "%s", build_info_line); + } + fclose(build_info); + } + setenv("HALO_DATA_ROOT", data_root, 1); + setenv("HALO_SAVE_ROOT", save_root, 1); + const SDL_DisplayMode *display = SDL_GetDesktopDisplayMode(SDL_GetPrimaryDisplay()); + if (display && display->h > 0) { + char width[32]; + snprintf(width, sizeof(width), "%d", (480 * display->w / display->h) & ~1); + setenv("HALO_DISPLAY_WIDTH", width, 0); + host_logf(HOST_LOG_INFO, "Display %dx%d; game aspect %sx480", display->w, + display->h, getenv("HALO_DISPLAY_WIDTH")); + } + const char *image_path = image_argument ? image_argument : default_image; + FILE *file = fopen(image_path, "rb"); + if (!file) + host_fatal("Cannot open guest image: %s", image_path); + fseek(file, 0, SEEK_END); + long size = ftell(file); + rewind(file); + if (size <= 0 || size > 256 * 1024 * 1024) + host_fatal("Invalid guest image size"); + void *image = malloc((size_t)size); + if (!image || fread(image, 1, (size_t)size, file) != (size_t)size) + host_fatal("Cannot read guest image"); + fclose(file); + if (host_load_image(image, (size_t)size)) + host_fatal("Cannot load rebased game image"); + free(image); + uint32_t boot = make_boot(); + size_t stack_size = 16 * 1024 * 1024; + char *stack = host_low_map(stack_size + HALO_MACOS_PAGE, PROT_READ | PROT_WRITE); + if (!stack) + host_fatal("Cannot allocate main stack"); + mprotect(stack, HALO_MACOS_PAGE, PROT_NONE); + host_logf(HOST_LOG_INFO, "Halo ARM64 starting: data %s; saves %s", data_root, save_root); + if (!image_argument) host_menu_begin_game(); + macos_enter_guest_stack(stack + stack_size + HALO_MACOS_PAGE, boot); +} diff --git a/port/macos/host/host_memory.c b/port/macos/host/host_memory.c new file mode 100644 index 0000000000..720c51a838 --- /dev/null +++ b/port/macos/host/host_memory.c @@ -0,0 +1,247 @@ +/* Native Darwin storage for the ILP32 guest, biased above the low 4 GB. */ +#include "host.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define PAGE HALO_MACOS_PAGE +#define PAGES (UINT64_C(0x100000000) / PAGE) +static unsigned char used[PAGES], watched[PAGES]; +static uint32_t generation[PAGES]; +static uint32_t serial = 1; +static pthread_mutex_t memory_lock = PTHREAD_MUTEX_INITIALIZER; +static int initialized; +/* Four Xbox 4 KB pages share one Darwin 16 KB page. Preserve the access + * required by neighboring allocations when a single Xbox page changes. */ +static unsigned char xbox_protection[HALO_GUEST_WINDOW_SIZE / 4096]; +static int xbox_protect(uint32_t address, size_t size, int protection, int fresh) { + if ((address & 4095) || !size || + (uint64_t)address + size > HALO_GUEST_WINDOW_BASE + HALO_GUEST_WINDOW_SIZE) + return -22; + uint32_t first = (address - HALO_GUEST_WINDOW_BASE) / 4096; + size_t count = (size + 4095) / 4096; + uint32_t native_first = first & ~3u; + size_t native_end = (first + count + 3) & ~(size_t)3; + pthread_mutex_lock(&memory_lock); + /* Updating one Xbox allocation can make its whole Darwin page writable. + Invalidate watched neighbors before changing protection or zeroing data, + so cached geometry and textures notice subsequent writes. */ + host_memory_watch_prepare_write(HALO_GUEST_WINDOW_BASE + native_first * 4096, + (uint32_t)((native_end - native_first) * 4096)); + if (fresh && protection) { + if (mprotect(guest_pointer(HALO_GUEST_WINDOW_BASE + native_first * 4096), + (native_end - native_first) * 4096, PROT_READ | PROT_WRITE)) { + pthread_mutex_unlock(&memory_lock); + return -host_linux_errno(errno); + } + memset(guest_pointer(address), 0, count * 4096); + } + memset(xbox_protection + first, protection, count); + int result = 0; + for (size_t i = native_first; i < native_end; i += 4) { + int combined = xbox_protection[i] | xbox_protection[i + 1] | xbox_protection[i + 2] | + xbox_protection[i + 3]; + if (mprotect(guest_pointer(HALO_GUEST_WINDOW_BASE + i * 4096), PAGE, combined)) { + result = -host_linux_errno(errno); + break; + } + } + pthread_mutex_unlock(&memory_lock); + return result; +} +static size_t rounded(size_t n) { return (n + PAGE - 1) & ~(size_t)(PAGE - 1); } +static void occupy(uint32_t a, size_t n) { memset(used + a / PAGE, 1, rounded(n) / PAGE); } +int host_memory_initialize(uint32_t image_base, uint32_t image_size) { + if (initialized) + return 0; + // Unlike MAP_FIXED, mach_vm_allocate with VM_FLAGS_FIXED never replaces + // an existing mapping. Darwin may disregard mmap hints for a large arena. + vm_address_t address = HALO_MACOS_BIAS; + kern_return_t error = + vm_allocate(mach_task_self(), &address, HALO_ARENA_SIZE, VM_FLAGS_FIXED); + if (error != KERN_SUCCESS) { + host_logf(HOST_LOG_ERROR, "Guest arena reservation: %s", mach_error_string(error)); + vm_address_t occupied = HALO_MACOS_BIAS; + vm_size_t length = 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; + if (vm_region_64(mach_task_self(), &occupied, &length, VM_REGION_BASIC_INFO_64, + (vm_region_info_t)&info, &count, &object) == KERN_SUCCESS) { + host_logf(HOST_LOG_ERROR, "Next occupied range: %llx-%llx", + (unsigned long long)occupied, (unsigned long long)(occupied + length)); + if (object != MACH_PORT_NULL) mach_port_deallocate(mach_task_self(), object); + } + return -1; + } + if (mprotect((void *)address, HALO_ARENA_SIZE, PROT_NONE)) + return -1; + occupy(0, 0x1000000); + occupy(HALO_GUEST_WINDOW_BASE, HALO_GUEST_WINDOW_SIZE); + occupy(image_base, image_size); + if (mprotect(guest_pointer(HALO_GUEST_WINDOW_BASE), HALO_GUEST_WINDOW_SIZE, + PROT_READ | PROT_WRITE)) + return -1; + if (mprotect(guest_pointer(image_base), rounded(image_size), PROT_READ | PROT_WRITE)) + return -1; + initialized = 1; + return 0; +} +void *host_low_map(size_t n, int protection) { + if (!n || n > 0x40000000) + return NULL; + n = rounded(n); + unsigned need = (unsigned)(n / PAGE), run = 0, start = 0; + pthread_mutex_lock(&memory_lock); + for (unsigned i = 0x1000000 / PAGE; i < HALO_GUEST_WINDOW_BASE / PAGE; i++) { + if (used[i]) + run = 0; + else { + if (!run) + start = i; + if (++run == need) { + void *p = guest_pointer(start * PAGE); + /* MAP_FIXED is safe here: this is a free portion of our already reserved + * arena. A fresh anonymous map must contain zeros, including after reuse; + * Darwin's MADV_DONTNEED alone does not provide that guarantee. */ + if (mmap(p, n, protection, MAP_PRIVATE | MAP_ANON | MAP_FIXED, -1, 0) == + MAP_FAILED) { + pthread_mutex_unlock(&memory_lock); + return NULL; + } + memset(used + start, 1, need); + pthread_mutex_unlock(&memory_lock); + return p; + } + } + } + pthread_mutex_unlock(&memory_lock); + return NULL; +} +void host_low_unmap(void *p, size_t n) { + uint32_t a = (uint32_t)(uintptr_t)p; + if (!n || a < 0x1000000 || (uint64_t)a + n > HALO_GUEST_WINDOW_BASE) + return; + n = rounded(n); + pthread_mutex_lock(&memory_lock); + mprotect(p, n, PROT_NONE); + madvise(p, n, MADV_DONTNEED); + memset(used + a / PAGE, 0, n / PAGE); + memset(watched + a / PAGE, 0, n / PAGE); + pthread_mutex_unlock(&memory_lock); +} +int host_low_owns(uintptr_t a, size_t n) { + return a >= HALO_MACOS_BIAS && a + n >= a && a + n <= HALO_MACOS_BIAS + HALO_ARENA_SIZE; +} +long host_guest_mmap(uint64_t a, uint64_t n, int prot, int flags, int fd, int64_t offset) { + if (!n || n > 0x40000000) + return -22; + if (!(flags & 0x20) || fd != -1 || offset) + return -38; + if (flags & (0x10 | 0x100000)) { + if (a < HALO_GUEST_WINDOW_BASE || a + n > HALO_GUEST_WINDOW_BASE + HALO_GUEST_WINDOW_SIZE) + return -22; + if (!(flags & 0x100000)) { + int result = xbox_protect((uint32_t)a, n, prot, 1); + if (result) + return result; + } + return (uint32_t)a; + } + void *p = host_low_map(n, prot); + return p ? (long)(uint32_t)(uintptr_t)p : -12; +} +long host_guest_munmap(uint64_t a, uint64_t n) { + if ((a & (PAGE - 1)) || a + n > UINT64_C(0x100000000)) + return -22; + host_low_unmap(guest_pointer(a), n); + return 0; +} +long host_guest_mprotect(uint64_t a, uint64_t n, int prot) { + if (!n || a + n > UINT64_C(0x100000000)) + return -22; + if (a >= HALO_GUEST_WINDOW_BASE && a + n <= HALO_GUEST_WINDOW_BASE + HALO_GUEST_WINDOW_SIZE) + return xbox_protect((uint32_t)a, n, prot, 0); + uint32_t start = (uint32_t)a & ~(PAGE - 1); + size_t size = rounded(a + n - start); + return mprotect(guest_pointer(start), size, prot) ? -host_linux_errno(errno) : 0; +} +void host_memory_watch_initialize(void) {} +void host_memory_watch_protect(uint32_t a, uint32_t n) { + if (!n || (uint64_t)a + n > UINT64_C(0x100000000)) + return; + uint32_t first = a / PAGE, last = (a + n - 1) / PAGE; + for (uint32_t i = first; i <= last; i++) + if (!__atomic_load_n(watched + i, __ATOMIC_RELAXED)) { + __atomic_store_n(watched + i, 1, __ATOMIC_RELEASE); + mprotect(guest_pointer(i * PAGE), PAGE, PROT_READ); + } +} +uint32_t host_memory_watch_generation(uint32_t a, uint32_t n) { + uint32_t result = 0; + if (!n || (uint64_t)a + n > UINT64_C(0x100000000)) + return 0; + for (uint32_t i = a / PAGE; i <= (a + n - 1) / PAGE; i++) { + uint32_t g = __atomic_load_n(generation + i, __ATOMIC_ACQUIRE); + if (g > result) + result = g; + } + return result; +} +uint32_t host_memory_watch_serial(void) { return __atomic_load_n(&serial, __ATOMIC_ACQUIRE); } +void host_memory_watch_prepare_write(uint32_t a, uint32_t n) { + if (!n || (uint64_t)a + n > UINT64_C(0x100000000)) + return; + for (uint32_t i = a / PAGE; i <= (a + n - 1) / PAGE; i++) + if (__atomic_exchange_n(watched + i, 0, __ATOMIC_ACQ_REL)) { + mprotect(guest_pointer(i * PAGE), PAGE, PROT_READ | PROT_WRITE); + __atomic_store_n(generation + i, __atomic_add_fetch(&serial, 1, __ATOMIC_ACQ_REL), + __ATOMIC_RELEASE); + } +} +void host_memory_watch_forget(uint32_t a, uint32_t n) { + if (!n || (uint64_t)a + n > UINT64_C(0x100000000)) + return; + /* Remapping invalidates cached data even if a previous write already + removed the watch. Match the shared allocator's forget contract. */ + for (uint32_t i = a / PAGE; i <= (a + n - 1) / PAGE; i++) { + if (__atomic_exchange_n(watched + i, 0, __ATOMIC_ACQ_REL)) + mprotect(guest_pointer(i * PAGE), PAGE, PROT_READ | PROT_WRITE); + __atomic_store_n(generation + i, __atomic_add_fetch(&serial, 1, __ATOMIC_ACQ_REL), + __ATOMIC_RELEASE); + } +} +static void fault(int signal, siginfo_t *info, void *context) { + uintptr_t address = (uintptr_t)info->si_addr; + if (address >= HALO_MACOS_BIAS && address < HALO_MACOS_BIAS + UINT64_C(0x100000000)) { + uint32_t a = (uint32_t)address, index = a / PAGE; + if (__atomic_exchange_n(watched + index, 0, __ATOMIC_ACQ_REL)) { + mprotect(guest_pointer(index * PAGE), PAGE, PROT_READ | PROT_WRITE); + __atomic_store_n(generation + index, __atomic_add_fetch(&serial, 1, __ATOMIC_ACQ_REL), + __ATOMIC_RELEASE); + return; + } + } + ucontext_t *u = context; + uint32_t guest_pc = (uint32_t)u->uc_mcontext->__ss.__pc; + fprintf(stderr, "Halo fault %d at %p; pc=%llx guest pc=%x\n", signal, info->si_addr, + (unsigned long long)u->uc_mcontext->__ss.__pc, guest_pc); + _exit(128 + signal); +} +void host_install_signal_handlers(void) { + struct sigaction a = {0}; + a.sa_sigaction = fault; + a.sa_flags = SA_SIGINFO; + sigaction(SIGSEGV, &a, NULL); + sigaction(SIGBUS, &a, NULL); +} +void host_debug_thread_started(void) {} +void host_debug_thread_exited(void) {} +void host_debug_start_sampler(const char *setting) { (void)setting; } diff --git a/port/macos/host/host_sdl.c b/port/macos/host/host_sdl.c new file mode 100644 index 0000000000..cafa7f071a --- /dev/null +++ b/port/macos/host/host_sdl.c @@ -0,0 +1,222 @@ +/* Mac video and events; handles, audio and controllers use the shared host. */ +#include "host.h" +#include "../native_events.h" +#include "../native/host_menu.h" +#include +#include +#include "../../android/host/host_sdl.c" + +static EGLDisplay metal_display; +static EGLContext metal_context; +static EGLSurface metal_surface; +static int requested_minor; +static SDL_Window *metal_window; +static SDL_MetalView metal_view; +static int metal_window_hidden; +static int metal_swap_interval = 1; +static int command_held; + +/* ---------- general */ + +int host_sdl_init(uint32_t flags) { + /* Handle close requests ourselves so Command-W does not also queue + SDL_EVENT_QUIT while W is being used to move. */ + SDL_SetHint(SDL_HINT_QUIT_ON_LAST_WINDOW_CLOSE, "0"); + if (!SDL_Init((SDL_InitFlags)flags)) + return 0; + SDL_SetEventEnabled(SDL_EVENT_DROP_FILE, true); + SDL_SetEventEnabled(SDL_EVENT_DROP_TEXT, true); + return 1; +} + +/* ---------- video */ + +uint32_t host_sdl_create_window(const char *title, int width, int height, int64_t flags) { + const char *windowed = SDL_getenv("HALO_WINDOWED"); + SDL_WindowFlags mode = SDL_WINDOW_METAL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY; + metal_window_hidden = (flags & SDL_WINDOW_HIDDEN) != 0; + if (metal_window_hidden) mode |= SDL_WINDOW_HIDDEN; + else if (windowed ? !SDL_atoi(windowed) : (flags & SDL_WINDOW_FULLSCREEN) != 0) + mode |= SDL_WINDOW_FULLSCREEN; + metal_window = SDL_CreateWindow(title, width > 0 ? width : 1280, height > 0 ? height : 960, + mode); + if (metal_window) { + SDL_SyncWindow(metal_window); + int w, h; + SDL_GetWindowSizeInPixels(metal_window, &w, &h); + host_logf(HOST_LOG_INFO, "Metal drawable %dx%d (%s)", w, h, + (mode & SDL_WINDOW_FULLSCREEN) ? "borderless fullscreen" : "windowed"); + } + return handle_new(_handle_window, metal_window); +} + +int host_sdl_is_fullscreen(void) { + return metal_window && (SDL_GetWindowFlags(metal_window) & SDL_WINDOW_FULLSCREEN) != 0; +} +int host_sdl_display_mode(void) { + if (!host_sdl_is_fullscreen()) return 0; + return SDL_GetWindowFullscreenMode(metal_window) ? 2 : 1; +} +/* The guest's config.toml is authoritative; mode changes apply on its thread. */ +int host_sdl_apply_display(int mode, int width, int height) { + const SDL_DisplayMode *display = NULL; + if (!metal_window || mode < -1 || mode > 2 || width < 0 || height < 0 || + ((width == 0) != (height == 0))) return 0; + if (metal_window_hidden && mode >= 0) mode = 0; + if (mode >= 0) { + if (mode == 2) { + SDL_DisplayID screen = SDL_GetDisplayForWindow(metal_window); + display = screen ? SDL_GetDesktopDisplayMode(screen) : NULL; + if (!display) return 0; + } + if (!SDL_SetWindowFullscreenMode(metal_window, display) || + !SDL_SetWindowFullscreen(metal_window, mode != 0)) return 0; + } + if (width && !SDL_SetWindowSize(metal_window, width, height)) return 0; + if (!SDL_SyncWindow(metal_window)) return 0; + return 1; +} +void host_sdl_release_mouse(void) { + if (metal_window) SDL_SetWindowRelativeMouseMode(metal_window, false); + /* Cocoa panels also invalidate the guest's held keys/buttons. */ + SDL_Event event = {.type = SDL_EVENT_USER}; + event.user.code = HALO_MACOS_MOUSE_RELEASE; + SDL_PushEvent(&event); +} +void host_sdl_request_quit(void) { + SDL_Event event = {.type = SDL_EVENT_QUIT}; + SDL_PushEvent(&event); +} + +int host_sdl_set_relative_mouse(uint32_t window, int enabled) { + SDL_Window *object = handle_get(window, _handle_window); + + if (metal_window_hidden) return 1; + return object ? SDL_SetWindowRelativeMouseMode(object, enabled != 0) : 0; +} + +int host_sdl_gl_set_attribute(int attribute, int value) { + if (attribute == SDL_GL_CONTEXT_MINOR_VERSION) + requested_minor = value; + return 1; +} +uint32_t host_sdl_gl_create_context(uint32_t window) { + SDL_Window *object = handle_get(window, _handle_window); + if (!object || requested_minor > 0) { + SDL_SetError("ANGLE Metal uses ES 3.0"); + return 0; + } + PFNEGLGETPLATFORMDISPLAYEXTPROC getDisplay = + (void *)eglGetProcAddress("eglGetPlatformDisplayEXT"); + const EGLint displayAttributes[] = {0x3203, 0x3489, EGL_NONE}; + metal_display = getDisplay ? getDisplay(0x3202, (void *)0, displayAttributes) : EGL_NO_DISPLAY; + EGLint major, minor, count; + if (metal_display == EGL_NO_DISPLAY || !eglInitialize(metal_display, &major, &minor)) { + SDL_SetError("Cannot initialize ANGLE Metal: %x", eglGetError()); + return 0; + } + const EGLint configAttributes[] = {EGL_SURFACE_TYPE, + metal_window_hidden ? EGL_PBUFFER_BIT : EGL_WINDOW_BIT, + EGL_RENDERABLE_TYPE, + EGL_OPENGL_ES3_BIT, + EGL_RED_SIZE, + 8, + EGL_GREEN_SIZE, + 8, + EGL_BLUE_SIZE, + 8, + EGL_ALPHA_SIZE, + 8, + EGL_DEPTH_SIZE, + 24, + EGL_STENCIL_SIZE, + 8, + EGL_NONE}; + EGLConfig config; + if (!eglChooseConfig(metal_display, configAttributes, &config, 1, &count) || !count) + return 0; + eglBindAPI(EGL_OPENGL_ES_API); + const EGLint contextAttributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE}; + metal_context = eglCreateContext(metal_display, config, EGL_NO_CONTEXT, contextAttributes); + if (metal_window_hidden) { + /* Isolated launch/input checks use the shipped host without taking + focus or capturing the user's mouse. Ordinary launches use MetalView. */ + int width, height; + SDL_GetWindowSizeInPixels(object, &width, &height); + const EGLint size[] = {EGL_WIDTH, width, EGL_HEIGHT, height, EGL_NONE}; + metal_surface = eglCreatePbufferSurface(metal_display, config, size); + } else { + metal_view = SDL_Metal_CreateView(object); + metal_surface = eglCreateWindowSurface( + metal_display, config, (EGLNativeWindowType)SDL_Metal_GetLayer(metal_view), NULL); + } + if (metal_context == EGL_NO_CONTEXT || metal_surface == EGL_NO_SURFACE) { + SDL_SetError("Cannot create Metal surface: %x", eglGetError()); + return 0; + } + eglMakeCurrent(metal_display, metal_surface, metal_surface, metal_context); + return handle_new(_handle_context, metal_context); +} +int host_sdl_gl_make_current(uint32_t window, uint32_t context) { + (void)window; + return eglMakeCurrent(metal_display, metal_surface, metal_surface, + handle_get(context, _handle_context)); +} +int host_sdl_gl_set_swap_interval(int interval) { + metal_swap_interval = interval; + return eglSwapInterval(metal_display, interval); +} +int host_sdl_gl_swap_window(uint32_t window) { + (void)window; + uint64_t start = SDL_GetTicksNS(); + int result = eglSwapBuffers(metal_display, metal_surface); + if (metal_window_hidden && metal_swap_interval > 0) { + uint64_t elapsed = SDL_GetTicksNS() - start; + if (elapsed < 16666667) SDL_DelayNS(16666667 - elapsed); + } + return result; +} + +int host_sdl_poll_event(void *event) { + SDL_Event host_event; + + if (!SDL_PollEvent(&host_event)) + return 0; + if (host_event.type == SDL_EVENT_KEY_DOWN || host_event.type == SDL_EVENT_KEY_UP) { + command_held = host_event.key.scancode == SDL_SCANCODE_LGUI || + host_event.key.scancode == SDL_SCANCODE_RGUI + ? host_event.key.down : (host_event.key.mod & SDL_KMOD_GUI) != 0; + } + if (host_event.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED) { + if (command_held) { + host_logf(HOST_LOG_INFO, "Command-W does not close the game; use Command-Q to quit"); + memset(event, 0, sizeof(host_event)); + return 1; + } + host_event.type = SDL_EVENT_QUIT; + } + /* Cocoa sends opened URLs as drop-file events. Consume the native + string here; the guest's 32-bit SDL event cannot hold that pointer. */ + if (host_event.type == SDL_EVENT_DROP_FILE || host_event.type == SDL_EVENT_DROP_TEXT) { + if (host_is_discord_launch_url(host_event.drop.data)) + host_logf(HOST_LOG_INFO, "Internet play: Discord launch received; waiting for its invite"); + else if (host_invite_received(host_event.drop.data)) + host_logf(HOST_LOG_INFO, "Internet play: opened invite queued for this game"); + memset(event, 0, sizeof(host_event)); + return 1; + } + /* the layouts agree except for the pointers of text, drop and user + events, which the guest does not read */ + memcpy(event, &host_event, sizeof(host_event)); + return 1; +} + +int host_sdl_show_toast(const char *message, int duration, int gravity, int x, int y) { + (void)duration; (void)gravity; (void)x; (void)y; + host_logf(HOST_LOG_INFO, "%s", message); + return 1; +} + +int host_sdl_show_simple_message_box(uint32_t flags, const char *title, const char *message) { + return SDL_ShowSimpleMessageBox(flags, title, message, metal_window); +} diff --git a/port/macos/host/host_syscall.c b/port/macos/host/host_syscall.c new file mode 100644 index 0000000000..cfb4407584 --- /dev/null +++ b/port/macos/host/host_syscall.c @@ -0,0 +1,368 @@ +/* Translate the ARM guest's Linux syscall ABI into Darwin calls. */ +#include "host.h" +#include "port/android/guest/libc/arch/arm64_32/bits/syscall.h.in" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define G(t, p) ((t)guest_pointer(p)) +struct guest_time { + int32_t seconds, nanoseconds; +}; +struct guest_vec { + uint32_t address, length; +}; +struct guest_stat { + uint64_t dev, ino; + uint32_t mode, nlink, uid, gid; + uint64_t rdev; + int64_t size; + int32_t blksize; + int32_t pad; + int64_t blocks; + struct guest_time atime, mtime, ctime; +}; +struct guest_sysinfo { + uint32_t uptime, loads[3], totalram, freeram, sharedram, bufferram, totalswap, freeswap; + uint16_t procs, pad; + uint32_t totalhigh, freehigh, mem_unit; + char reserved[256]; +}; +static long result(long r) { return r == -1 ? -host_linux_errno(errno) : r; } +static struct timespec time_in(uint64_t p) { + struct guest_time *g = G(struct guest_time *, p); + return (struct timespec){g->seconds, g->nanoseconds}; +} +static void time_out(uint64_t p, struct timespec t) { + if (p) + *G(struct guest_time *, p) = (struct guest_time){(int32_t)t.tv_sec, (int32_t)t.tv_nsec}; +} +static clockid_t clock_id(int c) { + return c == 0 ? CLOCK_REALTIME + : c == 2 ? CLOCK_PROCESS_CPUTIME_ID + : c == 3 ? CLOCK_THREAD_CPUTIME_ID + : CLOCK_MONOTONIC; +} +static int dirfd_in(int f) { return f == -100 ? AT_FDCWD : f; } +static int open_flags(int f) { + int out = f & 3; + if (f & 64) + out |= O_CREAT; + if (f & 128) + out |= O_EXCL; + if (f & 512) + out |= O_TRUNC; + if (f & 1024) + out |= O_APPEND; + if (f & 2048) + out |= O_NONBLOCK; + if (f & 0x80000) + out |= O_CLOEXEC; + if (f & 0x10000) + out |= O_DIRECTORY; + return out; +} +static long stat_out(int fd, const char *path, uint64_t p, int flags) { + struct stat s; + int r = path ? fstatat(dirfd_in(fd), path, &s, (flags & 0x100) ? AT_SYMLINK_NOFOLLOW : 0) + : fstat(fd, &s); + if (r) + return result(r); + struct guest_stat *g = G(struct guest_stat *, p); + memset(g, 0, sizeof(*g)); + g->dev = s.st_dev; + g->ino = s.st_ino; + g->mode = s.st_mode; + g->nlink = s.st_nlink; + g->uid = s.st_uid; + g->gid = s.st_gid; + g->rdev = s.st_rdev; + g->size = s.st_size; + g->blksize = s.st_blksize; + g->blocks = s.st_blocks; + g->atime = (struct guest_time){(int32_t)s.st_atimespec.tv_sec, (int32_t)s.st_atimespec.tv_nsec}; + g->mtime = (struct guest_time){(int32_t)s.st_mtimespec.tv_sec, (int32_t)s.st_mtimespec.tv_nsec}; + g->ctime = (struct guest_time){(int32_t)s.st_ctimespec.tv_sec, (int32_t)s.st_ctimespec.tv_nsec}; + return 0; +} +struct waiter { + uint32_t address; + int woken; + pthread_cond_t condition; + struct waiter *next; +}; +static pthread_mutex_t futex_lock = PTHREAD_MUTEX_INITIALIZER; +static struct waiter *waiters; +static long futex(uint32_t address, int op, uint32_t value, uint64_t timeout) { + int command = op & 127; + pthread_mutex_lock(&futex_lock); + if (command == 0 || command == 9) { + if (__atomic_load_n(G(uint32_t *, address), __ATOMIC_SEQ_CST) != value) { + pthread_mutex_unlock(&futex_lock); + return -11; + } + struct waiter w = { + .address = address, .condition = PTHREAD_COND_INITIALIZER, .next = waiters}; + waiters = &w; + int error = 0; + struct timespec deadline; + if (timeout) { + deadline = time_in(timeout); + if (command == 0) { + struct timespec now; + clock_gettime(CLOCK_REALTIME, &now); + deadline.tv_sec += now.tv_sec; + deadline.tv_nsec += now.tv_nsec; + } else if (!(op & 256)) { + struct timespec real, mono; + clock_gettime(CLOCK_REALTIME, &real); + clock_gettime(CLOCK_MONOTONIC, &mono); + deadline.tv_sec += real.tv_sec - mono.tv_sec; + deadline.tv_nsec += real.tv_nsec - mono.tv_nsec; + } + if (deadline.tv_nsec >= 1000000000) { + deadline.tv_sec++; + deadline.tv_nsec -= 1000000000; + } + if (deadline.tv_nsec < 0) { + deadline.tv_sec--; + deadline.tv_nsec += 1000000000; + } + } + while (!w.woken && !error) + error = timeout ? pthread_cond_timedwait(&w.condition, &futex_lock, &deadline) + : pthread_cond_wait(&w.condition, &futex_lock); + struct waiter **cursor = &waiters; + while (*cursor != &w) + cursor = &(*cursor)->next; + *cursor = w.next; + pthread_mutex_unlock(&futex_lock); + pthread_cond_destroy(&w.condition); + return error ? -host_linux_errno(error) : 0; + } + if (command == 1 || command == 10) { + long count = 0; + for (struct waiter *w = waiters; w && count < value; w = w->next) + if (w->address == address && !w->woken) { + w->woken = 1; + pthread_cond_signal(&w->condition); + count++; + } + pthread_mutex_unlock(&futex_lock); + return count; + } + pthread_mutex_unlock(&futex_lock); + return -38; +} +long long host_syscall(long long number, long long a, long long b, long long c, long long d, + long long e, long long f) { + switch (number) { + case __NR_read: + return result(read((int)a, G(void *, b), (uint32_t)c)); + case __NR_write: + return result(write((int)a, G(const void *, b), (uint32_t)c)); + case __NR_pread64: + return result(pread((int)a, G(void *, b), (uint32_t)c, d)); + case __NR_pwrite64: + return result(pwrite((int)a, G(const void *, b), (uint32_t)c, d)); + case __NR_readv: + case __NR_writev: + case __NR_preadv: + case __NR_pwritev: { + if (c < 0 || c > 64) + return -22; + struct iovec v[64]; + struct guest_vec *g = G(struct guest_vec *, b); + for (int i = 0; i < c; i++) + v[i] = (struct iovec){guest_pointer(g[i].address), g[i].length}; + if (number == __NR_readv) + return result(readv((int)a, v, (int)c)); + if (number == __NR_writev) + return result(writev((int)a, v, (int)c)); + if (number == __NR_preadv) + return result(preadv((int)a, v, (int)c, d)); + return result(pwritev((int)a, v, (int)c, d)); + } + case __NR_openat: + return result(openat(dirfd_in((int)a), G(const char *, b), open_flags((int)c), (mode_t)d)); + case __NR_close: + return result(close((int)a)); + case __NR_lseek: + return result(lseek((int)a, b, (int)c)); + case __NR_fstat: + return stat_out((int)a, NULL, b, 0); + case __NR_newfstatat: + return stat_out((int)a, G(const char *, b), c, (int)d); + case __NR_ftruncate: + return result(ftruncate((int)a, b)); + case __NR_fsync: + case __NR_fdatasync: + return result(fsync((int)a)); + case __NR_fcntl: + if (b == 1) + return result(fcntl((int)a, F_GETFD)); + if (b == 2) + return result(fcntl((int)a, F_SETFD, (int)c)); + if (b == 3) + return result(fcntl((int)a, F_GETFL)); + if (b == 4) + return result(fcntl((int)a, F_SETFL, open_flags((int)c))); + return -22; + case __NR_unlinkat: + return result( + unlinkat(dirfd_in((int)a), G(const char *, b), (c & 0x200) ? AT_REMOVEDIR : 0)); + case __NR_renameat: + return result( + renameat(dirfd_in((int)a), G(const char *, b), dirfd_in((int)c), G(const char *, d))); + case __NR_mkdirat: + return result(mkdirat(dirfd_in((int)a), G(const char *, b), (mode_t)c)); + case __NR_fchmod: + return result(fchmod((int)a, (mode_t)b)); + case __NR_faccessat: + return result(faccessat(dirfd_in((int)a), G(const char *, b), (int)c, 0)); + case __NR_chdir: + return result(chdir(G(const char *, a))); + case __NR_getcwd: + return getcwd(G(char *, a), (uint32_t)b) ? (long long)strlen(G(char *, a)) + 1 + : -host_linux_errno(errno); + case __NR_readlinkat: + return result(readlinkat(dirfd_in((int)a), G(const char *, b), G(char *, c), (uint32_t)d)); + case __NR_clock_gettime: + case __NR_clock_getres: { + struct timespec t; + int r = number == __NR_clock_gettime ? clock_gettime(clock_id((int)a), &t) + : clock_getres(clock_id((int)a), &t); + if (!r) + time_out(b, t); + return result(r); + } + case __NR_gettimeofday: { + struct timeval t; + gettimeofday(&t, NULL); + if (a) + *G(struct guest_time *, a) = (struct guest_time){(int32_t)t.tv_sec, t.tv_usec}; + return 0; + } + case __NR_nanosleep: { + struct timespec t = time_in(a), remaining = {0}; + int r = nanosleep(&t, &remaining); + if (r) + time_out(b, remaining); + return result(r); + } + case __NR_clock_nanosleep: { + struct timespec t = time_in(c), remaining = {0}; + if (b) { + struct timespec now; + clock_gettime(clock_id((int)a), &now); + t.tv_sec -= now.tv_sec; + t.tv_nsec -= now.tv_nsec; + if (t.tv_nsec < 0) { + t.tv_sec--; + t.tv_nsec += 1000000000; + } + if (t.tv_sec < 0) + return 0; + } + int r = nanosleep(&t, &remaining); + if (r) + time_out(d, remaining); + return result(r); + } + case __NR_futex: + return futex((uint32_t)a, (int)b, (uint32_t)c, d); + case __NR_mmap: + return host_guest_mmap((uint32_t)a, (uint32_t)b, (int)c, (int)d, (int)e, f); + case __NR_munmap: + return host_guest_munmap((uint32_t)a, (uint32_t)b); + case __NR_mprotect: + return host_guest_mprotect((uint32_t)a, (uint32_t)b, (int)c); + case __NR_madvise: + return 0; + case __NR_mremap: + case __NR_brk: + return -12; + case __NR_ioctl: + return -25; + case __NR_sched_yield: + sched_yield(); + return 0; + case __NR_gettid: + case __NR_set_tid_address: { + uint64_t tid; + pthread_threadid_np(NULL, &tid); + return (uint32_t)tid; + } + case __NR_getpid: + return getpid(); + case __NR_getppid: + return getppid(); + case __NR_getuid: + return getuid(); + case __NR_geteuid: + return geteuid(); + case __NR_getgid: + return getgid(); + case __NR_getegid: + return getegid(); + case __NR_getrandom: + arc4random_buf(G(void *, a), (uint32_t)b); + return (uint32_t)b; + case __NR_sysinfo: { + struct guest_sysinfo *info = G(struct guest_sysinfo *, a); + memset(info, 0, sizeof(*info)); + uint64_t bytes = 0; + size_t size = sizeof(bytes); + if (sysctlbyname("hw.memsize", &bytes, &size, NULL, 0)) + return -host_linux_errno(errno); + info->mem_unit = HALO_MACOS_PAGE; + info->totalram = (uint32_t)(bytes / HALO_MACOS_PAGE); + vm_statistics64_data_t vm; + mach_msg_type_number_t count = HOST_VM_INFO64_COUNT; + if (host_statistics64(mach_host_self(), HOST_VM_INFO64, (host_info64_t)&vm, &count) == + KERN_SUCCESS) + info->freeram = vm.free_count + vm.inactive_count; + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + info->uptime = (uint32_t)now.tv_sec; + return 0; + } + case __NR_rt_sigaction: + case __NR_rt_sigprocmask: + case __NR_sigaltstack: + case __NR_membarrier: + return 0; + case __NR_ppoll: { + int ms = -1; + if (c) { + struct timespec t = time_in(c); + ms = (int)(t.tv_sec * 1000 + (t.tv_nsec + 999999) / 1000000); + } + return result(poll(G(struct pollfd *, a), (uint32_t)b, ms)); + } + case __NR_flock: + return result(flock((int)a, (int)b)); + case __NR_umask: + return umask((mode_t)a); + case __NR_exit: + case __NR_exit_group: + host_exit((int)a); + default: + host_logf(HOST_LOG_WARN, "unimplemented guest syscall %lld", number); + return -38; + } +} diff --git a/port/macos/host/host_thread.c b/port/macos/host/host_thread.c new file mode 100644 index 0000000000..a406744900 --- /dev/null +++ b/port/macos/host/host_thread.c @@ -0,0 +1,2 @@ +/* Share the existing host implementation; HALO_MACOS selects Darwin adapters. */ +#include "../../android/host/host_thread.c" diff --git a/port/macos/host/posix_files.c b/port/macos/host/posix_files.c new file mode 100644 index 0000000000..d81df67fc8 --- /dev/null +++ b/port/macos/host/posix_files.c @@ -0,0 +1,2 @@ +/* Share the existing host implementation; HALO_MACOS selects Darwin adapters. */ +#include "../../linux/src/posix_files.c" diff --git a/port/macos/host/posix_net.c b/port/macos/host/posix_net.c new file mode 100644 index 0000000000..64026b9b5e --- /dev/null +++ b/port/macos/host/posix_net.c @@ -0,0 +1,126 @@ +/* The shared Winsock adapter with Darwin sockaddr layout translation. */ +#ifndef HALO_MACOS +#define HALO_MACOS 1 +#endif +#include +#include +#include +#include +#include +#include +#include +static struct sockaddr_storage input_address(const struct sockaddr *source, socklen_t n) { + struct sockaddr_storage result = {0}; + if (n > sizeof(result)) + n = sizeof(result); + memcpy(&result, source, n); + uint16_t family; + memcpy(&family, source, 2); + result.ss_len = (uint8_t)n; + result.ss_family = (uint8_t)family; + return result; +} +static void output_address(struct sockaddr *address) { + uint16_t family = address->sa_family; + memcpy(address, &family, 2); +} +static int darwin_bind(int fd, const struct sockaddr *a, socklen_t n) { + struct sockaddr_storage s = input_address(a, n); + return bind(fd, (void *)&s, n); +} +static int darwin_connect(int fd, const struct sockaddr *a, socklen_t n) { + struct sockaddr_storage s = input_address(a, n); + return connect(fd, (void *)&s, n); +} +static ssize_t darwin_sendto(int fd, const void *p, size_t n, int f, const struct sockaddr *a, + socklen_t an) { + /* A connected socket can omit its destination. Preserve NULL: a + non-NULL zero-length sockaddr makes Darwin reject the send. */ + if (!a) + return sendto(fd, p, n, f, NULL, an); + struct sockaddr_storage s = input_address(a, an); + ssize_t r = sendto(fd, p, n, f, (void *)&s, an); + if (r < 0 && errno == EISCONN) { + /* Halo supplies a destination even after connecting its gameplay UDP + socket. Darwin rejects that Winsock/Linux pattern. Use send() only + when the explicit destination is the connected datagram peer. */ + struct sockaddr_storage peer; + socklen_t peer_length = sizeof(peer), type_length = sizeof(int); + int type; + if (s.ss_family == AF_INET && an >= sizeof(struct sockaddr_in) && + getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &type_length) == 0 && + type == SOCK_DGRAM && getpeername(fd, (void *)&peer, &peer_length) == 0 && + peer.ss_family == AF_INET && peer_length >= sizeof(struct sockaddr_in)) { + const struct sockaddr_in *destination = (const void *)&s; + const struct sockaddr_in *connected = (const void *)&peer; + if (destination->sin_port == connected->sin_port && + destination->sin_addr.s_addr == connected->sin_addr.s_addr) + return send(fd, p, n, f); + } + errno = EISCONN; + } + return r; +} +static ssize_t darwin_recvmsg(int fd, struct msghdr *message, int flags) { + ssize_t r = recvmsg(fd, message, flags); + /* Upstream uses recvmsg to detect truncated UDP packets. Its sender + address still needs the game's two-byte family instead of sa_len. */ + if (r >= 0 && message->msg_name && message->msg_namelen >= sizeof(uint16_t)) + output_address(message->msg_name); + return r; +} +static int darwin_accept(int fd, struct sockaddr *a, socklen_t *n) { + int r = accept(fd, a, n); + if (r >= 0 && a) + output_address(a); + return r; +} +static int darwin_getsockname(int fd, struct sockaddr *a, socklen_t *n) { + int r = getsockname(fd, a, n); + if (!r && a) + output_address(a); + return r; +} +static int darwin_getpeername(int fd, struct sockaddr *a, socklen_t *n) { + int r = getpeername(fd, a, n); + if (!r && a) + output_address(a); + return r; +} +static ssize_t darwin_getrandom(void *p, size_t n, unsigned f) { + (void)f; + arc4random_buf(p, n); + return (ssize_t)n; +} +static int darwin_socket(int family, int type, int protocol) { + int fd = socket(family, type & ~0x80000, protocol); + if (fd >= 0) { + fcntl(fd, F_SETFD, FD_CLOEXEC); + /* PR #22: Darwin uses SO_NOSIGPIPE in place of MSG_NOSIGNAL. */ + int enabled = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)); + } + return fd; +} +static int darwin_accept4(int fd, struct sockaddr *a, socklen_t *n, int flags) { + (void)flags; + int r = darwin_accept(fd, a, n); + if (r >= 0) { + fcntl(r, F_SETFD, FD_CLOEXEC); + int enabled = 1; + setsockopt(r, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)); + } + return r; +} +#define SOCK_CLOEXEC 0x80000 +#define socket darwin_socket +#define accept4 darwin_accept4 +#define bind darwin_bind +#define connect darwin_connect +#define sendto darwin_sendto +#define recvmsg darwin_recvmsg +#define accept darwin_accept +#define getsockname darwin_getsockname +#define getpeername darwin_getpeername +#define getrandom darwin_getrandom +#include "port/linux/src/posix_net.c" diff --git a/port/macos/host/posix_upnp.c b/port/macos/host/posix_upnp.c new file mode 100644 index 0000000000..056575afca --- /dev/null +++ b/port/macos/host/posix_upnp.c @@ -0,0 +1,11 @@ +/* UPnP uses the host's BSD sockets directly, with no guest structures. */ +#include +#include "posix.h" + +/* miniupnpc's port probe uses a native Darwin sockaddr; the game's + Winsock adapter expects the guest layout instead. */ +static int upnp_bind_native(int socket, const void *address, int length) { + return bind(socket, (const struct sockaddr *)address, (socklen_t)length); +} +#define posix_socket_bind upnp_bind_native +#include "port/linux/src/posix_upnp.c" diff --git a/port/macos/host_imports.list b/port/macos/host_imports.list new file mode 100644 index 0000000000..804b3152fe --- /dev/null +++ b/port/macos/host_imports.list @@ -0,0 +1,4 @@ +# Mac desktop display controls; the Android import table remains unchanged. +host_sdl_display_mode +host_sdl_apply_display +host_sdl_request_quit diff --git a/port/macos/licenses/ANGLE.txt b/port/macos/licenses/ANGLE.txt new file mode 100644 index 0000000000..0f65fd60fd --- /dev/null +++ b/port/macos/licenses/ANGLE.txt @@ -0,0 +1,32 @@ +// Copyright 2018 The ANGLE Project Authors. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// +// Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// Neither the name of TransGaming Inc., Google Inc., 3DLabs Inc. +// Ltd., nor the names of their contributors may be used to endorse +// or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +// COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +// BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +// LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +// ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. diff --git a/port/macos/native/HaloPreferences.h b/port/macos/native/HaloPreferences.h new file mode 100644 index 0000000000..501b7b539d --- /dev/null +++ b/port/macos/native/HaloPreferences.h @@ -0,0 +1,19 @@ +#import + +/* User data stays outside the signed app and survives an application update. */ +@interface HaloPreferences : NSObject +@property(nonatomic, readonly) NSURL *supportDirectory; +@property(nonatomic, readonly) NSString *dataPath; +@property(nonatomic, readonly) NSString *isoPath; +@property(nonatomic, readonly) BOOL chooseDataOnLaunch; +- (instancetype)initWithSupportDirectory:(NSURL *)directory; +- (BOOL)selectDataRoot:(NSURL *)root iso:(NSURL *)iso error:(NSError **)error; +- (BOOL)setChooseDataOnLaunch:(BOOL)choose error:(NSError **)error; +@end + +/* Accept the extracted game directory or its maps subdirectory. Header checks + establish the supported format, not a claim of complete gameplay parity. */ +NSURL *HaloValidateGameData(NSURL *selection, NSError **error); +NSURL *HaloImportDiscImage(NSURL *image, NSURL *supportDirectory, + void (*progress)(void *, const char *, unsigned long long, unsigned long long), + void *context, NSError **error); diff --git a/port/macos/native/HaloPreferences.m b/port/macos/native/HaloPreferences.m new file mode 100644 index 0000000000..dbb432db5a --- /dev/null +++ b/port/macos/native/HaloPreferences.m @@ -0,0 +1,152 @@ +#import "HaloPreferences.h" +#include "xiso.h" +#include + +static NSError *failure(NSString *message) { + return [NSError errorWithDomain:@"HaloGameData" code:1 + userInfo:@{NSLocalizedDescriptionKey: message}]; +} + +@implementation HaloPreferences { + NSMutableDictionary *_settings; +} +- (instancetype)initWithSupportDirectory:(NSURL *)directory { + if ((self = [super init])) { + _supportDirectory = directory; + NSData *data = [NSData dataWithContentsOfURL:[directory URLByAppendingPathComponent:@"macos-settings.json"]]; + id decoded = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; + _settings = [decoded isKindOfClass:NSDictionary.class] ? [decoded mutableCopy] : [NSMutableDictionary dictionary]; + } + return self; +} +- (NSString *)dataPath { return [_settings[@"data_path"] isKindOfClass:NSString.class] ? _settings[@"data_path"] : nil; } +- (NSString *)isoPath { return [_settings[@"iso_path"] isKindOfClass:NSString.class] ? _settings[@"iso_path"] : nil; } +- (BOOL)chooseDataOnLaunch { return [_settings[@"choose_data_on_launch"] isKindOfClass:NSNumber.class] && [_settings[@"choose_data_on_launch"] boolValue]; } +- (BOOL)save:(NSMutableDictionary *)settings error:(NSError **)error { + NSData *data = [NSJSONSerialization dataWithJSONObject:settings options:NSJSONWritingPrettyPrinted error:error]; + if (!data || ![NSFileManager.defaultManager createDirectoryAtURL:_supportDirectory + withIntermediateDirectories:YES attributes:nil error:error]) return NO; + if (![data writeToURL:[_supportDirectory URLByAppendingPathComponent:@"macos-settings.json"] + options:NSDataWritingAtomic error:error]) return NO; + _settings = settings; + return YES; +} +- (BOOL)selectDataRoot:(NSURL *)root iso:(NSURL *)iso error:(NSError **)error { + NSURL *validated = HaloValidateGameData(root, error); + if (!validated) return NO; + NSMutableDictionary *settings = [_settings mutableCopy]; + settings[@"data_path"] = validated.path; + [settings removeObjectForKey:@"choose_data_on_launch"]; + if (iso) settings[@"iso_path"] = iso.path; + else [settings removeObjectForKey:@"iso_path"]; + return [self save:settings error:error]; +} +- (BOOL)setChooseDataOnLaunch:(BOOL)choose error:(NSError **)error { + NSMutableDictionary *settings = [_settings mutableCopy]; + settings[@"choose_data_on_launch"] = @(choose); + return [self save:settings error:error]; +} +@end + +static NSDictionary *entries(NSURL *directory, NSError **error) { + NSArray *files = [NSFileManager.defaultManager contentsOfDirectoryAtURL:directory + includingPropertiesForKeys:@[NSURLIsRegularFileKey] options:0 error:error]; + if (!files) return nil; + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + for (NSURL *file in files) { + NSString *name = file.lastPathComponent.lowercaseString; + if (result[name]) { + if (error) *error = failure(@"This folder contains duplicate names with different capitalization."); + return nil; + } + result[name] = file; + } + return result; +} + +NSURL *HaloValidateGameData(NSURL *selection, NSError **error) { + NSURL *root = selection.URLByStandardizingPath.URLByResolvingSymlinksInPath; + NSURL *maps; + if ([root.lastPathComponent.lowercaseString isEqualToString:@"maps"]) { + maps = root; + root = root.URLByDeletingLastPathComponent; + } else { + maps = entries(root, error)[@"maps"]; + } + if (!maps) { + if (error) *error = failure(@"Choose an extracted Xbox Halo game folder containing maps, or the maps folder itself."); + return nil; + } + NSDictionary *files = entries(maps, error); + if (!files) return nil; + /* Establish the Xbox base data here. Extra Custom Edition/OpenSauce maps + and resource files belong to the engine's existing format validator; + they must neither reject this folder nor stand in for a retail map. */ + NSSet *retailNames = [NSSet setWithArray:@[ + @"ui", @"a10", @"a30", @"a50", @"b30", @"b40", @"c10", @"c20", @"c40", @"d20", @"d40", + @"beavercreek", @"sidewinder", @"damnation", @"ratrace", @"prisoner", @"hangemhigh", @"chillout", + @"carousel", @"boardingaction", @"bloodgulch", @"wizard", @"putput", @"longest" + ]]; + NSString *discBuild = nil; + BOOL hasUI = NO, hasOpeningLevel = NO; + for (NSString *name in files) { + if (![name.pathExtension isEqualToString:@"map"] || + ![retailNames containsObject:name.stringByDeletingPathExtension]) continue; + NSURL *file = files[name]; + NSFileHandle *handle = [NSFileHandle fileHandleForReadingFromURL:file error:error]; + if (!handle) return nil; + NSData *header = [handle readDataUpToLength:2048 error:error]; + [handle closeFile]; + if (!header) return nil; + const unsigned char *bytes = header.bytes; + BOOL valid = header.length == 2048 && !memcmp(bytes, "daeh", 4) && !memcmp(bytes + 2044, "toof", 4); + uint32_t version = 0, length = 0; + NSString *cacheName = nil, *build = nil; + if (valid) { + memcpy(&version, bytes + 4, 4); + memcpy(&length, bytes + 8, 4); + const unsigned char *nameEnd = memchr(bytes + 32, 0, 32), *buildEnd = memchr(bytes + 64, 0, 32); + if (nameEnd && buildEnd) { + cacheName = [[NSString alloc] initWithBytes:bytes + 32 length:nameEnd - bytes - 32 encoding:NSASCIIStringEncoding]; + build = [[NSString alloc] initWithBytes:bytes + 64 length:buildEnd - bytes - 64 encoding:NSASCIIStringEncoding]; + } + valid = version == 5 && length >= 2048 && length <= 0x11600000 && + [cacheName.lowercaseString isEqualToString:name.stringByDeletingPathExtension] && + ([@"01.01.14.2342" isEqualToString:build] || [@"01.10.12.2276" isEqualToString:build]); + } + if (!valid) { + if (error) *error = failure([NSString stringWithFormat:@"%@ must be a supported original Xbox Halo map. A PC, Custom Edition, Anniversary or MCC map cannot replace this base-game file.", file.lastPathComponent]); + return nil; + } + if (discBuild && ![discBuild isEqualToString:build]) { + if (error) *error = failure(@"The maps mix different Xbox releases. Choose one complete set from one disc."); + return nil; + } + discBuild = build; + hasUI |= [name isEqualToString:@"ui.map"]; + hasOpeningLevel |= [name isEqualToString:@"a10.map"]; + } + if (!hasUI || !hasOpeningLevel) { + if (error) *error = failure(@"This folder needs ui.map and a10.map from the same Xbox Halo disc. Use a complete maps folder."); + return nil; + } + return root; +} + +NSURL *HaloImportDiscImage(NSURL *image, NSURL *supportDirectory, + xiso_progress_proc progress, void *context, NSError **error) { + /* A new directory for every import. Failed imports never replace working data. */ + NSURL *destination = [[supportDirectory URLByAppendingPathComponent:@"Game Data" isDirectory:YES] + URLByAppendingPathComponent:NSUUID.UUID.UUIDString isDirectory:YES]; + if (![NSFileManager.defaultManager createDirectoryAtURL:destination withIntermediateDirectories:YES + attributes:nil error:error]) return nil; + char detail[512] = {0}; + BOOL extracted = xiso_extract_maps(image.fileSystemRepresentation, destination.fileSystemRepresentation, + progress, context, detail, sizeof(detail)); + NSURL *validated = extracted ? HaloValidateGameData(destination, error) : nil; + if (!validated) { + [NSFileManager.defaultManager removeItemAtURL:destination error:nil]; + if (!extracted && error) *error = failure([NSString stringWithUTF8String:detail] ?: @"The disc image could not be read."); + } + return validated; +} diff --git a/port/macos/native/host_menu.h b/port/macos/native/host_menu.h new file mode 100644 index 0000000000..c520227294 --- /dev/null +++ b/port/macos/native/host_menu.h @@ -0,0 +1,14 @@ +#ifndef HALO_MAC_MENU_H +#define HALO_MAC_MENU_H +#include +void host_menu_initialize_application(void); +int host_menu_prepare(const char *support, const char *fallback, char *data, size_t capacity); +void host_menu_begin_game(void); +void host_menu_finish_game(int exit_code); +/* SDL operations stay on its real main thread. */ +int host_sdl_is_fullscreen(void); +int host_sdl_display_mode(void); +int host_sdl_apply_display(int mode, int width, int height); +void host_sdl_release_mouse(void); +void host_sdl_request_quit(void); +#endif diff --git a/port/macos/native/host_menu.m b/port/macos/native/host_menu.m new file mode 100644 index 0000000000..6ed06df27f --- /dev/null +++ b/port/macos/native/host_menu.m @@ -0,0 +1,160 @@ +#import +#import "HaloPreferences.h" +#include "host_menu.h" +#include + +@interface HaloMenu : NSObject +@property(nonatomic, strong) HaloPreferences *preferences; +@property(nonatomic, strong) id previousDelegate; +@property(nonatomic) BOOL gameRunning; +@property(nonatomic) BOOL importing; +- (BOOL)chooseData:(BOOL)disc; +@end + +static HaloMenu *menu; + +static void showError(NSError *error) { + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = @"Halo could not use that setting"; + alert.informativeText = error.localizedDescription ?: @"Please try again."; + if (menu.gameRunning && NSApp.keyWindow) + [alert beginSheetModalForWindow:NSApp.keyWindow completionHandler:nil]; + else [alert runModal]; +} + +static void addItem(NSMenu *parent, NSString *title, SEL action, NSString *key, id target) { + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:action keyEquivalent:key]; + item.target = target; + [parent addItem:item]; +} + +@implementation HaloMenu +- (BOOL)respondsToSelector:(SEL)selector { + return [super respondsToSelector:selector] || [self.previousDelegate respondsToSelector:selector]; +} +- (id)forwardingTargetForSelector:(SEL)selector { + return [self.previousDelegate respondsToSelector:selector] ? self.previousDelegate : [super forwardingTargetForSelector:selector]; +} +- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender { + (void)sender; + if (self.importing) return NSTerminateCancel; + if (self.gameRunning) { host_sdl_request_quit(); return NSTerminateCancel; } + return NSTerminateNow; +} +- (void)menuWillOpen:(NSMenu *)sender { (void)sender; host_sdl_release_mouse(); } +- (void)menuNeedsUpdate:(NSMenu *)sender { + for (NSMenuItem *item in sender.itemArray) { + if (item.action == @selector(changeData:)) + item.state = self.preferences.chooseDataOnLaunch ? NSControlStateValueOn : NSControlStateValueOff; + item.enabled = !self.importing; + } +} +- (void)about:(id)sender { (void)sender; [NSApp orderFrontStandardAboutPanel:self]; } +- (void)openSaves:(id)sender { + (void)sender; + [NSWorkspace.sharedWorkspace openURL:self.preferences.supportDirectory]; +} +- (void)changeData:(id)sender { + (void)sender; + NSError *error = nil; + if (![self.preferences setChooseDataOnLaunch:!self.preferences.chooseDataOnLaunch error:&error]) showError(error); +} +- (void)buildMenus { + NSMenu *main = [[NSMenu alloc] initWithTitle:@"Main"]; + NSMenuItem *app = [[NSMenuItem alloc] initWithTitle:@"Halo" action:nil keyEquivalent:@""]; + NSMenu *actions = [[NSMenu alloc] initWithTitle:@"Halo"]; + actions.autoenablesItems = NO; + actions.delegate = self; + addItem(actions, @"About Halo CE Universal", @selector(about:), @"", self); + [actions addItem:NSMenuItem.separatorItem]; + addItem(actions, @"Choose Game Data on Next Launch", @selector(changeData:), @"", self); + addItem(actions, @"Open Saves Folder", @selector(openSaves:), @"", self); + [actions addItem:NSMenuItem.separatorItem]; + addItem(actions, @"Quit Halo", @selector(terminate:), @"q", NSApp); + app.submenu = actions; + [main addItem:app]; + NSApp.mainMenu = main; +} +- (BOOL)chooseData:(BOOL)disc { + NSOpenPanel *panel = NSOpenPanel.openPanel; + panel.title = disc ? @"Choose Your Xbox Halo Disc Image" : @"Choose Your Xbox Halo Maps"; + panel.message = disc ? @"Import the maps from your own original Xbox Halo disc image." + : @"Choose an extracted game folder or its maps folder."; + panel.canChooseDirectories = !disc; + panel.canChooseFiles = disc; + panel.allowsMultipleSelection = NO; + if ([panel runModal] != NSModalResponseOK) return NO; + __block NSError *error = nil; + __block NSURL *root = panel.URL; + if (disc) { + NSWindow *progress = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 400, 100) + styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO]; + progress.title = @"Importing Halo Maps"; + NSTextField *label = [NSTextField labelWithString:@"Importing maps; your existing data stays in place."]; + label.frame = NSMakeRect(20, 58, 360, 20); + [progress.contentView addSubview:label]; + NSProgressIndicator *bar = [[NSProgressIndicator alloc] initWithFrame:NSMakeRect(20, 26, 360, 16)]; + bar.indeterminate = YES; + [progress.contentView addSubview:bar]; + [bar startAnimation:self]; + [progress center]; + [progress makeKeyAndOrderFront:self]; + self.importing = YES; + __block BOOL finished = NO; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSError *importError = nil; + NSURL *imported = HaloImportDiscImage(panel.URL, self.preferences.supportDirectory, NULL, NULL, &importError); + dispatch_async(dispatch_get_main_queue(), ^{ + root = imported; error = importError; finished = YES; + }); + }); + /* Import only before the game starts; keep Cocoa responsive while copying. */ + while (!finished) + [NSRunLoop.currentRunLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + self.importing = NO; + [progress orderOut:self]; + } + if (root && [self.preferences selectDataRoot:root iso:disc ? panel.URL : nil error:&error]) return YES; + if (disc && root) [NSFileManager.defaultManager removeItemAtURL:root error:nil]; + showError(error); + return NO; +} +@end + +void host_menu_initialize_application(void) { + /* Let Cocoa route Quit through the delegate so the guest can save first. */ + [NSApplication sharedApplication]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; +} + +int host_menu_prepare(const char *support, const char *fallback, char *data, size_t capacity) { + @autoreleasepool { + menu = [[HaloMenu alloc] init]; + menu.preferences = [[HaloPreferences alloc] initWithSupportDirectory:[NSURL fileURLWithPath:@(support) isDirectory:YES]]; + menu.previousDelegate = NSApp.delegate; + NSApp.delegate = menu; + [menu buildMenus]; + [NSApp finishLaunching]; + NSString *selected = menu.preferences.dataPath; + const char *override = getenv("HALO_DATA_ROOT"); + if (override && *override) selected = @(override); + NSURL *valid = selected ? HaloValidateGameData([NSURL fileURLWithPath:selected], nil) : nil; + if (!selected && fallback && *fallback) valid = HaloValidateGameData([NSURL fileURLWithPath:@(fallback)], nil); + if (menu.preferences.chooseDataOnLaunch && !(override && *override)) valid = nil; + while (!valid) { + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = @"Choose your Halo game data"; + alert.informativeText = @"Use your own original Xbox Halo disc image or extracted maps folder. The app does not include game data."; + [alert addButtonWithTitle:@"Choose Disc Image…"]; + [alert addButtonWithTitle:@"Choose Maps Folder…"]; + [alert addButtonWithTitle:@"Quit"]; + NSModalResponse answer = [alert runModal]; + if (answer == NSAlertThirdButtonReturn) return 0; + if ([menu chooseData:answer == NSAlertFirstButtonReturn]) + valid = [NSURL fileURLWithPath:menu.preferences.dataPath]; + } + return [valid.path getCString:data maxLength:capacity encoding:NSUTF8StringEncoding] ? 1 : 0; + } +} +void host_menu_begin_game(void) { menu.gameRunning = YES; } +void host_menu_finish_game(int exit_code) { (void)exit_code; menu.gameRunning = NO; } diff --git a/port/macos/native_events.h b/port/macos/native_events.h new file mode 100644 index 0000000000..cecffb192b --- /dev/null +++ b/port/macos/native_events.h @@ -0,0 +1,5 @@ +/* Private SDL events from Cocoa panels to the guest input loop. */ +#ifndef HALO_MACOS_NATIVE_EVENTS_H +#define HALO_MACOS_NATIVE_EVENTS_H +#define HALO_MACOS_MOUSE_RELEASE 0x48414c01 +#endif diff --git a/source/cseries/stack_walk_windows.c b/source/cseries/stack_walk_windows.c index 70c20987b1..34a880513a 100644 --- a/source/cseries/stack_walk_windows.c +++ b/source/cseries/stack_walk_windows.c @@ -318,6 +318,11 @@ void stack_walk_with_context( unsigned long levels_dumped; long frame_number; +#ifdef HALO_MACOS + /* The ILP32 chain crosses into native Darwin frames. The Xbox walker + cannot validate those 64-bit addresses; preserve the original error. */ + return; +#endif if (context_pointer) { initialize_stack_walk(context_pointer); diff --git a/tools/android_build.py b/tools/android_build.py index 581bff32b4..ce9d647ab5 100644 --- a/tools/android_build.py +++ b/tools/android_build.py @@ -200,28 +200,48 @@ def android_configure_inputs() -> List[Path]: def generate_android_build(n: Writer, sln: Any) -> None: + macos = getattr(sln, "macos", False) + build_root = Path("build/macos") if macos else BUILD config_path = LINUX_DIR / "port.json" if not config_path.is_file() or not (PORT_DIR / "host").is_dir(): return + guest_only = getattr(sln, "android_guest_only", False) ndk = Path(sln.android_ndk) if getattr(sln, "android_ndk", None) else _find_ndk() - if not ndk or not ndk.is_dir(): + if guest_only: + llvm_bin = getattr(sln, "android_guest_llvm_bin", None) + gl_headers = getattr(sln, "android_guest_gl_include", None) + if not llvm_bin or not gl_headers: + raise ValueError("--android-guest-only requires --android-guest-llvm-bin and --android-guest-gl-include") + ndk_bin = Path(llvm_bin).resolve() + sysroot_include = Path(gl_headers).resolve() + required = [ndk_bin / "llvm-ar", ndk_bin / "ld.lld", sysroot_include / "GLES3/gl32.h", + sysroot_include / "GLES2/gl2ext.h", sysroot_include / "KHR/khrplatform.h"] + for path in required: + if not path.is_file(): + raise ValueError(f"ARM guest build input is missing: {path}") + host_cc = None + elif not ndk or not ndk.is_dir(): n.comment("Android build: no NDK found (set ANDROID_NDK_HOME or pass --android-ndk)") return + else: + host_tag = "darwin-x86_64" if sys.platform == "darwin" else "linux-x86_64" + toolchain = ndk / "toolchains" / "llvm" / "prebuilt" / host_tag + sysroot_include = toolchain / "sysroot" / "usr" / "include" + ndk_bin = toolchain / "bin" + host_cc = ndk_bin / f"aarch64-linux-android{ANDROID_API}-clang" try: fetch_third_party() except (subprocess.CalledProcessError, OSError) as error: + if guest_only: + raise print(f"Android build disabled: cannot fetch musl/SDL3 ({error})", file=sys.stderr) return import json config: Dict[str, Any] = json.loads(config_path.read_text(encoding="utf-8")) - toolchain = ndk / "toolchains" / "llvm" / "prebuilt" / "linux-x86_64" - sysroot_include = toolchain / "sysroot" / "usr" / "include" - host_cc = toolchain / "bin" / f"aarch64-linux-android{ANDROID_API}-clang" - ndk_bin = toolchain / "bin" guest_cc = getattr(sln, "android_guest_cc", None) or "clang" - guest_dir = BUILD / "guest" + guest_dir = build_root / "guest" obj_dir = guest_dir / "obj" gen_dir = guest_dir / "gen" libc_include = guest_dir / "libc_include" @@ -231,17 +251,18 @@ def generate_android_build(n: Writer, sln: Any) -> None: semantics_header = Path("build/linux/halo_msvc_semantics.h") platform_semantics_header = Path("build/linux/platform_msvc_semantics.h") prefix_header = LINUX_DIR / "include" / "halo_linux_prefix.h" - image = BUILD / "halo_guest.elf" - sdl_build = BUILD / "sdl3-build" + image = build_root / "halo_guest.elf" + sdl_build = build_root / "sdl3-build" libsdl = sdl_build / "libSDL3.so" - jni_dir = BUILD / "jniLibs" / "arm64-v8a" + jni_dir = build_root / "jniLibs" / "arm64-v8a" libmain = jni_dir / "libmain.so" - assets_dir = BUILD / "assets" + assets_dir = build_root / "assets" python = "$python" n.comment("Android build (ninja android); see port/android/README.md") n.variable("android_guest_cc", guest_cc) - n.variable("android_host_cc", str(host_cc)) + if host_cc: + n.variable("android_host_cc", str(host_cc)) n.variable("android_ndk_bin", str(ndk_bin)) # ---------- generated headers and sources @@ -302,15 +323,18 @@ def generate_android_build(n: Writer, sln: Any) -> None: implicit=[Path("tools/android_posix_stubs.py"), LINUX_DIR / "src" / "posix.h"]) imports_s = gen_dir / "imports.s" - host_table_c = BUILD / "host" / "host_import_table.c" + host_table_c = build_root / "host" / "host_import_table.c" host_imports_list = PORT_DIR / "host_imports.list" + import_lists = [host_imports_list, posix_imports, gl_imports] + if macos: + import_lists.append(Path("port/macos/host_imports.list")) n.rule( name="android_imports", command=f"{python} tools/android_imports.py --host-table {host_table_c} {imports_s} $in", description="ANDROID IMPORTS", ) n.build(outputs=[imports_s, host_table_c], rule="android_imports", - inputs=[host_imports_list, posix_imports, gl_imports], + inputs=import_lists, implicit=[Path("tools/android_imports.py")]) generated_headers = [*xdk_headers(), alltypes, syscall_h, version_h, gl_stamp, @@ -340,6 +364,14 @@ def generate_android_build(n: Writer, sln: Any) -> None: guest_abi = " ".join(GUEST_ABI_FLAGS + (["-DHALO_RELEASE"] if getattr(sln, "port_release", False) else [])) guest_code = " ".join(GUEST_CODE_FLAGS) tool_implicit = [Path("tools/android_asm_convert.py"), *generated_headers] + if macos: + n.rule(name="macos_rebase_plugin", command=f"{python} tools/macos_build.py --plugin-only", + description="MACOS COMPILER PASS") + plugin = build_root / "guest_rebase.dylib" + n.build(outputs=plugin, rule="macos_rebase_plugin", + inputs=[Path("port/macos/compiler/guest_rebase.cpp")], + implicit=[Path("tools/macos_build.py")]) + tool_implicit.extend([Path("tools/macos_guest_cc.py"), plugin]) # profile-guided optimisation with the Linux build's profile (committed, # or trained by the Linux build with --pgo=train): the game and platform # code are the same, and functions that differ simply go without @@ -350,8 +382,8 @@ def generate_android_build(n: Writer, sln: Any) -> None: def guest_object(source: Path, cflags: str, prefix: str = "") -> Path: obj = obj_dir / prefix / Path(str(source).lstrip("/")).with_suffix(".o") - if str(source).startswith(str(BUILD)): - obj = obj_dir / prefix / source.relative_to(BUILD).with_suffix(".o") + if str(source).startswith(str(build_root)): + obj = obj_dir / prefix / source.relative_to(build_root).with_suffix(".o") n.build(outputs=obj, rule="android_guest_cc", inputs=source, implicit=tool_implicit, variables={"cflags": cflags}) return obj @@ -458,16 +490,23 @@ def guest_object(source: Path, cflags: str, prefix: str = "") -> Path: # ---------- the guest image linker_script = PORT_DIR / "guest" / "guest.ld" + builtins = getattr(sln, "android_guest_builtins", None) if guest_only else None + compiler_runtime = (_quote(builtins) if builtins else "") if guest_only else "$$($android_host_cc -print-libgcc-file-name)" n.rule( name="android_guest_link", command=(f"$android_ndk_bin/ld.lld -m aarch64linux -static -nostdlib -T {linker_script} " f"-Map $out.map -o $out @$out.rsp {libguestc} " - "$$($android_host_cc -print-libgcc-file-name)"), + f"{compiler_runtime}"), description="ANDROID LINK $out", rspfile="$out.rsp", rspfile_content="$in_newline", ) - n.build(outputs=image, rule="android_guest_link", inputs=objects, implicit=[libguestc, linker_script]) + n.build(outputs=image, rule="android_guest_link", inputs=objects, + implicit=[libguestc, linker_script] + ([builtins] if builtins else [])) + n.build(outputs="macos_guest" if macos else "android_guest", rule="phony", inputs=image) + if guest_only: + n.newline() + return # ---------- SDL3 @@ -479,7 +518,7 @@ def guest_object(source: Path, cflags: str, prefix: str = "") -> Path: f"-DSDL_SHARED=ON -DSDL_STATIC=OFF -DSDL_TEST_LIBRARY=OFF -DSDL_TESTS=OFF -DSDL_EXAMPLES=OFF " # 16 KB pages (Android 15 and later), as the host library f"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384 " - f"> {BUILD}/sdl3-configure.log && ninja -C {sdl_build} > {BUILD}/sdl3-build.log"), + f"> {build_root}/sdl3-configure.log && ninja -C {sdl_build} > {build_root}/sdl3-build.log"), description="ANDROID SDL3", pool="console", ) @@ -488,7 +527,7 @@ def guest_object(source: Path, cflags: str, prefix: str = "") -> Path: # ---------- the host library host_objects: List[Path] = [] - host_obj_dir = BUILD / "host" / "obj" + host_obj_dir = build_root / "host" / "obj" n.rule( name="android_host_cc", command="$android_host_cc -MMD -MF $out.d $cflags -c $in -o $out", diff --git a/tools/android_gl_stubs.py b/tools/android_gl_stubs.py index c28a0f0bc1..2a94d3cc1b 100644 --- a/tools/android_gl_stubs.py +++ b/tools/android_gl_stubs.py @@ -63,6 +63,12 @@ def main(): "#include ", "#include ", "#include ", + "/* Integer-valued pointers bypass the Mac guest's typed-pointer rebase pass. */", + "#ifdef HALO_MACOS", + "#define HOST_POINTER(p) ((p) ? (0x10000000000ull | (unsigned int)(p)) : 0ull)", + "#else", + "#define HOST_POINTER(p) ((unsigned long long)(unsigned int)(p))", + "#endif", "", "void host_gl_get_string(unsigned int name, int index, char *buffer, unsigned int size);", "", @@ -107,7 +113,7 @@ def main(): "\tif (count > 16)", "\t\tcount = 16;", "\tfor (index = 0; index < count; index++)", - "\t\twide[index] = (unsigned long long)(unsigned int)strings[index];", + "\t\twide[index] = HOST_POINTER(strings[index]);", "\thostgl_glShaderSource(shader, count, wide, lengths);", "}", "", @@ -135,7 +141,7 @@ def main(): if is_pointer: if on_stack: host_params.append("unsigned long long") - call_args.append(f"(unsigned long long)(unsigned int){arg}") + call_args.append(f"HOST_POINTER({arg})") else: host_params.append(ptype) call_args.append(arg) diff --git a/tools/macos_build.py b/tools/macos_build.py new file mode 100644 index 0000000000..cbd4c2201e --- /dev/null +++ b/tools/macos_build.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Build the native Apple Silicon host, rebased game, and local .app bundle.""" +import argparse +from datetime import datetime, timezone +import hashlib +import os +from pathlib import Path +import plistlib +import re +import shlex +import shutil +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from tools.linux_build import MINIUPNPC_DEFINES, MINIUPNPC_DIR, miniupnpc_sources +from tools.macos_setup import prepare_angle_distribution +BUILD = ROOT / "build/macos" +LLVM = Path(os.environ.get("HALO_MACOS_LLVM_BIN", "/opt/homebrew/opt/llvm@22/bin")) +SDL = Path(os.environ.get("HALO_MACOS_SDL_PREFIX", "/opt/homebrew/opt/sdl3")) +ANGLE = Path(os.environ.get("HALO_MACOS_ANGLE_DIR", str(BUILD / "angle/dist"))) +GL = BUILD / "toolchain/gl" +APP_VERSION = "0.1.0" +APP_BUILD = "1" + + +def run(*args): + subprocess.run([str(arg) for arg in args], cwd=ROOT, check=True) + + +def require(path): + if not path.exists(): + raise RuntimeError(f"Missing build dependency: {path}. See port/macos/README.md.") + return path + + +def build_plugin(): + BUILD.mkdir(parents=True, exist_ok=True) + require(LLVM / "llvm-config") + flags = shlex.split(subprocess.check_output( + [LLVM / "llvm-config", "--cxxflags", "--ldflags", "--libs", "core", "passes"], text=True)) + run(LLVM / "clang++", "-shared", "-fPIC", "port/macos/compiler/guest_rebase.cpp", + "-o", BUILD / "guest_rebase.dylib", *flags) + + +def build_host(): + prepare_angle_distribution(ANGLE) + obj_dir = BUILD / "host-obj" + obj_dir.mkdir(parents=True, exist_ok=True) + frameworks = [ANGLE / f"{name}.xcframework/macos-arm64" for name in ("EGL", "GLESv2")] + for name, directory in zip(("libEGL", "libGLESv2"), frameworks): + require(directory / f"{name}.framework" / name) + flags = ["-arch", "arm64", "-mmacosx-version-min=14.0", "-O2", "-g", "-DHALO_MACOS=1", "-D_DARWIN_C_SOURCE", + "-Wall", "-Wextra", "-Wno-unused-function", "-Wno-unused-parameter", + "-I.", "-Iport/macos/host", "-Iport/macos/native", "-Iport/android/include", "-Iport/linux/src", + f"-I{SDL / 'include'}", f"-I{GL}", + f"-I{MINIUPNPC_DIR / 'include'}", f"-I{MINIUPNPC_DIR / 'src'}", *MINIUPNPC_DEFINES] + sources = sorted((ROOT / "port/macos/host").glob("*.c")) + sources += sorted((ROOT / "port/macos/native").glob("*.m")) + sources += [ROOT / "port/linux/src/xiso.c"] + sources += miniupnpc_sources() + sources += [BUILD / "host/host_import_table.c", ROOT / "port/macos/host/entry.s"] + objects = [] + for source in sources: + obj = obj_dir / (source.name + ".o") + native_flags = ["-fobjc-arc", "-fblocks"] if source.suffix == ".m" else [] + run("clang", *flags, *native_flags, "-c", source, "-o", obj) + objects.append(obj) + run("clang", "-arch", "arm64", "-mmacosx-version-min=14.0", *objects, f"-L{SDL / 'lib'}", "-lSDL3", + *(f"-F{directory}" for directory in frameworks), + "-framework", "libEGL", "-framework", "libGLESv2", "-framework", "Cocoa", + *(f"-Wl,-rpath,{directory}" for directory in frameworks), "-o", BUILD / "halo") + + +def minimum_macos_version(binaries): + output = subprocess.check_output(["otool", "-l", *map(str, binaries)], text=True) + versions = [] + for command in re.split(r"Load command \d+", output): + field = "minos" if "cmd LC_BUILD_VERSION" in command else "version" if "cmd LC_VERSION_MIN_MACOSX" in command else None + if field: + match = re.search(r"^\s+" + field + r" (\d+(?:\.\d+){1,2})\s*$", command, re.MULTILINE) + if match: + versions.append(match[1]) + return max(["14.0", *versions], key=lambda value: tuple(map(int, value.split(".")))) + + +def package(*, version=APP_VERSION, build=APP_BUILD, repository="local"): + destination = BUILD / "Halo CE Universal.app" + with tempfile.TemporaryDirectory(prefix=".halo-package-", dir=BUILD) as temporary: + staged = Path(temporary) / destination.name + package_into(staged, version=version, build=build, repository=repository) + # A fresh bundle prevents old development resources entering a release. + # Preserve the previous build, including a running executable's inode. + backup = None + if destination.exists(): + backup = BUILD / "app-backups.noindex" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + backup.mkdir(parents=True) + destination.rename(backup / (destination.name + ".backup")) + try: + staged.rename(destination) + except OSError: + if backup: + (backup / (destination.name + ".backup")).rename(destination) + raise + print(f"Built {destination}") + + +def package_into(app, *, version=APP_VERSION, build=APP_BUILD, repository="local"): + contents = app / "Contents" + macos = contents / "MacOS" + frameworks = contents / "Frameworks" + resources = contents / "Resources" + for directory in (macos, frameworks, resources): + directory.mkdir(parents=True, exist_ok=True) + executable = macos / "halo" + shutil.copy2(BUILD / "halo", executable) + shutil.copy2(BUILD / "halo_guest.elf", resources / "halo_guest.elf") + sdl = frameworks / "libSDL3.0.dylib" + shutil.copy2(require(SDL / "lib/libSDL3.0.dylib"), sdl) + run("install_name_tool", "-change", str(SDL / "lib/libSDL3.0.dylib"), + "@rpath/libSDL3.0.dylib", executable) + run("install_name_tool", "-id", "@rpath/libSDL3.0.dylib", sdl) + for name in ("EGL", "GLESv2"): + directory = ANGLE / f"{name}.xcframework/macos-arm64" + source = directory / f"lib{name}.framework" + target = frameworks / f"lib{name}.dylib" + shutil.copy2(source / f"lib{name}", target) + target.chmod(0o755) + run("install_name_tool", "-change", f"@rpath/lib{name}.framework/lib{name}", + f"@rpath/lib{name}.dylib", executable) + run("install_name_tool", "-id", f"@rpath/lib{name}.dylib", target) + run("install_name_tool", "-delete_rpath", str(directory), executable) + run("install_name_tool", "-add_rpath", "@executable_path/../Frameworks", executable) + info = { + "CFBundleExecutable": "halo", "CFBundleIdentifier": "local.halo.ce-universal", + "CFBundleName": "Halo CE Universal", "CFBundleDisplayName": "Halo CE Universal", + "CFBundlePackageType": "APPL", "CFBundleShortVersionString": version, + "CFBundleVersion": build, "LSMinimumSystemVersion": minimum_macos_version([ + executable, sdl, frameworks / "libEGL.dylib", frameworks / "libGLESv2.dylib"]), + "CFBundleURLTypes": [{"CFBundleURLName": "Halo multiplayer invite", + # Match the shared discord.application_id default. + "CFBundleURLSchemes": ["halo", "discord-1553978809840050229"], + "CFBundleTypeRole": "Viewer"}], + "NSLocalNetworkUsageDescription": "Connect to players hosting Halo multiplayer games.", + "NSHighResolutionCapable": True, + "NSHumanReadableCopyright": "Local experimental Apple Silicon port", + } + with (contents / "Info.plist").open("wb") as stream: + plistlib.dump(info, stream) + try: + revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, + text=True, stderr=subprocess.DEVNULL).strip() + if subprocess.check_output(["git", "status", "--porcelain"], cwd=ROOT, text=True).strip(): + revision += " (local changes)" + except (OSError, subprocess.CalledProcessError): + revision = "unknown" + guest_hash = hashlib.sha256((resources / "halo_guest.elf").read_bytes()).hexdigest() + (resources / "BuildInfo.txt").write_text( + f"Halo CE Universal {version} (build {build})\n" + f"Repository: {repository}\nSource: {revision}\nGuest SHA-256: {guest_hash}\n") + licenses = resources / "Licenses" + licenses.mkdir(exist_ok=True) + for source, name in ( + (ROOT / "LICENSE.md", "Project.txt"), + (ROOT / "port/assets/fonts/Overpass-OFL.txt", "Overpass-OFL.txt"), + (ROOT / "port/assets/fonts/OpenCE-OFL.txt", "OpenCE-OFL.txt"), + (ROOT / "port/assets/fonts/Newtown-LICENSE.txt", "Newtown-LICENSE.txt"), + (ROOT / "port/macos/licenses/ANGLE.txt", "ANGLE.txt"), + (SDL / "share/licenses/SDL3/LICENSE.txt", "SDL3.txt"), + (ROOT / "build/android/third_party/musl-1.2.5/COPYRIGHT", "musl.txt"), + (ROOT / "port/third_party/kcp/LICENSE", "KCP.txt"), + (ROOT / "port/third_party/miniupnpc/LICENSE", "miniupnpc.txt"), + (ROOT / "port/third_party/extract-xiso/LICENSE.TXT", "extract-xiso.txt"), + (ROOT / "port/third_party/expat/COPYING", "Expat.txt"), + (ROOT / "port/third_party/tomlc17/LICENSE", "tomlc17.txt"), + (ROOT / "port/third_party/mbedtls/LICENSE", "mbedtls.txt"), + ): + shutil.copy2(require(source), licenses / name) + for binary in (sdl, frameworks / "libGLESv2.dylib", frameworks / "libEGL.dylib", app): + run("codesign", "--force", "--sign", "-", binary) + run("codesign", "--verify", "--deep", "--strict", app) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plugin-only", action="store_true") + parser.add_argument("--host-only", action="store_true") + parser.add_argument("--version", default=APP_VERSION) + parser.add_argument("--build-number", default=APP_BUILD) + parser.add_argument("--repository", default="local", help="Source repository recorded in BuildInfo.txt") + parser.add_argument("--jobs", type=int, default=min(os.cpu_count() or 4, 6)) + args = parser.parse_args() + os.chdir(ROOT) + if args.plugin_only: + build_plugin() + return + if not args.host_only: + llvm_bin = BUILD / "toolchain/bin" + require(llvm_bin / "llvm-ar") + require(llvm_bin / "ld.lld") + require(GL / "GLES3/gl32.h") + run(sys.executable, "configure.py", "--macos", "--android-guest-llvm-bin", llvm_bin, + "--android-guest-gl-include", GL, "--pgo", "off") + ninja = shutil.which("ninja") or str(BUILD / "toolchain/venv/bin/ninja") + run(ninja, "-j", args.jobs, "macos_guest") + build_host() + package(version=args.version, build=args.build_number, repository=args.repository) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, subprocess.CalledProcessError) as error: + print(f"macOS build failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/tools/macos_guest_cc.py b/tools/macos_guest_cc.py new file mode 100755 index 0000000000..929232de46 --- /dev/null +++ b/tools/macos_guest_cc.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""LLVM 22 compiler adapter for the rebased ILP32 macOS guest.""" +import os +from pathlib import Path +import subprocess +import sys + + +def main(): + llvm = Path(os.environ.get("HALO_MACOS_LLVM_BIN", "/opt/homebrew/opt/llvm@22/bin")) + args = sys.argv[1:] + if "-S" not in args: + return subprocess.call([str(llvm / "clang"), *args]) + output_index = args.index("-o") + 1 + output = Path(args[output_index]) + ir = output.with_suffix(".ll") + args[output_index] = str(ir) + subprocess.run([str(llvm / "clang"), *args, "-emit-llvm", "-DHALO_MACOS=1"], check=True) + rebased = ir.with_suffix(".rebased.ll") + subprocess.run([str(llvm / "opt"), "-load-pass-plugin=build/macos/guest_rebase.dylib", + "-passes=halo-rebase,verify", "-S", str(ir), "-o", str(rebased)], check=True) + subprocess.run([str(llvm / "llc"), "-O2", "-mtriple=arm64_32-apple-watchos", + "-aarch64-neon-syntax=generic", "-emulated-tls", str(rebased), + "-o", str(output)], check=True) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except subprocess.CalledProcessError as error: + sys.exit(error.returncode) diff --git a/tools/macos_release.py b/tools/macos_release.py new file mode 100644 index 0000000000..3468efd26b --- /dev/null +++ b/tools/macos_release.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Package an asset-free, ad-hoc signed Mac app as a drag-to-Applications DMG.""" +import argparse +from pathlib import Path +import plistlib +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "build/macos/Halo CE Universal.app" +ACCOUNT = "local.halo.ce-universal" + + +def run(*args): + subprocess.run([str(arg) for arg in args], cwd=ROOT, check=True) + + +def audit_bundle(app): + """An explicit package boundary, not a license clearance for compiled code.""" + allowed = {"Info.plist", "MacOS", "Frameworks", "Resources", "_CodeSignature"} + contents = app / "Contents" + if {path.name for path in contents.iterdir()} - allowed: + raise RuntimeError("Unexpected top-level content in the app") + resources = contents / "Resources" + if {path.name for path in resources.iterdir()} - {"halo_guest.elf", "BuildInfo.txt", "Licenses"}: + raise RuntimeError("Release resources must contain only the compiled engine, build record and licenses") + if {path.name for path in (contents / "MacOS").iterdir()} != {"halo"}: + raise RuntimeError("Release executables must contain only the native host") + if {path.name for path in (contents / "Frameworks").iterdir()} != {"libSDL3.0.dylib", "libEGL.dylib", "libGLESv2.dylib"}: + raise RuntimeError("Unexpected bundled runtime dependency") + for path in app.rglob("*"): + if path.suffix.lower() in {".iso", ".xiso", ".map", ".xbe", ".pdb", ".p12", ".mobileprovision"}: + raise RuntimeError("Restricted or private input in app: " + str(path.relative_to(app))) + if path.is_symlink() and not path.resolve().is_relative_to(app.resolve()): + raise RuntimeError("External symlink in the signed app") + return True + + +def audit_adhoc_signing(app): + """Check all bundled code without exposing unexpected signing metadata.""" + with (app / "Contents/Info.plist").open("rb") as stream: + if plistlib.load(stream)["CFBundleIdentifier"] != ACCOUNT: + raise RuntimeError("Unexpected app bundle identifier") + # Check every architecture of every bundled Mach-O, including dependencies. + magic = {bytes.fromhex(value) for value in ( + "feedface", "cefaedfe", "feedfacf", "cffaedfe", + "cafebabe", "bebafeca", "cafebabf", "bfbafeca", + )} + checked = 0 + for path in app.rglob("*"): + if path.is_symlink() or not path.is_file(): + continue + with path.open("rb") as stream: + if stream.read(4) not in magic: + continue + architectures = subprocess.check_output(["lipo", "-archs", str(path)], text=True).split() + if not architectures: + raise RuntimeError("No Mach-O architectures found: " + str(path.relative_to(app))) + for architecture in architectures: + result = subprocess.run( + ["codesign", "-d", "--verbose=4", "--arch", architecture, str(path)], + capture_output=True, text=True, check=True) + fields = result.stderr.splitlines() + if ("Signature=adhoc" not in fields or "TeamIdentifier=not set" not in fields + or any(field.startswith("Authority=") for field in fields)): + raise RuntimeError("Expected ad-hoc signing without a certificate or team: " + + str(path.relative_to(app)) + " (" + architecture + ")") + checked += 1 + if not checked: + raise RuntimeError("No signed Mach-O code found in app") + print(f"Verified {checked} code objects: ad-hoc signing, no certificate authorities or team identifiers") + return checked + + +def create_dmg(app, destination): + if destination.exists(): + raise RuntimeError("Disk image output already exists; choose a new filename") + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="halo-dmg-") as temporary: + layout = Path(temporary) + run("ditto", app, layout / app.name) + (layout / "Applications").symlink_to("/Applications") + run("hdiutil", "create", "-volname", "Halo CE Universal", "-srcfolder", layout, + "-format", "UDZO", destination) + + +def local_dmg(args): + audit_bundle(APP) + run("codesign", "--verify", "--deep", "--strict", APP) + audit_adhoc_signing(APP) + with (APP / "Contents/Info.plist").open("rb") as stream: + info = plistlib.load(stream) + destination = args.output or ROOT / "build/macos" / ("Halo-" + info["CFBundleShortVersionString"] + "-local.dmg") + create_dmg(APP, destination.resolve()) + print("Prepared ad-hoc DMG (not notarized): " + str(destination)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + local = commands.add_parser("local-dmg", help="Package the built app without credentials or game data") + local.add_argument("--output", type=Path) + local_dmg(parser.parse_args()) + + +if __name__ == "__main__": + try: + main() + except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as error: + print("Packaging failed: " + str(error), file=sys.stderr) + sys.exit(1) diff --git a/tools/macos_setup.py b/tools/macos_setup.py new file mode 100644 index 0000000000..7105faa8bf --- /dev/null +++ b/tools/macos_setup.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Prepare public Mac build dependencies; never downloads game data or the XDK.""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import urllib.request +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +BUILD = ROOT / "build/macos" + + +def download(entry, destination): + if destination.exists() and hashlib.sha256(destination.read_bytes()).hexdigest() == entry["sha256"]: + return + print(f"Downloading {destination.name}", flush=True) + with urllib.request.urlopen(entry["url"], timeout=60) as response: + data = response.read() + if hashlib.sha256(data).hexdigest() != entry["sha256"]: + raise RuntimeError(f"Checksum mismatch for {destination.name}") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + + +def prepare_angle_distribution(directory): + """Keep EGL's dynamically loaded GLES companion inside this distribution.""" + egl = directory / "EGL.xcframework/macos-arm64/libEGL.framework/libEGL" + gles = directory / "GLESv2.xcframework/macos-arm64/libGLESv2.framework/libGLESv2" + for library in (egl, gles): + if not library.is_file(): + raise RuntimeError(f"Missing ANGLE runtime library: {library}") + companion = egl.with_name("libGLESv2.dylib") + relative = Path(os.path.relpath(gles, companion.parent)) + if companion.is_symlink() and companion.readlink() != relative: + companion.unlink() + if not companion.exists(): + companion.symlink_to(relative) + + +def main(): + manifest = json.loads((ROOT / "port/macos/dependencies.json").read_text()) + llvm = Path(os.environ.get("HALO_MACOS_LLVM_BIN", "/opt/homebrew/opt/llvm@22/bin")) + version = subprocess.check_output([llvm / "llvm-config", "--version"], text=True).strip() + if version.split(".")[0] != str(manifest["llvm_major"]): + raise RuntimeError("The compiler pass requires LLVM 22") + toolchain = BUILD / "toolchain" + binary = toolchain / "bin" + binary.mkdir(parents=True, exist_ok=True) + ar = binary / "llvm-ar" + if not ar.exists(): + ar.symlink_to(llvm / "llvm-ar") + linker = binary / "ld.lld" + lld = shutil.which("ld.lld") + if not lld and Path("/opt/homebrew/opt/lld@22/bin/ld.lld").exists(): + lld = "/opt/homebrew/opt/lld@22/bin/ld.lld" + if not linker.exists(): + if not lld: + raise RuntimeError("Install Homebrew lld@22 and add its bin directory to PATH") + linker.symlink_to(lld) + if not shutil.which("ninja"): + raise RuntimeError("Install ninja: brew install ninja") + for entry in manifest["headers"]: + download(entry, toolchain / "gl" / entry["path"]) + archive = BUILD / "angle/angle.zip" + download(manifest["angle"], archive) + if not (BUILD / "angle/dist/BUILD_INFO.txt").exists(): + with zipfile.ZipFile(archive) as package: + package.extractall(BUILD / "angle") + prepare_angle_distribution(BUILD / "angle/dist") + print(f"Mac build dependencies ready (LLVM {version})") + + +if __name__ == "__main__": + try: + main() + except (OSError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"Setup failed: {error}", file=sys.stderr) + sys.exit(1)