A working document for the macOS port of Path of Building. Not upstream documentation. For upstream-worthy bugs and their fixes, see UPSTREAM_NOTES.md.
PathOfBuilding is ~95% pure Lua running inside SimpleGraphic, a C++ host library (libSimpleGraphic.dylib on macOS) that provides windowing, OpenGL ES rendering, input, file I/O, and exposes a ~70-function API to Lua via sol2. The Lua side is already cross-platform; essentially all the porting work is in SimpleGraphic and a small wrapper around it.
This is a three-repo project:
PathOfBuilding-Mac ← this repo (the wrapper)
├── CMakeLists.txt top-level build — orchestrates SG + launcher + bundle
├── macos/
│ ├── launcher.cpp the .app's main binary (~340 lines, C++17 + std::filesystem)
│ ├── mac_entry.lua Lua bootstrap — patches PoB's in-app updater before Launch.lua
│ ├── Info.plist.in bundle Info.plist template
│ ├── AppIcon.icns app icon
│ ├── entitlements.plist hardened-runtime entitlements (allow-jit + dylib loading)
│ ├── sign.sh codesign dylibs → bundle with entitlements
│ └── make-dmg.sh hdiutil DMG packaging
├── SimpleGraphic/ ← submodule: stevschmid/PathOfBuilding-SimpleGraphic @ macos-port
│ our fork; all engine-side porting work lives here
└── PathOfBuilding/ ← submodule: PathOfBuildingCommunity/PathOfBuilding @ v2.63.0
upstream PoB Lua app; we never patch this
The wrapper's top-level CMakeLists.txt does:
add_subdirectory(SimpleGraphic)→ buildslibSimpleGraphic.dylib+ four helper Lua modules (lcurl.dylib,lua-utf8.dylib,socket.dylib,lzip.dylib)- Adds a
PathOfBuildingexecutable target (frommacos/launcher.cpp), configured as aMACOSX_BUNDLEtarget - Install rules that deposit everything into a
Path of Building.app/Contents/layout and pull the PoB Lua tree out of thePathOfBuilding/submodule intoContents/Resources/src - A configure-time transform of upstream's
manifest.xml(stripsruntime="win32"entries, addsbranch="master" platform="macos"to<Version>)
Path of Building.app/
├── Contents/
│ ├── Info.plist CFBundleIdentifier = ch.spidy.PathOfBuildingMac
│ ├── MacOS/
│ │ ├── Path of Building our launcher (launcher.cpp)
│ │ ├── libSimpleGraphic.dylib SG engine
│ │ ├── libEGL.dylib / libGLESv2.dylib ANGLE w/ Metal backend
│ │ └── lcurl lua-utf8 socket lzip Lua helper modules (.dylib)
│ ├── Resources/
│ │ ├── AppIcon.icns
│ │ ├── SimpleGraphic/Fonts/ bitmap fonts
│ │ ├── lua/ runtime/lua from PoB (dkjson etc.)
│ │ └── src/ bundled PoB Lua tree — "factory default"
│ │ ├── Launch.lua upstream entry
│ │ ├── mac_entry.lua our bootstrap (not in any manifest)
│ │ ├── manifest.xml generated at configure time from upstream
│ │ ├── changelog.txt moved from PoB repo root (see below)
│ │ ├── help.txt ↑
│ │ ├── LICENSE.md ↑
│ │ └── Modules/ Classes/ Data/ TreeData/ …
│ └── _CodeSignature/CodeResources sealed manifest from `codesign`
Contents/ is completely read-only after signing. All mutable state goes elsewhere — see next section.
Two App Support directories, deliberately separate:
~/Library/Application Support/
├── Path of Building/ PoB's own user data, unchanged from upstream
│ ├── Settings.xml window prefs, recent builds, UI scale, cloud provider
│ └── Builds/ user's saved builds
│
└── PathOfBuildingMac/ our wrapper runtime state
├── .bundle_version refresh-detection marker (compared to compile-time constant)
├── src/ relocated Lua tree — this is PoB's live working directory
│ └── … updater writes here, cfg files resolve here
├── SimpleGraphic/
│ ├── SimpleGraphic.cfg SG's window state
│ └── SimpleGraphicAuto.cfg
└── imgui.ini Dear ImGui window state
Path of Building/is what PoB'sMain.luawrites viaGetUserPath() .. "Path of Building/"— the user-facing data dir. We don't touch it, and if an official PoB mac build ever ships, its user data can coexist here without colliding.PathOfBuildingMac/is ours. Wrapper-specific runtime state that the user never has to care about — if they delete it, the launcher rebuilds it from the bundle on next launch.
macos/launcher.cpp is the binary inside Contents/MacOS/Path of Building. C++17, ~340 lines, uses std::filesystem for the relocation logic. Deliberately not Objective-C so we don't add a new language to the SG+PoB project surface.
What it does, in order:
- Resolve its own directory via
_NSGetExecutablePath+realpath. - Pre-load ANGLE (
libGLESv2.dylib, thenlibEGL.dylib) via absolute-pathdlopen, so GLFW's laterdlopen("libEGL.dylib", ...)by bare leaf name finds the already-loaded image. dyld does not search LC_RPATH or@loader_pathfor bare-name dlopen, so this is the cleanest way to make GLFW find ANGLE without pollutingDYLD_LIBRARY_PATH. - Load
libSimpleGraphic.dylibfrom the same directory anddlsymthe single exportRunLuaFileAsWin. - Detect bundle vs. dev mode by checking if
<exeDir>/../Resources/src/Launch.luaexists. In bundle mode the launcher ignores argv entirely and auto-discovers paths; in dev mode argv[1] is a path to a Lua script and paths are derived relative to it (matches PR #98'slinux/launcher.cpattern). - Relocate
src/into App Support (bundle mode only) — see next section. - Set environment variables for SG:
SG_BASE_PATH(always points at bundleContents/Resources— fonts and runtime lua stay in the bundle as read-only data),POB_MAC_USER_DIR(points atPathOfBuildingMac/),LUA_PATH,LUA_CPATH. - Call
RunLuaFileAsWin(1, { scriptPath, NULL })wherescriptPathis<AppSupport>/PathOfBuildingMac/src/mac_entry.lua. SG then sets its internalscriptPathto the parent directory and everything downstream runs out of App Support.
CMAKE_CXX_STANDARD 17 is set at wrapper scope in the top-level CMakeLists.txt because std::filesystem and std::optional need it.
Every bundle-mode launch calls RelocateRuntime(bundleSrc):
- Compute
~/Library/Application Support/PathOfBuildingMac/(via$HOMEwithgetpwuidfallback — same pattern as SG'ssys_main_c::FindUserPath). - Read the marker
PathOfBuildingMac/.bundle_version. Compare string-equality againstPOB_BUNDLE_VERSION_STRING(a compile-time constant set from thePOB_BUNDLE_VERSIONCMake variable). - If marker matches: no-op for
src/. The extracted tree is up-to-date for this DMG, and any in-app updater state accumulated since is preserved. - If marker is missing or stale:
fs::remove_all(appSrc)thenfs::copy(bundleSrc, appSrc, fs::copy_options::recursive), then write the new marker. Logged to stderr asRelocateRuntime: bundle version changed (marker=X, bundle=Y), refreshing …. - Always overwrite
PathOfBuildingMac/src/mac_entry.luafrom the bundle. It's our bootstrap file, PoB's updater never touches it (excluded from the manifest), and keeping it always-fresh lets dev iteration onmac_entry.luatake effect on next launch without needing aPOB_BUNDLE_VERSIONbump.
PoB on Windows writes updater-fetched Lua files directly into its install tree, because on Windows the install dir is user-writable and there's no sealed manifest. On macOS:
/Applications/Path of Building.appis not user-writable without admin (the updater wouldEACCES)- A signed bundle's
_CodeSignature/CodeResourcesseals everything insideContents/; any write invalidatescodesign --verify --deep --strict
Option B splits the tree: the bundle ships a read-only "factory default" Lua tree, and src/ lives for real at a user-writable App Support location that the updater can freely modify. The signed bundle is never touched after install.
- User installs v0.1 of our DMG (bundled PoB 2.63.0):
.bundle_versionmissing → refresh →src/= PoB 2.63.0, marker =2.63.0
- User runs PoB, in-app updater pulls PoB 2.64.0:
- Updater writes to
PathOfBuildingMac/src/directly. Marker unchanged.
- Updater writes to
- User relaunches v0.1:
- Marker
2.63.0matches bundle version → no-op, preserves in-app 2.64.0
- Marker
- User downloads v0.2 DMG (bundled PoB 2.65.0), replaces
/Applications/Path of Building.app:- Marker
2.63.0≠ bundle2.65.0→ refresh fires src/wiped, re-populated from bundle 2.65.0. In-app-updated 2.64.0 state discarded.- A new DMG is an explicit "reset to this baseline" operation — that's the intended semantics.
- Marker
- User in-app updates to 2.66.0, etc. — cycle repeats.
Verified by pinning the PoB submodule to v2.60.0, bumping POB_BUNDLE_VERSION to match, building, installing, launching, letting the in-app updater run to master, and confirming: src/ content updated to 2.63.0, bundle untouched (1135 files still, same timestamps), codesign --verify --deep --strict still exit 0.
Three files that SG writes at runtime — SimpleGraphic/SimpleGraphic.cfg, SimpleGraphic/SimpleGraphicAuto.cfg, imgui.ini — are specified in SG's code as bare cwd-relative paths. ui_main_c::PCall flips cwd between scriptWorkDir (during Lua) and basePath (between Lua calls), so at save time cwd is basePath and the files land inside Contents/Resources/. See UPSTREAM_NOTES.md — SimpleGraphic writes its own config files to basePath instead of userPath for the full analysis.
Our wrapper patches SG to read an env var POB_MAC_USER_DIR:
ui_main.cpp: file-statics_sgCfgBaseresolved once inInit, used in both Init (load) and Shutdown (save)r_main.cpp: right afterImGui::CreateContext(), setsio.IniFilenameto an absolute path under$POB_MAC_USER_DIR/imgui.ini
When the env var is unset (dev mode, non-macOS, or anyone using SG without our launcher), both files fall back to sys->basePath and preserve the legacy behavior. The patch is ~15 lines total with no header changes, specifically shaped for merge-friendliness with upstream SG.
PoB's src/Modules/Main.lua:1185 reads changelog.txt / help.txt / LICENSE.md relative to cwd (= src/) in installed mode:
local changelogName = launch.devMode and "../changelog.txt" or "changelog.txt"These files live at the PoB repo root, not in src/. The Windows installer moves them into src/ as a packaging step. Our CMakeLists replicates that move:
install(FILES
"${POB_LUA_TREE}/changelog.txt"
"${POB_LUA_TREE}/help.txt"
"${POB_LUA_TREE}/LICENSE.md"
DESTINATION "${POB_RES_DEST}/src"
)Without this, PoB's updater reports "Update available" on first launch because UpdateCheck.lua sees part="default" files as missing (their fullPath = scriptPath / name lookups in src/ come up empty).
PoB's manifest.xml is generated on a Windows build machine where text files have CRLF line endings, so its sha1="..." attributes are hashes of CRLF content. On a mac install those files have LF endings. The shas don't match directly:
| File | LF sha (what's on disk on mac) | Manifest claims (CRLF) |
|---|---|---|
changelog.txt |
f5aa2be6… |
1f9deab0… |
help.txt |
b1287f42… |
e486cb63… |
LICENSE.md |
be578b61… |
c77635aa… |
UpdateCheck.lua:191/257 handles it:
if data.sha1 ~= sha1(content) and data.sha1 ~= sha1(content:gsub("\n", "\r\n")) thenIt tries both LF and LF→CRLF hashes. That's a built-in cross-platform tolerance that's been in PoB since before our port. It's why shipping our LF-ending files against the bundled CRLF-sha manifest works — the updater silently accepts the CRLF match and moves on. This only kicks in when the file exists, though, which is why shipping them at all is necessary.
Upstream's manifest.xml has Windows-specific entries (runtime="win32") and a <Version> element with only number="...". At CMake configure time we transform it:
- Strip every line matching
runtime="win32"(prevents the updater from trying to download.dll/.exe). - Rewrite
<Version number="..." />→<Version number="..." branch="master" platform="macos" />soLaunch.luarecognizes installed mode and uses the correct source URLs. - Write the result to
${CMAKE_BINARY_DIR}/manifest.xml, which the install rules deposit intoContents/Resources/src/manifest.xml.
CMAKE_CONFIGURE_DEPENDS on ${POB_LUA_TREE}/manifest.xml ensures the transform re-runs when the PoB submodule bumps.
macos/mac_entry.lua applies the same filter at runtime for defensive reasons — when the in-app updater fetches a fresh manifest from master, it needs to be filtered before UpdateCheck.lua starts iterating.
com.apple.security.cs.allow-jit— required so LuaJIT'smmap(MAP_JIT)calls succeed under hardened runtime. Without it, JIT traces fall back to the interpreter and passive-tree clicks stall for ~1.5s each.com.apple.security.cs.allow-unsigned-executable-memory— companion toallow-jitfor the executable pages LuaJIT writes.com.apple.security.cs.disable-library-validation— required because our launcher explicitlydlopenslibEGL.dylibandlibGLESv2.dylib. Under ad-hoc signing this trips the "different Team IDs" library-validation check. Under Developer ID signing it would pass normally, but keeping the entitlement consistent across both ad-hoc and release builds simplifies the signing pipeline. Notarization accepts this entitlement for apps that dlopen their own bundled dylibs.
- Sign every
.dylibinContents/MacOS/first (leaf-first). - Sign the bundle root with
--entitlements macos/entitlements.plist --options runtime. Signing a.appbundle is signing its main executable (codesign resolvesContents/MacOS/<CFBundleExecutable>from Info.plist), so the entitlements go on this step. codesign --verify --deep --strict --verbose=2at the end.
CODESIGN_IDENTITY env var controls the identity, defaulting to - (ad-hoc). Set it to "Developer ID Application: ... (TEAMID)" for release signing. Same script works for both.
Plain hdiutil create -format UDZO. No create-dmg brew dependency. Refuses to package an unsigned bundle as a safety check. Output is a .dmg containing the .app plus a symlink to /Applications for the drag-install UX.
cmake -B build
cmake --build build
cmake --install build --prefix /tmp/pob-install-wrapper
macos/sign.sh # ad-hoc for local iteration
open "/tmp/pob-install-wrapper/Path of Building.app"
For a release build: set CODESIGN_IDENTITY before sign.sh, then run make-dmg.sh against the signed bundle, then notarize+staple (pipeline pending — see below).
All documented in UPSTREAM_NOTES.md with file/line/fix detail. Short list:
- LuaJIT version bump — critical Apple Silicon mcode allocator fix. 100× click-latency improvement.
- LuaJIT portfile hardcoded
x64-linux-*build subdir. - lcurl
luaL_setfuncsduplicate symbol — per-source rename replacing--allow-multiple-definition(which is GNU-ld-only). - SG
SIMPLEGRAPHIC_PLATFORM_SOURCESclobbered on Apple (set()instead oflist(APPEND)). r_font.cpptext visibility cull uses logical window size instead of framebuffer size on HiDPI.sys_video.cppGLFW cursor in logical points vs. physical pixels on macOS Retina.- Launcher
dirname()POSIX static-buffer aliasing. $<TARGET_RUNTIME_DLLS:>empty on macOS — shared imported targets not captured.- ANGLE
liblibEGL.dylibdouble-lib-prefix on macOS. UpdateCheck.luaMakeDir POSIX-absolute-path loop bug — the hanging updater on first fresh install.- SG config files (
SimpleGraphic.cfg/imgui.ini) write tobasePathinstead ofuserPath.
All eleven are things that belong upstream — independent of our wrapper-specific Option B architecture.
- Apple Developer Program reactivation (user has an inactive account — low effort)
- CI build workflow (
macos-14arm64, builds on push/PR, uploads.dmgas artifact) macos/notarize.sh(xcrun notarytool submit --wait+xcrun stapler staple)- CI release workflow (tag-triggered
mac-v*: real sign + notarize + GitHub Release) - Wrapper
README.md+ install docs with "unofficial port" disclaimer - First
mac-v0.1.0tag
- PathOfBuildingCommunity/PathOfBuilding-SimpleGraphic#98 — native Linux port by
velomeister. Does ~80% of the POSIX work we built on top of. Our SG submodule rebases our macOS changes onto this branch. hsource/pobfrontend— unmaintained alternative macOS port. Uses Qt + a custom Lua frontend rather than porting SimpleGraphic. Different approach entirely; not a useful base for our work.