summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorquarrel07 <178681861+quarrel07@users.noreply.github.com>2026-08-01 18:35:51 -0700
committerGitHub <noreply@github.com>2026-08-01 19:35:51 -0600
commitaa6c9c10caf3630ca37cb45bafc0485b76605445 (patch)
treef2dacffad35e2d852e1b6b8775e37c456c5f89c9
parent08dca164d4604002be9c98211bb6de0a196738bf (diff)
macOS: package the game as a proper SpaghettiKart.app bundle (#724)
* macOS: package the game as a proper SpaghettiKart.app bundle Turns the bare Spaghettify executable into a self-contained SpaghettiKart.app: - cmake/macos/apple_bundle.cmake: bundle metadata (using the existing Info.plist, which already described a bundle), app icon, runtime resources (config.yml, yamls, meta, spaghetti.o2r) into Contents/Resources, dylib relinking for local Homebrew builds (no-op with static vcpkg deps), and ad-hoc codesigning so the app launches without 'damaged app' warnings - App icon: on Xcode 26+ the Icon Composer package (SpaghettiKartIcon.icon, drawn from the existing HM ship-kart icon.png art) compiles with actool into an Assets.car, giving the native Liquid Glass look on macOS 26+, plus a flattened icns for older systems; older Xcode falls back to the previous sips/iconutil flat icon. Supersedes the CreateOSXIcons target. - Game.cpp: default SHIP_HOME to ~/Library/Application Support/SpaghettiKart on macOS so first-run extraction, saves, config, and mods land there instead of scattering into the user's home folder; a user-set SHIP_HOME still wins - Info.plist: NSHighResolutionCapable for proper Retina rendering - CI: mac jobs now ship the .app (ditto zip so the executable bit survives artifact upload) - the yamls-next-to-exe copy is scoped to non-Darwin; on macOS those files live in Contents/Resources instead * macOS: ship prebuilt icon artifacts instead of compiling them at build time Commits the compiled Assets.car (Liquid Glass icon for macOS 26+) and the flattened SpaghettiKart.icns, both built from the in-repo Icon Composer package. The build now just copies them into the bundle, which removes the actool machinery and the Xcode 26 requirement entirely: every build on any machine, including CI, gets the real icon. Also adds a staleness guard: the artifacts carry a hash stamp of the icon.png they were made from, and if icon.png ever changes without them being regenerated, the build automatically generates a flat icns from the current icon.png instead, so updating the logo never requires any macOS work and an outdated icon can never ship. Regeneration steps are documented in apple_bundle.cmake. * Rename the icon layer to ship-kart-layer.png Group 2.png was the design tool's default layer name; name it for what it is. Verified the Icon Composer package still compiles with actool after the rename; the committed Assets.car is unaffected (compiled, self-contained). --------- Co-authored-by: MegaMech <MegaMech@users.noreply.github.com>
-rw-r--r--.github/workflows/macos-compile.yml4
-rw-r--r--.github/workflows/main.yml28
-rw-r--r--CMakeLists.txt17
-rw-r--r--Info.plist6
-rw-r--r--cmake/macos/Assets.carbin0 -> 2621960 bytes
-rw-r--r--cmake/macos/SpaghettiKart.icnsbin0 -> 72290 bytes
-rw-r--r--cmake/macos/SpaghettiKartIcon.icon.source-sha2561
-rw-r--r--cmake/macos/SpaghettiKartIcon.icon/Assets/ship-kart-layer.pngbin0 -> 378328 bytes
-rw-r--r--cmake/macos/SpaghettiKartIcon.icon/icon.json80
-rw-r--r--cmake/macos/apple_bundle.cmake166
-rw-r--r--cmake/macos/entitlements.plist10
-rw-r--r--cmake/macos/fixup_bundle.cmake31
-rw-r--r--src/port/Game.cpp8
13 files changed, 330 insertions, 21 deletions
diff --git a/.github/workflows/macos-compile.yml b/.github/workflows/macos-compile.yml
index d3c5dfbf8..59986d8fc 100644
--- a/.github/workflows/macos-compile.yml
+++ b/.github/workflows/macos-compile.yml
@@ -25,7 +25,9 @@ jobs:
- name: Create Package
run: |
mkdir spaghetti-${{ matrix.config }}
- mv build-cmake/Spaghettify spaghetti-${{ matrix.config }}/
+ # The mac build now produces SpaghettiKart.app; zip with ditto so the
+ # executable bit survives artifact upload.
+ ditto -c -k --keepParent build-cmake/SpaghettiKart.app spaghetti-${{ matrix.config }}/SpaghettiKart.app.zip
- name: Publish packaged artifacts
uses: actions/upload-artifact@v4
with:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 3b97f5122..16a0758d6 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -107,13 +107,15 @@ jobs:
path: ./build-cmake
- name: Create Package
run: |
+ APP=build-cmake/SpaghettiKart.app
+ # Insert the canonical spaghetti.o2r from the generate-port-o2r job and
+ # the controller database, then re-sign since the bundle contents changed.
+ cp build-cmake/spaghetti.o2r $APP/Contents/Resources/spaghetti.o2r
+ wget -O $APP/Contents/Resources/gamecontrollerdb.txt https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/master/gamecontrollerdb.txt
+ codesign --force --deep --sign - --options runtime --entitlements cmake/macos/entitlements.plist $APP
mkdir spaghetti-${{ matrix.config }}
- mv build-cmake/Spaghettify spaghetti-${{ matrix.config }}/
- mv build-cmake/spaghetti.o2r spaghetti-${{ matrix.config }}/
- mv config.yml spaghetti-${{ matrix.config }}/
- mv yamls spaghetti-${{ matrix.config }}/
- mv meta spaghetti-${{ matrix.config }}/
- wget -O spaghetti-${{ matrix.config }}/gamecontrollerdb.txt https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/master/gamecontrollerdb.txt
+ # Zip with ditto so the executable bit survives artifact upload.
+ ditto -c -k --keepParent $APP spaghetti-${{ matrix.config }}/SpaghettiKart.app.zip
- name: Publish packaged artifacts
if: matrix.config == 'Release'
uses: actions/upload-artifact@v4
@@ -146,13 +148,15 @@ jobs:
path: ./build-cmake
- name: Create Package
run: |
+ APP=build-cmake/SpaghettiKart.app
+ # Insert the canonical spaghetti.o2r from the generate-port-o2r job and
+ # the controller database, then re-sign since the bundle contents changed.
+ cp build-cmake/spaghetti.o2r $APP/Contents/Resources/spaghetti.o2r
+ wget -O $APP/Contents/Resources/gamecontrollerdb.txt https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/master/gamecontrollerdb.txt
+ codesign --force --deep --sign - --options runtime --entitlements cmake/macos/entitlements.plist $APP
mkdir spaghetti-${{ matrix.config }}
- mv build-cmake/Spaghettify spaghetti-${{ matrix.config }}/
- mv build-cmake/spaghetti.o2r spaghetti-${{ matrix.config }}/
- mv config.yml spaghetti-${{ matrix.config }}/
- mv yamls spaghetti-${{ matrix.config }}/
- mv meta spaghetti-${{ matrix.config }}/
- wget -O spaghetti-${{ matrix.config }}/gamecontrollerdb.txt https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/master/gamecontrollerdb.txt
+ # Zip with ditto so the executable bit survives artifact upload.
+ ditto -c -k --keepParent $APP spaghetti-${{ matrix.config }}/SpaghettiKart.app.zip
- name: Publish packaged artifacts
if: matrix.config == 'Release'
uses: actions/upload-artifact@v4
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 883763dfe..9eac9a299 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -204,6 +204,10 @@ endif()
# ##############################################################################
+# On macOS these live in the .app's Contents/Resources instead (see
+# cmake/macos/apple_bundle.cmake); copying them next to the executable would
+# just duplicate them inside Contents/MacOS.
+if(NOT CMAKE_SYSTEM_NAME MATCHES "Darwin")
add_custom_command(
TARGET ${PROJECT_NAME}
POST_BUILD
@@ -213,6 +217,7 @@ add_custom_command(
-DDESTINATION_DIR=$<TARGET_FILE_DIR:${PROJECT_NAME}>
-P ${CMAKE_SOURCE_DIR}/cmake/StageRuntimeFiles.cmake
VERBATIM)
+endif()
if(NOT CMAKE_SYSTEM_NAME STREQUAL "NintendoSwitch")
include(ExternalProject)
@@ -281,16 +286,12 @@ if(NOT CMAKE_SYSTEM_NAME STREQUAL "NintendoSwitch")
endif()
if(APPLE AND NOT SPAGHETTIKART_IOS)
- add_custom_target(
- CreateOSXIcons
- COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/macosx
- COMMAND sips -s format icns icon.png --out
- ${CMAKE_BINARY_DIR}/macosx/spaghettikart.icns
- WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
- COMMENT "Creating OSX icons ...")
- add_dependencies(${PROJECT_NAME} CreateOSXIcons)
configure_file("${CMAKE_SOURCE_DIR}/Info.plist"
"${CMAKE_BINARY_DIR}/Info.plist" COPYONLY)
+ # Assemble SpaghettiKart.app: app icon, bundle metadata, resources, dylib
+ # relinking, and ad-hoc codesigning. Also generates the icns (superseding
+ # the old CreateOSXIcons target).
+ include(${CMAKE_SOURCE_DIR}/cmake/macos/apple_bundle.cmake)
endif()
set_property(TARGET ${PROJECT_NAME} PROPERTY APPIMAGE_DESKTOP_FILE_TERMINAL
diff --git a/Info.plist b/Info.plist
index d844ba858..b2600d74f 100644
--- a/Info.plist
+++ b/Info.plist
@@ -12,6 +12,8 @@
<string>1.0.0</string>
<key>CFBundleIconFile</key>
<string>SpaghettiKart.icns</string>
+ <key>CFBundleIconName</key>
+ <string>SpaghettiKartIcon</string>
<key>CFBundleIdentifier</key>
<string>com.SpaghettiKart.SpaghettiKart</string>
<key>CFBundleDocumentTypes</key>
@@ -33,6 +35,10 @@
<string>public.app-category.games</string>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
+ <key>NSHighResolutionCapable</key>
+ <true/>
+ <key>NSSupportsAutomaticGraphicsSwitching</key>
+ <true/>
<key>LSArchitecturePriority</key>
<array>
<string>arm64</string>
diff --git a/cmake/macos/Assets.car b/cmake/macos/Assets.car
new file mode 100644
index 000000000..ab3d52f08
--- /dev/null
+++ b/cmake/macos/Assets.car
Binary files differ
diff --git a/cmake/macos/SpaghettiKart.icns b/cmake/macos/SpaghettiKart.icns
new file mode 100644
index 000000000..49d6c6e0a
--- /dev/null
+++ b/cmake/macos/SpaghettiKart.icns
Binary files differ
diff --git a/cmake/macos/SpaghettiKartIcon.icon.source-sha256 b/cmake/macos/SpaghettiKartIcon.icon.source-sha256
new file mode 100644
index 000000000..e90c4c891
--- /dev/null
+++ b/cmake/macos/SpaghettiKartIcon.icon.source-sha256
@@ -0,0 +1 @@
+b211f35ff967bb6ba0efda748c5a54d6f4909f97138c6d7178c860487fe6c80f
diff --git a/cmake/macos/SpaghettiKartIcon.icon/Assets/ship-kart-layer.png b/cmake/macos/SpaghettiKartIcon.icon/Assets/ship-kart-layer.png
new file mode 100644
index 000000000..725f876c0
--- /dev/null
+++ b/cmake/macos/SpaghettiKartIcon.icon/Assets/ship-kart-layer.png
Binary files differ
diff --git a/cmake/macos/SpaghettiKartIcon.icon/icon.json b/cmake/macos/SpaghettiKartIcon.icon/icon.json
new file mode 100644
index 000000000..6d9bbe0d9
--- /dev/null
+++ b/cmake/macos/SpaghettiKartIcon.icon/icon.json
@@ -0,0 +1,80 @@
+{
+ "fill" : {
+ "linear-gradient" : [
+ "display-p3:0.01176,0.05884,0.20398,1.00000",
+ "display-p3:0.06274,0.10980,0.10199,1.00000"
+ ],
+ "orientation" : {
+ "start" : {
+ "x" : 0.5,
+ "y" : 1
+ },
+ "stop" : {
+ "x" : 0.5,
+ "y" : 0
+ }
+ }
+ },
+ "groups" : [
+ {
+ "blur-material" : null,
+ "layers" : [
+ {
+ "glass" : true,
+ "image-name" : "ship-kart-layer.png",
+ "name" : "ship-kart-layer",
+ "position-specializations" : [
+ {
+ "value" : {
+ "scale" : 1,
+ "translation-in-points" : [
+ 30.66035267835514,
+ -14.790467402525564
+ ]
+ }
+ },
+ {
+ "idiom" : "square",
+ "value" : {
+ "scale" : 0.97,
+ "translation-in-points" : [
+ 18.04483550076793,
+ -6.30499999999995
+ ]
+ }
+ }
+ ]
+ }
+ ],
+ "lighting" : "individual",
+ "refractivity" : {
+ "depth" : 0,
+ "enabled" : false,
+ "strength" : 0.5
+ },
+ "shadow" : {
+ "kind" : "neutral",
+ "opacity" : 0.6
+ },
+ "specular" : true,
+ "translucency-specializations" : [
+ {
+ "value" : {
+ "enabled" : true,
+ "value" : 0.2
+ }
+ },
+ {
+ "appearance" : "dark",
+ "value" : {
+ "enabled" : true,
+ "value" : 0.2
+ }
+ }
+ ]
+ }
+ ],
+ "supported-platforms" : {
+ "squares" : "shared"
+ }
+} \ No newline at end of file
diff --git a/cmake/macos/apple_bundle.cmake b/cmake/macos/apple_bundle.cmake
new file mode 100644
index 000000000..6885950c4
--- /dev/null
+++ b/cmake/macos/apple_bundle.cmake
@@ -0,0 +1,166 @@
+# macOS .app bundle assembly for SpaghettiKart.
+#
+# Turns the plain executable into SpaghettiKart.app (MACOSX_BUNDLE), bundles the
+# runtime resources the game and the first-run ROM extractor need into
+# Contents/Resources, relinks any non-system dylibs into Contents/Frameworks,
+# and ad-hoc codesigns the result so it launches without "damaged app" warnings.
+#
+# Resource layout the runtime expects on macOS:
+# * Ship::Context::GetAppBundlePath() -> <App>.app/Contents/Resources (read-only)
+# - spaghetti.o2r : packed port assets (GenerateO2R target)
+# - config.yml, yamls/, meta/: asset definitions + mods.toml read by the
+# first-run ROM extractor (GameExtractor)
+# * Ship::Context::GetAppDirectoryPath() -> SHIP_HOME
+# (~/Library/Application Support/SpaghettiKart, defaulted in Game.cpp)
+# - mk64.o2r is extracted here on first run from the user's ROM, alongside
+# config/saves/mods, instead of scattering into the home folder
+
+set(MACOS_DIR ${CMAKE_SOURCE_DIR}/cmake/macos)
+set(ENTITLEMENTS_FILE ${MACOS_DIR}/entitlements.plist)
+
+option(SPAGHETTI_BUNDLE_DEPS "Relink and bundle dylibs into the .app so it is portable" ON)
+
+# ---------------------------------------------------------------------------
+# Bundle metadata. OUTPUT_NAME "SpaghettiKart" makes the bundle SpaghettiKart.app
+# with Contents/MacOS/SpaghettiKart, matching CFBundleExecutable in Info.plist
+# (the build target itself stays named "Spaghettify").
+# ---------------------------------------------------------------------------
+set_target_properties(${PROJECT_NAME} PROPERTIES
+ OUTPUT_NAME "SpaghettiKart"
+ MACOSX_BUNDLE TRUE
+ MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/Info.plist
+ XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "-"
+ XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${ENTITLEMENTS_FILE}
+)
+
+# ---------------------------------------------------------------------------
+# App icon.
+#
+# The repo ships the compiled icon artifacts (same approach as other ports that
+# ship a prebuilt Assets.car), so no special Xcode is needed at build time:
+# - Assets.car : compiled asset catalog with the layered Liquid Glass
+# icon, used on macOS 26+ via CFBundleIconName
+# ("SpaghettiKartIcon").
+# - SpaghettiKart.icns : flattened icon for macOS versions before 26, via
+# CFBundleIconFile.
+# Both are built from cmake/macos/SpaghettiKartIcon.icon (Icon Composer package;
+# the ship-kart art from icon.png with the subject separated from the background
+# so the system can render the glass depth).
+#
+# Staleness guard: if icon.png ever changes without the icon artifacts being
+# regenerated, the build detects it via the source hash stamp and falls back to
+# generating a flat icns from the CURRENT icon.png with sips/iconutil, so an
+# outdated icon can never ship and updating the logo requires no macOS work.
+#
+# To regenerate after a logo change (needs a Mac with Xcode 26+):
+# 1. Recreate the cut-out art layer and update SpaghettiKartIcon.icon in
+# Apple's Icon Composer.
+# 2. xcrun actool cmake/macos/SpaghettiKartIcon.icon --compile /tmp/iconout \
+# --app-icon SpaghettiKartIcon --output-partial-info-plist /tmp/iconout/p.plist \
+# --platform macosx --target-device mac --minimum-deployment-target 11.0
+# 3. cp /tmp/iconout/Assets.car cmake/macos/Assets.car
+# cp /tmp/iconout/SpaghettiKartIcon.icns cmake/macos/SpaghettiKart.icns
+# 4. shasum -a 256 icon.png > cmake/macos/SpaghettiKartIcon.icon.source-sha256
+# ---------------------------------------------------------------------------
+set(ICON_SOURCE_STAMP ${MACOS_DIR}/SpaghettiKartIcon.icon.source-sha256)
+set(ICON_PREBUILT_CAR ${MACOS_DIR}/Assets.car)
+set(ICON_PREBUILT_ICNS ${MACOS_DIR}/SpaghettiKart.icns)
+set(ICNS_FILE ${CMAKE_BINARY_DIR}/macosx/SpaghettiKart.icns)
+file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/macosx)
+
+set(ICON_SOURCE_FRESH FALSE)
+if(EXISTS ${ICON_SOURCE_STAMP} AND EXISTS ${ICON_PREBUILT_CAR} AND EXISTS ${ICON_PREBUILT_ICNS})
+ file(SHA256 ${CMAKE_SOURCE_DIR}/icon.png ICON_PNG_HASH)
+ file(READ ${ICON_SOURCE_STAMP} ICON_STAMP_HASH)
+ string(STRIP "${ICON_STAMP_HASH}" ICON_STAMP_HASH)
+ if(ICON_PNG_HASH STREQUAL ICON_STAMP_HASH)
+ set(ICON_SOURCE_FRESH TRUE)
+ else()
+ message(STATUS "App icon: icon.png changed since the icon artifacts were made; using a flat icon from the current icon.png")
+ endif()
+endif()
+
+if(ICON_SOURCE_FRESH)
+ message(STATUS "App icon: prebuilt Liquid Glass catalog + flattened icns fallback")
+ file(COPY_FILE ${ICON_PREBUILT_ICNS} ${ICNS_FILE})
+ file(COPY_FILE ${ICON_PREBUILT_CAR} ${CMAKE_BINARY_DIR}/macosx/Assets.car)
+ set(ICON_BUNDLE_FILES ${CMAKE_BINARY_DIR}/macosx/Assets.car ${ICNS_FILE})
+ set(SPAGHETTI_ICON_HAS_GLASS TRUE)
+else()
+ set(ICON_SRC ${CMAKE_SOURCE_DIR}/icon.png)
+ set(ICONSET_DIR ${CMAKE_BINARY_DIR}/macosx/SpaghettiKart.iconset)
+ file(MAKE_DIRECTORY ${ICONSET_DIR})
+ foreach(SPEC "16;icon_16x16" "32;icon_16x16@2x" "32;icon_32x32" "64;icon_32x32@2x"
+ "128;icon_128x128" "256;icon_128x128@2x" "256;icon_256x256"
+ "512;icon_256x256@2x" "512;icon_512x512" "1024;icon_512x512@2x")
+ list(GET SPEC 0 SZ)
+ list(GET SPEC 1 NAME)
+ execute_process(COMMAND sips -z ${SZ} ${SZ} ${ICON_SRC} --out ${ICONSET_DIR}/${NAME}.png OUTPUT_QUIET ERROR_QUIET)
+ endforeach()
+ execute_process(COMMAND iconutil -c icns -o ${ICNS_FILE} ${ICONSET_DIR})
+ set(ICON_BUNDLE_FILES ${ICNS_FILE})
+ set(SPAGHETTI_ICON_HAS_GLASS FALSE)
+endif()
+
+set_source_files_properties(${ICON_BUNDLE_FILES} PROPERTIES GENERATED TRUE MACOSX_PACKAGE_LOCATION "Resources")
+target_sources(${PROJECT_NAME} PRIVATE ${ICON_BUNDLE_FILES})
+
+if(NOT SPAGHETTI_ICON_HAS_GLASS)
+ # No asset catalog in this build: remove CFBundleIconName so macOS 26+
+ # falls back to CFBundleIconFile instead of showing a generic icon.
+ add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
+ COMMAND plutil -remove CFBundleIconName "$<TARGET_BUNDLE_DIR:${PROJECT_NAME}>/Contents/Info.plist" || true
+ VERBATIM
+ )
+endif()
+
+# Ensure the packed port assets (spaghetti.o2r) are generated before the app
+# links, so the POST_BUILD step below has them to copy into the bundle.
+# (mk64.o2r is created at runtime from the user's ROM into SHIP_HOME, so it is
+# intentionally not a build dependency.)
+add_dependencies(${PROJECT_NAME} GenerateO2R)
+
+# ---------------------------------------------------------------------------
+# Copy runtime resources into Contents/Resources after the app links.
+# ---------------------------------------------------------------------------
+set(RES_DIR "$<TARGET_BUNDLE_DIR:${PROJECT_NAME}>/Contents/Resources")
+add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E make_directory "${RES_DIR}"
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/config.yml" "${RES_DIR}/config.yml"
+ COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/yamls" "${RES_DIR}/yamls"
+ COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/meta" "${RES_DIR}/meta"
+ # spaghetti.o2r is produced by the GenerateO2R target; copy if present (CI
+ # builds it in a separate job and inserts it into the bundle afterwards).
+ COMMAND bash -c "[ -f '${CMAKE_BINARY_DIR}/spaghetti.o2r' ] && cp '${CMAKE_BINARY_DIR}/spaghetti.o2r' '${RES_DIR}/spaghetti.o2r' || ([ -f '${CMAKE_SOURCE_DIR}/spaghetti.o2r' ] && cp '${CMAKE_SOURCE_DIR}/spaghetti.o2r' '${RES_DIR}/spaghetti.o2r' || echo 'note: spaghetti.o2r not found - build the GenerateO2R target, then rebuild')"
+ COMMENT "Bundling SpaghettiKart resources into the .app"
+ VERBATIM
+)
+
+# ---------------------------------------------------------------------------
+# Relink dylibs into Contents/Frameworks (portable .app) and codesign.
+# With static vcpkg dependencies (CI) this is a near no-op; it matters for
+# local builds against Homebrew/MacPorts dylibs.
+# ---------------------------------------------------------------------------
+if (SPAGHETTI_BUNDLE_DEPS)
+ add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
+ COMMAND ${CMAKE_COMMAND}
+ -DAPP_BUNDLE=$<TARGET_BUNDLE_DIR:${PROJECT_NAME}>
+ -DEXECUTABLE_NAME=SpaghettiKart
+ -P ${MACOS_DIR}/fixup_bundle.cmake
+ COMMAND bash -c "install_name_tool -add_rpath '@executable_path/../Frameworks/' '$<TARGET_BUNDLE_DIR:${PROJECT_NAME}>/Contents/MacOS/SpaghettiKart' 2>/dev/null || true"
+ # Homebrew's "sdl2" is sdl2-compat, a shim that dlopen()s libSDL3.dylib
+ # from @loader_path at runtime. fixup_bundle can't follow a dlopen, so if
+ # a dynamic libSDL2 was bundled, copy SDL3 in next to it (= @loader_path)
+ # or the app aborts with "Failed loading SDL3 library." SDL3 itself only
+ # links system frameworks. Static SDL2 builds (vcpkg) skip this entirely.
+ COMMAND bash -c "FRAMEWORKS='$<TARGET_BUNDLE_DIR:${PROJECT_NAME}>/Contents/Frameworks'; if ls \"$FRAMEWORKS\"/libSDL2*.dylib >/dev/null 2>&1; then SDL3_LIB=$(brew --prefix sdl3 2>/dev/null)/lib/libSDL3.0.dylib; if [ -f \"$SDL3_LIB\" ]; then cp \"$SDL3_LIB\" \"$FRAMEWORKS/libSDL3.dylib\" && chmod u+w \"$FRAMEWORKS/libSDL3.dylib\"; else echo 'warning: bundled libSDL2 is sdl2-compat but Homebrew sdl3 was not found'; fi; fi"
+ COMMENT "Relinking dylibs into the .app bundle"
+ VERBATIM
+ )
+endif()
+
+add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
+ COMMAND codesign --force --deep --sign - --options runtime --entitlements ${ENTITLEMENTS_FILE} "$<TARGET_BUNDLE_DIR:${PROJECT_NAME}>"
+ COMMENT "Ad-hoc codesigning SpaghettiKart.app"
+ VERBATIM
+)
diff --git a/cmake/macos/entitlements.plist b/cmake/macos/entitlements.plist
new file mode 100644
index 000000000..4579570e5
--- /dev/null
+++ b/cmake/macos/entitlements.plist
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <!-- Allow the app to load the Homebrew/relinked dylibs bundled under
+ Contents/Frameworks when running with the hardened runtime. -->
+ <key>com.apple.security.cs.disable-library-validation</key>
+ <true/>
+</dict>
+</plist>
diff --git a/cmake/macos/fixup_bundle.cmake b/cmake/macos/fixup_bundle.cmake
new file mode 100644
index 000000000..10b82d939
--- /dev/null
+++ b/cmake/macos/fixup_bundle.cmake
@@ -0,0 +1,31 @@
+# Run at build time (cmake -P) to copy non-system dylibs the executable links
+# against into the .app's Contents/Frameworks and rewrite the load paths, making
+# the bundle self-contained / portable. Invoked from apple_bundle.cmake.
+#
+# Expects -D APP_BUNDLE=<abs path to SpaghettiKart.app> -D EXECUTABLE_NAME=SpaghettiKart
+
+include(BundleUtilities)
+
+set(APP "${APP_BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}")
+file(REAL_PATH "${APP}" APP)
+
+# Where fixup_bundle should look for the dependencies it needs to copy in.
+set(SEARCH_DIRS
+ "/opt/homebrew/lib" # Homebrew (Apple Silicon)
+ "/usr/local/lib" # Homebrew (Intel) / misc
+ "/opt/local/lib" # MacPorts
+ "${APP_BUNDLE}/Contents/Frameworks"
+)
+
+set(RESOLVED_DIRS "")
+foreach(DIR IN LISTS SEARCH_DIRS)
+ if (EXISTS "${DIR}")
+ file(REAL_PATH "${DIR}" RESOLVED_DIR)
+ list(APPEND RESOLVED_DIRS "${RESOLVED_DIR}")
+ endif()
+endforeach()
+
+message(STATUS "fixup_bundle: ${APP}")
+message(STATUS "fixup_bundle search dirs: ${RESOLVED_DIRS}")
+
+fixup_bundle("${APP}" "" "${RESOLVED_DIRS}")
diff --git a/src/port/Game.cpp b/src/port/Game.cpp
index 9804fe324..c1afe80b1 100644
--- a/src/port/Game.cpp
+++ b/src/port/Game.cpp
@@ -1035,6 +1035,14 @@ extern "C"
CFPreferencesSetAppValue(CFSTR("ApplePressAndHoldEnabled"), kCFBooleanFalse, kCFPreferencesCurrentApplication);
CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication);
#endif
+#if defined(__APPLE__) && !defined(PLATFORM_IOS)
+ // Default the writable data folder to ~/Library/Application Support/SpaghettiKart
+ // (libultraship expands the ~ and creates the directory). Without this, a Finder
+ // launch has no SHIP_HOME and libultraship falls back to the current working
+ // directory, scattering config/saves/mods into the user's home folder.
+ // overwrite=0 keeps any SHIP_HOME the user already set.
+ setenv("SHIP_HOME", "~/Library/Application Support/SpaghettiKart", 0);
+#endif
// load_wasm();
GameEngine::Create();
audio_init();