From ef2f926816d2456992729df6bc259d529c4e1312 Mon Sep 17 00:00:00 2001 From: Ruslan Semov Date: Wed, 21 Oct 2026 12:56:16 +0300 Subject: [PATCH 1/4] Standalone macOS app packaging and Steam data setup documentation --- docs/macos-build.md | 61 ++++++++++++++++++ macos/bundle.py | 154 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 docs/macos-build.md create mode 100644 macos/bundle.py diff --git a/docs/macos-build.md b/docs/macos-build.md new file mode 100644 index 00000000..f297fd82 --- /dev/null +++ b/docs/macos-build.md @@ -0,0 +1,61 @@ +# Building a macOS app + +The engine source does not include the game data. Start with a complete installation of Perimeter or Emperor's Testament from your own copy of the game, and keep the two installations in separate directories. + +## Russian game data from Steam + +Before copying the Windows installation to your Mac, open the game's Properties in Steam, select Russian in the language setting, and wait for Steam to finish downloading the language files. Then copy the complete game directory. Repeat this for Emperor's Testament if you use that game too. + +Changing the language in Perimeter's main menu selects resources that are already installed; it cannot download missing speech or repair a partially copied installation. In our test, the missing Russian speech came from an incomplete Steam download. No text conversion was needed once the complete resources were present. Steam documents the per-game language setting [here](https://help.steampowered.com/en/faqs/view/4984-C127-121D-B3F2). + +Text or speech baked into a movie will only change if the installation includes another version of that movie. A Russian-only video does not become English when the menu language changes. + +## Build the engine + +These commands target Apple Silicon and use Homebrew dependencies. Intel and universal builds need separate validation; selecting two architectures in CMake is not enough because DXVK uses a single-architecture Meson cross file. + +Install Xcode Command Line Tools if needed, then install the build dependencies: + +```sh +xcode-select --install +brew install cmake ninja meson pkgconf boost ffmpeg zlib lld sdl2 sdl2_image sdl2_mixer sdl2_net vulkan-headers vulkan-loader molten-vk +``` + +From the repository root, configure a fresh build directory: + +```sh +cmake -S . -B build-macos -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_FIND_FRAMEWORK=LAST -DOPTION_D3D9=ON -DOPTION_FFMPEG_MOVIES=ON +cmake --build build-macos --parallel 8 +``` + +The executable is `build-macos/Source/perimeter.app/Contents/MacOS/perimeter`. The build tree still uses libraries from the build machine; package it before treating it as a standalone application. + +If your checkout also contains the experimental macOS shader patch, add `-DOPTION_MACOS_D3D9_SHADERS=ON` when configuring. That option patches the pinned DXVK source and enables the R32F shadow-map path. It is off by default and is not part of unmodified upstream main. Do not enable the renderer changes against an unrelated unpatched DXVK library. + +The macOS FFmpeg finder patch uses pkg-config for headers and libraries together. If you use a custom FFmpeg installation, set `FFMPEG_ROOT` to its prefix or configure `PKG_CONFIG_PATH` for its `.pc` files. If CMake previously selected headers from GStreamer and libraries from Homebrew, start with a fresh build directory. + +## Package the app + +`macos/bundle.py` copies the built app, recursively bundles its linked non-system libraries, adds MoltenVK and its ICD manifest, rewrites library paths, creates the Finder launcher, and applies an ad-hoc signature. It leaves the source build untouched and refuses to overwrite an existing destination. + +```sh +python3 macos/bundle.py --app build-macos/Source/perimeter.app --output dist/Perimeter.app --icd "$(brew --prefix molten-vk)/etc/vulkan/icd.d/MoltenVK_icd.json" --library-dir build-macos/Source/Render/dxvk-prefix/src/dxvk-build/src/d3d9 +``` + +To include your game data in a personal copy of the app, add `--content "/absolute/path/to/Perimeter"` to that command. The data is copied into `Contents/Resources/Content` before signing. Use a separate output app for Emperor's Testament, with its own content directory. Game data is not included in this repository or in the patch. + +For an app without embedded data, supply the directory on first launch: + +```sh +open dist/Perimeter.app --args "content=/absolute/path/to/Perimeter" +``` + +The engine stores the selected content path in its preferences. An app with embedded data can be opened directly in Finder. The launcher uses absolute paths so spaces in the app's location do not change how libraries or content are resolved. + +Homebrew's full FFmpeg build can bring many dependent libraries into the bundle. A smaller release would need a separately configured FFmpeg build and a review of the bundled libraries' distribution requirements. Ad-hoc signing is for local use; it is not Developer ID signing or notarization. + +## Check the result + +The packager verifies the signature and checks for remaining absolute non-system library dependencies. Move the app to a different directory, open it from Finder, and test the intro, a mission briefing, speech, language switching, and the exit movie. Test switching to another application and back as a separate case. + +Local validation has covered dependency bundling and loading the bundled DXVK and MoltenVK libraries. The engine and shader changes have also been exercised in the game, including Emperor's Testament, but a Finder launch of the newly packaged app and a run on a Mac without the build dependencies still need to be checked. The fullscreen recovery workaround is a separate unfinished patch and is not included here. diff --git a/macos/bundle.py b/macos/bundle.py new file mode 100644 index 00000000..195aa3d3 --- /dev/null +++ b/macos/bundle.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Bundle a CMake-built macOS app and its non-system dynamic libraries.""" +import argparse +import json +from pathlib import Path +import plistlib +import shutil +import subprocess + + +def output(*cmd): + return subprocess.check_output([str(x) for x in cmd], text=True) + + +def run(*cmd): + subprocess.run([str(x) for x in cmd], check=True) + + +def system(path): + return path.startswith(('/usr/lib/', '/System/Library/')) + + +def dependencies(binary): + return [line.strip().split(' (compatibility version', 1)[0] + for line in output('otool', '-L', binary).splitlines()[1:] if line.strip()] + + +def rpaths(binary): + lines = output('otool', '-l', binary).splitlines() + return [lines[i + 2].strip().split('path ', 1)[1].split(' (offset', 1)[0] + for i, line in enumerate(lines) if line.strip() == 'cmd LC_RPATH'] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--app', required=True, type=Path, help='CMake-built perimeter.app') + parser.add_argument('--output', required=True, type=Path, help='new destination .app (must not exist)') + parser.add_argument('--icd', required=True, type=Path, help='MoltenVK ICD JSON installed on the build host') + parser.add_argument('--content', type=Path, help='optional complete game directory to copy for personal use') + parser.add_argument('--library-dir', action='append', default=[], type=Path) + args = parser.parse_args() + source_app = args.app.resolve() + dest = args.output.absolute() + if dest.exists() or dest.suffix != '.app': + parser.error('--output must be a new .app path') + if source_app in dest.resolve().parents: + parser.error('--output must be outside the source app') + if args.content and (args.content.resolve() == dest.resolve() or args.content.resolve() in dest.resolve().parents): + parser.error('--output must be outside the content directory') + executable = source_app / 'Contents/MacOS/perimeter' + if not executable.is_file(): + parser.error('expected Contents/MacOS/perimeter in --app') + if args.content and not args.content.is_dir(): + parser.error('--content must be a game directory') + icd = json.loads(args.icd.read_text()) + molten = (args.icd.parent / icd['ICD']['library_path']).resolve() + if not molten.is_file(): + parser.error('MoltenVK ICD library was not found') + + # Resolve the complete graph before creating the output. Missing libraries + # fail packaging instead of silently leaving build-host paths in the app. + graph = {} + names = {} + inherited = rpaths(executable) + def expand(value, owner): + return value.replace('@loader_path', str(owner.parent)).replace('@executable_path', str(executable.parent)) + def resolve(dep, owner): + if dep.startswith('@rpath/'): + suffix = dep[len('@rpath/'):] + choices = [Path(expand(p, owner)) / suffix for p in rpaths(owner)] + choices += [Path(expand(p, executable)) / suffix for p in inherited] + choices += [p.resolve() / suffix for p in args.library_dir] + else: + choices = [Path(expand(dep, owner))] + for p in choices: + if p.is_file(): + return p.resolve() + raise RuntimeError(f'Cannot resolve {dep} required by {owner}') + def visit(binary): + binary = binary.resolve() + if binary in graph: + return + if binary != executable.resolve(): + previous = names.setdefault(binary.name, binary) + if previous != binary: + raise RuntimeError(f'Conflicting library names: {previous} and {binary}') + graph[binary] = {} + ids = output('otool', '-D', binary).splitlines()[1:] + for dep in dependencies(binary): + if dep in ids or system(dep): + continue + child = resolve(dep, binary) + graph[binary][dep] = child + visit(child) + visit(executable) + visit(molten) + shutil.copytree(source_app, dest) + frameworks = dest / 'Contents/Frameworks' + frameworks.mkdir(exist_ok=True) + resources = dest / 'Contents/Resources' + resources.mkdir(exist_ok=True) + if args.content: + shutil.copytree(args.content, resources / 'Content') + target_exe = dest / 'Contents/MacOS/perimeter' + for source, deps in graph.items(): + is_exe = source == executable.resolve() + target = target_exe if is_exe else frameworks / source.name + if not is_exe: + shutil.copy2(source, target) + target.chmod(target.stat().st_mode | 0o200) + run('install_name_tool', '-id', '@rpath/' + target.name, target) + for old, child in deps.items(): + prefix = '@executable_path/../Frameworks/' if is_exe else '@loader_path/' + run('install_name_tool', '-change', old, prefix + child.name, target) + for path in rpaths(target): + run('install_name_tool', '-delete_rpath', path, target) + icd['ICD']['library_path'] = '../Frameworks/' + molten.name + (resources / 'MoltenVK_icd.json').write_text(json.dumps(icd, indent=2) + '\n') + launcher = dest / 'Contents/MacOS/start' + launcher.write_text('''#!/bin/sh +set -eu +macos_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export VK_DRIVER_FILES="$macos_dir/../Resources/MoltenVK_icd.json" +export VK_ICD_FILENAMES="$VK_DRIVER_FILES" +if [ -d "$macos_dir/../Resources/Content" ]; then + exec "$macos_dir/perimeter" "content=$macos_dir/../Resources/Content" "$@" +fi +exec "$macos_dir/perimeter" "$@" +''') + launcher.chmod(0o755) + plist_path = dest / 'Contents/Info.plist' + with plist_path.open('rb') as f: + plist = plistlib.load(f) + plist['CFBundleExecutable'] = 'start' + with plist_path.open('wb') as f: + plistlib.dump(plist, f) + # Sign inside out after all install-name changes. This is a local ad-hoc + # signature; it is not Developer ID signing or notarization. + for lib in sorted(frameworks.iterdir()): + if lib.is_file(): + run('codesign', '--force', '--sign', '-', lib) + run('codesign', '--force', '--sign', '-', target_exe) + run('codesign', '--force', '--sign', '-', dest) + run('codesign', '--verify', '--deep', '--strict', dest) + for binary in [target_exe, *frameworks.iterdir()]: + if binary.is_file(): + for dep in dependencies(binary): + if not system(dep) and not dep.startswith(('@loader_path/', '@executable_path/', '@rpath/')): + raise RuntimeError(f'Unbundled dependency: {binary}: {dep}') + print(f'Bundled {len(names)} libraries into {dest}') + + +if __name__ == '__main__': + main() From 3f289a06f4c5d2ab4d65074d28594c8c0f6019a6 Mon Sep 17 00:00:00 2001 From: Ruslan Semov Date: Wed, 21 Oct 2026 13:01:31 +0300 Subject: [PATCH 2/4] Bundle minimum macOS version reflects linked libraries --- docs/macos-build.md | 2 ++ macos/bundle.py | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/macos-build.md b/docs/macos-build.md index f297fd82..eebf4dea 100644 --- a/docs/macos-build.md +++ b/docs/macos-build.md @@ -52,6 +52,8 @@ open dist/Perimeter.app --args "content=/absolute/path/to/Perimeter" The engine stores the selected content path in its preferences. An app with embedded data can be opened directly in Finder. The launcher uses absolute paths so spaces in the app's location do not change how libraries or content are resolved. +The minimum macOS version comes from the newest requirement among the executable and bundled libraries, which the packager records in Info.plist. A low CMake deployment target does not make newer Homebrew libraries work on older macOS releases. + Homebrew's full FFmpeg build can bring many dependent libraries into the bundle. A smaller release would need a separately configured FFmpeg build and a review of the bundled libraries' distribution requirements. Ad-hoc signing is for local use; it is not Developer ID signing or notarization. ## Check the result diff --git a/macos/bundle.py b/macos/bundle.py index 195aa3d3..c23adb0e 100644 --- a/macos/bundle.py +++ b/macos/bundle.py @@ -31,6 +31,19 @@ def rpaths(binary): for i, line in enumerate(lines) if line.strip() == 'cmd LC_RPATH'] +def minimum_macos(binary): + command = "" + versions = [] + for line in output('otool', '-l', binary).splitlines(): + parts = line.split() + if len(parts) == 2 and parts[0] == 'cmd': + command = parts[1] + if len(parts) == 2 and ((command == 'LC_BUILD_VERSION' and parts[0] == 'minos') + or (command == 'LC_VERSION_MIN_MACOSX' and parts[0] == 'version')): + versions.append(tuple(int(n) for n in parts[1].split('.'))) + return max(versions, default=(10, 15)) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--app', required=True, type=Path, help='CMake-built perimeter.app') @@ -132,6 +145,9 @@ def visit(binary): with plist_path.open('rb') as f: plist = plistlib.load(f) plist['CFBundleExecutable'] = 'start' + declared_min = tuple(int(n) for n in plist.get('LSMinimumSystemVersion', '10.15').split('.')) + required_min = max([declared_min, *(minimum_macos(binary) for binary in graph)]) + plist['LSMinimumSystemVersion'] = '.'.join(str(n) for n in required_min) with plist_path.open('wb') as f: plistlib.dump(plist, f) # Sign inside out after all install-name changes. This is a local ad-hoc @@ -147,7 +163,7 @@ def visit(binary): for dep in dependencies(binary): if not system(dep) and not dep.startswith(('@loader_path/', '@executable_path/', '@rpath/')): raise RuntimeError(f'Unbundled dependency: {binary}: {dep}') - print(f'Bundled {len(names)} libraries into {dest}') + print(f'Bundled {len(names)} libraries into {dest}; minimum macOS {plist["LSMinimumSystemVersion"]}') if __name__ == '__main__': From a9911126ee104e8253de6810d07662860beb6bbb Mon Sep 17 00:00:00 2001 From: Ruslan Semov Date: Wed, 21 Oct 2026 13:10:33 +0300 Subject: [PATCH 3/4] Language persistence checks and embedded-content limits documented --- docs/macos-build.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/macos-build.md b/docs/macos-build.md index eebf4dea..204bba0e 100644 --- a/docs/macos-build.md +++ b/docs/macos-build.md @@ -10,6 +10,14 @@ Changing the language in Perimeter's main menu selects resources that are alread Text or speech baked into a movie will only change if the installation includes another version of that movie. A Russian-only video does not become English when the menu language changes. +## Switching languages + +The language button in the main menu switches between installed languages and restarts the engine, skipping the startup splash. The choice is written when it is made, so an ordinary exit should not be needed to save it. On the next launch the engine reads the saved selection, unless a launcher supplies an explicit `locale=Russian` or `locale=English` argument. Leave that argument out of a normal launcher; use it only when you deliberately want to override the saved choice. `clearlocale=1` also clears the selection. + +Language switching was checked in the running macOS game. Persistence was checked separately with unchanged engine function bodies and real INI writes in a fixture harness: Russian → English → process exit → English, and English → Russian → process exit → Russian, for both base-game and Emperor's Testament settings. That test substitutes filesystem discovery and the preference directory; it is not a complete menu/quit/relaunch test of the packaged app, which remains outstanding. Legate Edition has not been tested. + +The engine currently reads `[Game] Language` from `Perimeter.ini` before its saved preferences, and a menu selection updates both locations. If content is embedded in the app, changing languages can therefore rewrite a file inside the signed bundle and invalidate its resource seal. Use external game data while this packaging detail is unresolved; embedded-content language switching is still a draft feature. + ## Build the engine These commands target Apple Silicon and use Homebrew dependencies. Intel and universal builds need separate validation; selecting two architectures in CMake is not enough because DXVK uses a single-architecture Meson cross file. @@ -42,7 +50,7 @@ The macOS FFmpeg finder patch uses pkg-config for headers and libraries together python3 macos/bundle.py --app build-macos/Source/perimeter.app --output dist/Perimeter.app --icd "$(brew --prefix molten-vk)/etc/vulkan/icd.d/MoltenVK_icd.json" --library-dir build-macos/Source/Render/dxvk-prefix/src/dxvk-build/src/d3d9 ``` -To include your game data in a personal copy of the app, add `--content "/absolute/path/to/Perimeter"` to that command. The data is copied into `Contents/Resources/Content` before signing. Use a separate output app for Emperor's Testament, with its own content directory. Game data is not included in this repository or in the patch. +For experiments with embedded game data, add `--content "/absolute/path/to/Perimeter"` to that command. The data is copied into `Contents/Resources/Content` before signing. Use a separate output app for Emperor's Testament, with its own content directory. Game data is not included in this repository or in the patch. For an app without embedded data, supply the directory on first launch: @@ -50,7 +58,7 @@ For an app without embedded data, supply the directory on first launch: open dist/Perimeter.app --args "content=/absolute/path/to/Perimeter" ``` -The engine stores the selected content path in its preferences. An app with embedded data can be opened directly in Finder. The launcher uses absolute paths so spaces in the app's location do not change how libraries or content are resolved. +The engine stores the selected content path in its preferences. The launcher also supports embedded data, subject to the language-switching limitation above. The launcher uses absolute paths so spaces in the app's location do not change how libraries or content are resolved. The minimum macOS version comes from the newest requirement among the executable and bundled libraries, which the packager records in Info.plist. A low CMake deployment target does not make newer Homebrew libraries work on older macOS releases. From 940f2bf16ad336d1863ac8538a4a3fe1ea09b51a Mon Sep 17 00:00:00 2001 From: Ruslan Semov Date: Wed, 21 Oct 2026 14:56:57 +0300 Subject: [PATCH 4/4] macOS user locale persistence and dynamically loaded library bundling --- Source/Util/Localization.cpp | 8 ++++++++ docs/macos-build.md | 14 ++++++++------ macos/bundle.py | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Source/Util/Localization.cpp b/Source/Util/Localization.cpp index 79a4318b..95103f59 100644 --- a/Source/Util/Localization.cpp +++ b/Source/Util/Localization.cpp @@ -14,11 +14,13 @@ std::string localePath; std::vector localesAvailable; void saveLocale(const std::string& locale) { +#ifndef __APPLE__ //Windows players are used to changing it in Locale, so keep behavior consistent if is present in Perimeter.ini IniManager perimeter_ini("Perimeter.ini", false); if (perimeter_ini.get("Game", "Language")) { perimeter_ini.put("Game", "Language", locale.c_str()); } +#endif putStringSettings("Locale", locale); } @@ -68,6 +70,12 @@ void initLocale() { #endif IniManager perimeter_ini("Perimeter.ini", false); +#ifdef __APPLE__ + // User preferences take precedence over the read-only game defaults. + if (localeCurrent.empty()) { + localeCurrent = getStringSettings("Locale"); + } +#endif if (localeCurrent.empty()) { localeCurrent = perimeter_ini.get("Game", "Language"); if (!localeCurrent.empty()) { diff --git a/docs/macos-build.md b/docs/macos-build.md index 204bba0e..8f32ebd5 100644 --- a/docs/macos-build.md +++ b/docs/macos-build.md @@ -14,9 +14,9 @@ Text or speech baked into a movie will only change if the installation includes The language button in the main menu switches between installed languages and restarts the engine, skipping the startup splash. The choice is written when it is made, so an ordinary exit should not be needed to save it. On the next launch the engine reads the saved selection, unless a launcher supplies an explicit `locale=Russian` or `locale=English` argument. Leave that argument out of a normal launcher; use it only when you deliberately want to override the saved choice. `clearlocale=1` also clears the selection. -Language switching was checked in the running macOS game. Persistence was checked separately with unchanged engine function bodies and real INI writes in a fixture harness: Russian → English → process exit → English, and English → Russian → process exit → Russian, for both base-game and Emperor's Testament settings. That test substitutes filesystem discovery and the preference directory; it is not a complete menu/quit/relaunch test of the packaged app, which remains outstanding. Legate Edition has not been tested. +Language switching followed by a full exit and relaunch was checked in the packaged app with external game data. On macOS, the selected language is stored in the user's settings and takes precedence over the default in `Perimeter.ini`. Changing the language does not modify the game data. The command-line `locale` option still overrides the saved preference. -The engine currently reads `[Game] Language` from `Perimeter.ini` before its saved preferences, and a menu selection updates both locations. If content is embedded in the app, changing languages can therefore rewrite a file inside the signed bundle and invalidate its resource seal. Use external game data while this packaging detail is unresolved; embedded-content language switching is still a draft feature. +Use writable external game data under `~/Library/Application Support/K-D LAB/Perimeter/` for normal play. Saved games currently remain under `Resource/Saves` in the selected game directory. Embedded content still needs a separate writable-save path before it is suitable for normal play. ## Build the engine @@ -26,7 +26,7 @@ Install Xcode Command Line Tools if needed, then install the build dependencies: ```sh xcode-select --install -brew install cmake ninja meson pkgconf boost ffmpeg zlib lld sdl2 sdl2_image sdl2_mixer sdl2_net vulkan-headers vulkan-loader molten-vk +brew install cmake ninja meson pkgconf boost ffmpeg zlib lld sdl2 sdl3 sdl2_image sdl2_mixer sdl2_net vulkan-headers vulkan-loader molten-vk ``` From the repository root, configure a fresh build directory: @@ -47,9 +47,11 @@ The macOS FFmpeg finder patch uses pkg-config for headers and libraries together `macos/bundle.py` copies the built app, recursively bundles its linked non-system libraries, adds MoltenVK and its ICD manifest, rewrites library paths, creates the Finder launcher, and applies an ad-hoc signature. It leaves the source build untouched and refuses to overwrite an existing destination. ```sh -python3 macos/bundle.py --app build-macos/Source/perimeter.app --output dist/Perimeter.app --icd "$(brew --prefix molten-vk)/etc/vulkan/icd.d/MoltenVK_icd.json" --library-dir build-macos/Source/Render/dxvk-prefix/src/dxvk-build/src/d3d9 +python3 macos/bundle.py --app build-macos/Source/perimeter.app --output dist/Perimeter.app --icd "$(brew --prefix molten-vk)/etc/vulkan/icd.d/MoltenVK_icd.json" --library-dir build-macos/Source/Render/dxvk-prefix/src/dxvk-build/src/d3d9 --runtime-library "$(brew --prefix sdl3)/lib/libSDL3.dylib" ``` +`--runtime-library` includes libraries loaded dynamically and preserves their lookup filenames. Homebrew SDL2-compat needs SDL3 this way because it loads SDL3 with `dlopen`; the library is absent from its linked dependency list. Omit this argument when using native SDL2 instead of SDL2-compat. + For experiments with embedded game data, add `--content "/absolute/path/to/Perimeter"` to that command. The data is copied into `Contents/Resources/Content` before signing. Use a separate output app for Emperor's Testament, with its own content directory. Game data is not included in this repository or in the patch. For an app without embedded data, supply the directory on first launch: @@ -58,7 +60,7 @@ For an app without embedded data, supply the directory on first launch: open dist/Perimeter.app --args "content=/absolute/path/to/Perimeter" ``` -The engine stores the selected content path in its preferences. The launcher also supports embedded data, subject to the language-switching limitation above. The launcher uses absolute paths so spaces in the app's location do not change how libraries or content are resolved. +The engine stores the selected content path in its preferences. The launcher also supports embedded data, subject to the writable-save limitation above. The launcher uses absolute paths so spaces in the app's location do not change how libraries or content are resolved. The minimum macOS version comes from the newest requirement among the executable and bundled libraries, which the packager records in Info.plist. A low CMake deployment target does not make newer Homebrew libraries work on older macOS releases. @@ -68,4 +70,4 @@ Homebrew's full FFmpeg build can bring many dependent libraries into the bundle. The packager verifies the signature and checks for remaining absolute non-system library dependencies. Move the app to a different directory, open it from Finder, and test the intro, a mission briefing, speech, language switching, and the exit movie. Test switching to another application and back as a separate case. -Local validation has covered dependency bundling and loading the bundled DXVK and MoltenVK libraries. The engine and shader changes have also been exercised in the game, including Emperor's Testament, but a Finder launch of the newly packaged app and a run on a Mac without the build dependencies still need to be checked. The fullscreen recovery workaround is a separate unfinished patch and is not included here. +Local validation has covered dependency bundling and loading the bundled DXVK and MoltenVK libraries. The engine and shader changes have also been exercised in the game, including Emperor's Testament, and the packaged app has been launched through macOS Launch Services. A run on a Mac without the build dependencies still needs to be checked. The fullscreen recovery workaround is a separate unfinished patch and is not included here. diff --git a/macos/bundle.py b/macos/bundle.py index c23adb0e..a09d10c0 100644 --- a/macos/bundle.py +++ b/macos/bundle.py @@ -51,6 +51,8 @@ def main(): parser.add_argument('--icd', required=True, type=Path, help='MoltenVK ICD JSON installed on the build host') parser.add_argument('--content', type=Path, help='optional complete game directory to copy for personal use') parser.add_argument('--library-dir', action='append', default=[], type=Path) + parser.add_argument('--runtime-library', action='append', default=[], type=Path, + help='additional dlopen library to bundle, preserving its lookup filename') args = parser.parse_args() source_app = args.app.resolve() dest = args.output.absolute() @@ -107,6 +109,15 @@ def visit(binary): visit(child) visit(executable) visit(molten) + runtime_aliases = {} + for library in args.runtime_library: + if not library.is_file(): + parser.error(f'runtime library was not found: {library}') + resolved = library.resolve() + visit(resolved) + previous = runtime_aliases.setdefault(library.name, resolved) + if previous != resolved or (library.name in names and names[library.name] != resolved): + parser.error(f'conflicting runtime library name: {library.name}') shutil.copytree(source_app, dest) frameworks = dest / 'Contents/Frameworks' frameworks.mkdir(exist_ok=True) @@ -127,6 +138,9 @@ def visit(binary): run('install_name_tool', '-change', old, prefix + child.name, target) for path in rpaths(target): run('install_name_tool', '-delete_rpath', path, target) + for name, source in runtime_aliases.items(): + if name != source.name: + (frameworks / name).symlink_to(source.name) icd['ICD']['library_path'] = '../Frameworks/' + molten.name (resources / 'MoltenVK_icd.json').write_text(json.dumps(icd, indent=2) + '\n') launcher = dest / 'Contents/MacOS/start'