Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Source/Util/Localization.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ std::string localePath;
std::vector<std::string> 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);
}

Expand Down Expand Up @@ -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()) {
Expand Down
73 changes: 73 additions & 0 deletions docs/macos-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# 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.

## 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 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.

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

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 sdl3 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 --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:

```sh
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 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.

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, 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.
184 changes: 184 additions & 0 deletions macos/bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/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 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')
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)
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()
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)
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)
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)
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'
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'
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
# 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}; minimum macOS {plist["LSMinimumSystemVersion"]}')


if __name__ == '__main__':
main()
Loading