diff --git a/.github/workflows/cibuild.yml b/.github/workflows/cibuild.yml index 9d671267886..792b6fcaf2b 100644 --- a/.github/workflows/cibuild.yml +++ b/.github/workflows/cibuild.yml @@ -138,7 +138,7 @@ jobs: - name: Install macOS packages if: ${{ startsWith(matrix.platform.name, 'macOS') }} run: | - brew install ccache cmake sdl2 lzo libogg libvorbis theora openal-soft jpeg-turbo + brew install ccache cmake dylibbundler sdl2 lzo libogg libvorbis theora openal-soft jpeg-turbo - name: Install Fedora packages if: ${{ matrix.platform.name == 'Fedora' }} @@ -187,6 +187,13 @@ jobs: if: ${{ startsWith(matrix.platform.name, 'macOS') }} run: ccache -s || true + - name: Make macOS app bundle + if: ${{ startsWith(matrix.platform.name, 'macOS') && matrix.configuration == 'Release' && steps.cmake-build.outcome == 'success' }} + id: make-macos-app-bundle + run: | + bash misc/macos/make_app_bundle.sh "${{ matrix.platform.arch }}" "${{ matrix.configuration }}" + file build/artifacts/openxray*.* + # TODO: Merge this step with 'Make AppImage' once we switch to CMake 4.2 which directly supports AppImage generation with CPack # https://cmake.org/cmake/help/latest/cpack_gen/appimage.html - name: Make package @@ -209,7 +216,7 @@ jobs: file build/artifacts/openxray*.* - name: Upload OpenXRay artifact - if: ${{ steps.make-package.outcome == 'success' || steps.make-appimage.outcome == 'success' }} + if: ${{ steps.make-package.outcome == 'success' || steps.make-appimage.outcome == 'success' || steps.make-macos-app-bundle.outcome == 'success' }} uses: actions/upload-artifact@main with: name: ${{ matrix.platform.name }} ${{ matrix.configuration }} ${{ matrix.platform.arch }} (${{ matrix.platform.cc }} github-${{ github.run_number }}) diff --git a/cmake/XRay.Compiler.GNULike.cmake b/cmake/XRay.Compiler.GNULike.cmake index a9f5c0cdfca..471b4b3fb13 100644 --- a/cmake/XRay.Compiler.GNULike.cmake +++ b/cmake/XRay.Compiler.GNULike.cmake @@ -5,8 +5,28 @@ if (APPLE) if ($ENV{MACOSX_DEPLOYMENT_TARGET}) set(CMAKE_OSX_DEPLOYMENT_TARGET $ENV{MACOSX_DEPLOYMENT_TARGET}) else() - message(NOTICE "CMAKE_OSX_DEPLOYMENT_TARGET is not set, defaulting it to your system's version: ${CMAKE_SYSTEM_VERSION}") - set(CMAKE_OSX_DEPLOYMENT_TARGET ${CMAKE_SYSTEM_VERSION}) + execute_process( + COMMAND sw_vers -productVersion + OUTPUT_VARIABLE XRAY_MACOS_PRODUCT_VERSION + RESULT_VARIABLE XRAY_SW_VERS_RESULT + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) + + if (XRAY_SW_VERS_RESULT STREQUAL "0" AND XRAY_MACOS_PRODUCT_VERSION MATCHES "^([0-9]+)") + set(CMAKE_OSX_DEPLOYMENT_TARGET "${CMAKE_MATCH_1}.0") + message(NOTICE "CMAKE_OSX_DEPLOYMENT_TARGET is not set, defaulting it to macOS ${CMAKE_OSX_DEPLOYMENT_TARGET}") + elseif (CMAKE_SYSTEM_VERSION MATCHES "^([0-9]+)") + set(XRAY_DARWIN_VERSION_MAJOR "${CMAKE_MATCH_1}") + if (XRAY_DARWIN_VERSION_MAJOR GREATER_EQUAL 20) + math(EXPR XRAY_MACOS_VERSION_MAJOR "${XRAY_DARWIN_VERSION_MAJOR} - 9") + set(CMAKE_OSX_DEPLOYMENT_TARGET "${XRAY_MACOS_VERSION_MAJOR}.0") + message(NOTICE "CMAKE_OSX_DEPLOYMENT_TARGET is not set, defaulting it to macOS ${CMAKE_OSX_DEPLOYMENT_TARGET}") + else() + message(NOTICE "CMAKE_OSX_DEPLOYMENT_TARGET is not set, defaulting it to 10.15") + set(CMAKE_OSX_DEPLOYMENT_TARGET 10.15) + endif() + endif() endif() endif() message(STATUS "CMAKE_OSX_DEPLOYMENT_TARGET: ${CMAKE_OSX_DEPLOYMENT_TARGET}") diff --git a/misc/macos/make_app_bundle.sh b/misc/macos/make_app_bundle.sh new file mode 100755 index 00000000000..39eccdec3f5 --- /dev/null +++ b/misc/macos/make_app_bundle.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " + exit 1 +fi + +ARCH="$1" +CONFIGURATION="$2" +SKIP_DMG="${OPENXRAY_SKIP_DMG:-0}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +BIN_DIR="${ROOT_DIR}/bin/${ARCH}/${CONFIGURATION}" +ARTIFACTS_DIR="${ROOT_DIR}/build/artifacts" + +APP_DIR="${ARTIFACTS_DIR}/OpenXRay.app" +CONTENTS_DIR="${APP_DIR}/Contents" +MACOS_DIR="${CONTENTS_DIR}/MacOS" +LIBS_DIR="${CONTENTS_DIR}/libs" +RESOURCES_DIR="${CONTENTS_DIR}/Resources" +OXR_RES_DIR="${RESOURCES_DIR}/openxray" + +if [[ ! -x "${BIN_DIR}/xr_3da" ]]; then + echo "Cannot find executable: ${BIN_DIR}/xr_3da" + exit 1 +fi + +required_tools=(install_name_tool dylibbundler ditto) +if [[ "${SKIP_DMG}" != "1" ]]; then + required_tools+=(hdiutil) +fi + +for tool in "${required_tools[@]}"; do + if ! command -v "${tool}" >/dev/null 2>&1; then + echo "Required tool is missing: ${tool}" + exit 1 + fi +done + +mkdir -p "${ARTIFACTS_DIR}" +rm -rf "${APP_DIR}" +mkdir -p "${MACOS_DIR}" "${LIBS_DIR}" "${OXR_RES_DIR}" + +cat > "${CONTENTS_DIR}/Info.plist" <<'PLIST' + + + + + CFBundleName + OpenXRay + CFBundleDisplayName + OpenXRay + CFBundleIdentifier + org.openxray.xray-16 + CFBundlePackageType + APPL + CFBundleExecutable + xr_3da + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + LSApplicationCategoryType + public.app-category.games + NSHighResolutionCapable + + + +PLIST + +printf 'APPL????' > "${CONTENTS_DIR}/PkgInfo" + +cp "${BIN_DIR}/xr_3da" "${MACOS_DIR}/xr_3da" +chmod +x "${MACOS_DIR}/xr_3da" + +find "${BIN_DIR}" -maxdepth 1 -type f -name '*.dylib' -exec cp {} "${LIBS_DIR}/" \; + +# Bundle only open-source engine resources from this repository. +cp "${ROOT_DIR}/res/fsgame.ltx" "${OXR_RES_DIR}/fsgame.ltx" +cp -R "${ROOT_DIR}/res/gamedata" "${OXR_RES_DIR}/gamedata" + +# Bundle non-system dynamic libraries (Homebrew deps etc.). +dylibbundler \ + -of -cd -b \ + -x "${MACOS_DIR}/xr_3da" \ + -d "${LIBS_DIR}" \ + -s "${BIN_DIR}" \ + -s "${LIBS_DIR}" + +reset_rpaths() { + local binary="$1" + + while install_name_tool -delete_rpath "@executable_path/../libs" "${binary}" >/dev/null 2>&1; do + : + done + while install_name_tool -delete_rpath "@executable_path/../libs/" "${binary}" >/dev/null 2>&1; do + : + done + + install_name_tool -add_rpath "@executable_path/../libs" "${binary}" + codesign --force --deep --preserve-metadata=entitlements,requirements,flags,runtime --sign - "${binary}" >/dev/null +} + +# dylibbundler may leave duplicate LC_RPATH commands, which dyld rejects on newer macOS. +reset_rpaths "${MACOS_DIR}/xr_3da" +for lib in "${LIBS_DIR}"/*.dylib; do + [[ -e "${lib}" ]] || continue + reset_rpaths "${lib}" +done + +codesign --force --deep --sign - "${APP_DIR}" >/dev/null + +APP_ZIP="${ARTIFACTS_DIR}/openxray-${CONFIGURATION}-${ARCH}.app.zip" +DMG_PATH="${ARTIFACTS_DIR}/openxray-${CONFIGURATION}-${ARCH}.dmg" +DMG_ROOT="${ARTIFACTS_DIR}/dmg-root" + +rm -f "${APP_ZIP}" "${DMG_PATH}" +ditto -c -k --sequesterRsrc --keepParent "${APP_DIR}" "${APP_ZIP}" + +echo "Created:" +echo " ${APP_ZIP}" +if [[ "${SKIP_DMG}" == "1" ]]; then + echo "Skipped DMG creation (OPENXRAY_SKIP_DMG=1)" +else + rm -rf "${DMG_ROOT}" + mkdir -p "${DMG_ROOT}" + ditto "${APP_DIR}" "${DMG_ROOT}/OpenXRay.app" + ln -s /Applications "${DMG_ROOT}/Applications" + hdiutil create -volname "OpenXRay ${CONFIGURATION} ${ARCH}" -srcfolder "${DMG_ROOT}" -format UDZO -ov "${DMG_PATH}" + rm -rf "${DMG_ROOT}" + echo " ${DMG_PATH}" +fi diff --git a/src/Layers/xrRenderGL/glSH_Texture.cpp b/src/Layers/xrRenderGL/glSH_Texture.cpp index d7393f78ec7..f20ca081e52 100644 --- a/src/Layers/xrRenderGL/glSH_Texture.cpp +++ b/src/Layers/xrRenderGL/glSH_Texture.cpp @@ -14,6 +14,16 @@ namespace xray::render::RENDER_NAMESPACE { +namespace +{ +void ClearGLErrors() +{ + while (glGetError() != GL_NO_ERROR) + { + } +} +} // namespace + void resptrcode_texture::create(LPCSTR _name) { _set(RImplementation.Resources->_CreateTexture(_name)); @@ -196,6 +206,8 @@ void CTexture::Load() u32 _w = pTheora->Width(false); u32 _h = pTheora->Height(false); + ClearGLErrors(); + glGenBuffers(1, &pBuffer); glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pBuffer); CHK_GL(glBufferData(GL_PIXEL_UNPACK_BUFFER, flags.MemoryUsage, nullptr, GL_STREAM_DRAW)); @@ -203,7 +215,9 @@ void CTexture::Load() glGenTextures(1, &pTexture); glBindTexture(GL_TEXTURE_2D, pTexture); - CHK_GL(glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, _w, _h)); + CHK_GL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0)); + CHK_GL(glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0)); + CHK_GL(glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, _w, _h, 0, GL_BGRA, GL_UNSIGNED_BYTE, nullptr)); pSurface = pTexture; desc = GL_TEXTURE_2D; diff --git a/src/xrEngine/CMakeLists.txt b/src/xrEngine/CMakeLists.txt index 6ee391fa94c..540fa3dd9cc 100644 --- a/src/xrEngine/CMakeLists.txt +++ b/src/xrEngine/CMakeLists.txt @@ -407,6 +407,16 @@ target_sources(xrEngine TODO.txt ) +if (APPLE) + target_sources_grouped( + TARGET xrEngine + NAME "Platform\\macOS" + FILES + macos/GameDataResolver.cpp + macos/GameDataResolver.h + ) +endif() + target_include_directories(xrEngine PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" diff --git a/src/xrEngine/macos/GameDataResolver.cpp b/src/xrEngine/macos/GameDataResolver.cpp new file mode 100644 index 00000000000..6c19dfa40f6 --- /dev/null +++ b/src/xrEngine/macos/GameDataResolver.cpp @@ -0,0 +1,521 @@ +#include "stdafx.h" +#pragma hdrstop + +#if defined(XR_PLATFORM_APPLE) +#include "GameDataResolver.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr pcstr CompanyName = "GSC Game World"; +constexpr pcstr SavedPathFileName = "openxray_gamedata_path.txt"; +constexpr pcstr ChooseAnotherItem = "Choose another folder..."; +constexpr pcstr QuitItem = "Quit"; + +struct GameInfo +{ + std::string appSupportName; + std::string displayName; + std::vector steamNames; + std::vector gogNames; +}; + +bool HasCommandLineOption(pcstr commandLine, pcstr option) +{ + return commandLine && strstr(commandLine, option); +} + +std::string TrimLineEnd(std::string value) +{ + while (!value.empty() && (value.back() == '\n' || value.back() == '\r')) + value.pop_back(); + return value; +} + +std::string EnsureTrailingSlash(std::string path) +{ + if (!path.empty() && path.back() != '/') + path.push_back('/'); + return path; +} + +std::string RemoveTrailingSlash(std::string path) +{ + while (path.size() > 1 && path.back() == '/') + path.pop_back(); + return path; +} + +std::string JoinPath(const std::string& left, pcstr right) +{ + if (left.empty()) + return right ? right : ""; + + std::string result = left; + if (result.back() != '/') + result.push_back('/'); + result += right; + return result; +} + +std::string ExpandHomePath(pcstr suffix) +{ + const char* home = SDL_getenv("HOME"); + if (!home || !home[0]) + return {}; + + std::string path = home; + if (suffix && suffix[0]) + { + if (path.back() != '/' && suffix[0] != '/') + path.push_back('/'); + path += suffix; + } + return path; +} + +std::string NormalizeExistingPath(const std::string& path) +{ + char resolved[PATH_MAX]; + if (realpath(path.c_str(), resolved)) + return resolved; + return RemoveTrailingSlash(path); +} + +bool IsDirectory(const std::string& path) +{ + struct stat st; + return stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode); +} + +bool IsFile(const std::string& path) +{ + struct stat st; + return stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode); +} + +bool IsSymlink(const std::string& path) +{ + struct stat st; + return lstat(path.c_str(), &st) == 0 && S_ISLNK(st.st_mode); +} + +bool PathExistsNoFollow(const std::string& path) +{ + struct stat st; + return lstat(path.c_str(), &st) == 0; +} + +bool HasRequiredGameData(const std::string& root) +{ + if (root.empty()) + return false; + + return IsDirectory(JoinPath(root, "levels")) && + IsDirectory(JoinPath(root, "resources")) && + IsDirectory(JoinPath(root, "localization")); +} + +bool HasRuntimeLayout(const std::string& root) +{ + return HasRequiredGameData(root) && + IsFile(JoinPath(root, "fsgame.ltx")) && + IsDirectory(JoinPath(root, "gamedata")); +} + +GameInfo GetGameInfo(pcstr commandLine) +{ + if (HasCommandLineOption(commandLine, "-shoc") || HasCommandLineOption(commandLine, "-soc")) + { + return { + "S.T.A.L.K.E.R. - Shadow of Chernobyl", + "S.T.A.L.K.E.R.: Shadow of Chernobyl", + { "STALKER Shadow of Chernobyl", "Stalker Shadow of Chernobyl", "S.T.A.L.K.E.R. Shadow of Chernobyl" }, + { "S.T.A.L.K.E.R. - Shadow of Chernobyl" } + }; + } + + if (HasCommandLineOption(commandLine, "-cs")) + { + return { + "S.T.A.L.K.E.R. - Clear Sky", + "S.T.A.L.K.E.R.: Clear Sky", + { "STALKER Clear Sky", "Stalker Clear Sky", "S.T.A.L.K.E.R. Clear Sky" }, + { "S.T.A.L.K.E.R. - Clear Sky" } + }; + } + + return { + "S.T.A.L.K.E.R. - Call of Pripyat", + "S.T.A.L.K.E.R.: Call of Pripyat", + { "STALKER Call of Pripyat", "Stalker Call of Pripyat", "S.T.A.L.K.E.R. Call of Pripyat" }, + { "S.T.A.L.K.E.R. - Call of Pripyat" } + }; +} + +std::string GetPrefPath(const GameInfo& gameInfo) +{ + char* prefPath = SDL_GetPrefPath(CompanyName, gameInfo.appSupportName.c_str()); + if (!prefPath) + return {}; + + std::string result = EnsureTrailingSlash(prefPath); + SDL_free(prefPath); + return result; +} + +bool GetBundleResourcesRoot(std::string& resourcesRoot) +{ + char* basePathRaw = SDL_GetBasePath(); + if (!basePathRaw) + return false; + + std::string basePath = EnsureTrailingSlash(basePathRaw); + SDL_free(basePathRaw); + + std::string candidate = NormalizeExistingPath(JoinPath(basePath, "../Resources/openxray")); + if (!IsFile(JoinPath(candidate, "fsgame.ltx")) || !IsDirectory(JoinPath(candidate, "gamedata"))) + return false; + + resourcesRoot = candidate; + return true; +} + +std::string GetBundleNeighborRoot() +{ + char* basePathRaw = SDL_GetBasePath(); + if (!basePathRaw) + return {}; + + std::string basePath = EnsureTrailingSlash(basePathRaw); + SDL_free(basePathRaw); + return NormalizeExistingPath(JoinPath(basePath, "../../..")); +} + +std::string GetSavedRoot(const std::string& prefPath) +{ + const std::string pathFile = JoinPath(prefPath, SavedPathFileName); + FILE* file = fopen(pathFile.c_str(), "r"); + if (!file) + return {}; + + char buffer[PATH_MAX]; + const bool hasValue = fgets(buffer, sizeof(buffer), file) != nullptr; + fclose(file); + + if (!hasValue) + return {}; + + return RemoveTrailingSlash(TrimLineEnd(buffer)); +} + +void SaveRoot(const std::string& prefPath, const std::string& root) +{ + const std::string pathFile = JoinPath(prefPath, SavedPathFileName); + FILE* file = fopen(pathFile.c_str(), "w"); + if (!file) + return; + + fprintf(file, "%s\n", root.c_str()); + fclose(file); +} + +void AddCandidate(std::vector& candidates, const std::string& path) +{ + if (!HasRequiredGameData(path)) + return; + + const std::string normalized = NormalizeExistingPath(path); + for (const auto& candidate : candidates) + { + if (candidate == normalized) + return; + } + candidates.emplace_back(normalized); +} + +std::vector DiscoverCandidates(const GameInfo& gameInfo, const std::string& prefPath) +{ + std::vector candidates; + + AddCandidate(candidates, prefPath); + AddCandidate(candidates, ExpandHomePath(JoinPath(".local/share/GSC Game World", gameInfo.appSupportName.c_str()).c_str())); + + for (const auto& steamName : gameInfo.steamNames) + { + AddCandidate(candidates, ExpandHomePath(JoinPath("Library/Application Support/Steam/steamapps/common", steamName.c_str()).c_str())); + AddCandidate(candidates, ExpandHomePath(JoinPath(".local/share/Steam/steamapps/common", steamName.c_str()).c_str())); + AddCandidate(candidates, ExpandHomePath(JoinPath(".steam/steam/steamapps/common", steamName.c_str()).c_str())); + } + + for (const auto& gogName : gameInfo.gogNames) + { + AddCandidate(candidates, ExpandHomePath(JoinPath("GOG Games", gogName.c_str()).c_str())); + AddCandidate(candidates, ExpandHomePath(JoinPath("Applications", gogName.c_str()).c_str())); + AddCandidate(candidates, JoinPath("/Applications", gogName.c_str())); + } + + AddCandidate(candidates, GetBundleNeighborRoot()); + return candidates; +} + +std::string EscapeAppleScriptString(const std::string& value) +{ + std::string result; + result.reserve(value.size() + 2); + result.push_back('"'); + for (const char c : value) + { + if (c == '\\' || c == '"') + result.push_back('\\'); + if (c == '\n' || c == '\r') + result.push_back(' '); + else + result.push_back(c); + } + result.push_back('"'); + return result; +} + +bool RunAppleScript(const std::string& script, std::string& output) +{ + char scriptPath[] = "/tmp/openxray_osascript_XXXXXX"; + const int fd = mkstemp(scriptPath); + if (fd == -1) + return false; + + FILE* file = fdopen(fd, "w"); + if (!file) + { + close(fd); + xr_unlink(scriptPath); + return false; + } + + fwrite(script.data(), 1, script.size(), file); + fclose(file); + + const std::string command = std::string("/usr/bin/osascript ") + scriptPath + " 2>/dev/null"; + FILE* pipe = popen(command.c_str(), "r"); + if (!pipe) + { + xr_unlink(scriptPath); + return false; + } + + char buffer[1024]; + output.clear(); + while (fgets(buffer, sizeof(buffer), pipe)) + output += buffer; + + const int status = pclose(pipe); + xr_unlink(scriptPath); + output = TrimLineEnd(output); + return status == 0 && !output.empty(); +} + +void ShowAppleScriptAlert(const std::string& message) +{ + std::string ignored; + RunAppleScript( + "display alert \"OpenXRay\" message " + EscapeAppleScriptString(message) + " as warning\n", + ignored); +} + +bool ChooseFolder(const GameInfo& gameInfo, std::string& selectedRoot) +{ + const std::string prompt = "Select the " + gameInfo.displayName + + " directory that contains levels, resources, and localization."; + std::string output; + if (!RunAppleScript("POSIX path of (choose folder with prompt " + EscapeAppleScriptString(prompt) + ")\n", output)) + return false; + + selectedRoot = RemoveTrailingSlash(output); + return true; +} + +bool ChooseRootFromDialog(const GameInfo& gameInfo, const std::vector& candidates, std::string& selectedRoot) +{ + std::vector choices = candidates; + choices.emplace_back(ChooseAnotherItem); + choices.emplace_back(QuitItem); + + std::string choicesLiteral = "{"; + for (size_t i = 0; i < choices.size(); ++i) + { + if (i != 0) + choicesLiteral += ", "; + choicesLiteral += EscapeAppleScriptString(choices[i]); + } + choicesLiteral += "}"; + + const std::string defaultItem = candidates.empty() ? ChooseAnotherItem : candidates.front(); + const std::string prompt = candidates.empty() + ? "OpenXRay could not find game data automatically. Choose the " + gameInfo.displayName + " directory." + : "Choose the " + gameInfo.displayName + " game data directory."; + + const std::string script = + "set openxrayChoices to " + choicesLiteral + "\n" + "set openxraySelection to choose from list openxrayChoices with title \"OpenXRay\" with prompt " + + EscapeAppleScriptString(prompt) + " default items {" + EscapeAppleScriptString(defaultItem) + + "} OK button name \"Use Selected\" cancel button name \"Quit\"\n" + "if openxraySelection is false then\n" + " return " + EscapeAppleScriptString(QuitItem) + "\n" + "end if\n" + "return item 1 of openxraySelection\n"; + + std::string output; + if (!RunAppleScript(script, output) || output == QuitItem) + return false; + + if (output == ChooseAnotherItem) + return ChooseFolder(gameInfo, selectedRoot); + + selectedRoot = RemoveTrailingSlash(output); + return true; +} + +bool MoveExistingAside(const std::string& path) +{ + if (!PathExistsNoFollow(path)) + return true; + + for (u32 i = 0; i < 100; ++i) + { + std::string backupPath = path + ".openxray-backup"; + if (i != 0) + backupPath += std::to_string(i); + + if (PathExistsNoFollow(backupPath)) + continue; + + return rename(path.c_str(), backupPath.c_str()) == 0; + } + + return false; +} + +bool EnsureManagedSymlink(const std::string& source, const std::string& linkPath) +{ + if (source.empty() || linkPath.empty()) + return false; + + if (RemoveTrailingSlash(source) == RemoveTrailingSlash(linkPath)) + return true; + + if (IsSymlink(linkPath)) + xr_unlink(linkPath.c_str()); + else if (!MoveExistingAside(linkPath)) + return false; + + return symlink(source.c_str(), linkPath.c_str()) == 0; +} + +void LinkDirectoryIfPresent(const std::string& prefPath, const std::string& gameRoot, pcstr dirName) +{ + const std::string source = JoinPath(gameRoot, dirName); + if (!IsDirectory(source)) + return; + + const std::string linkPath = JoinPath(prefPath, dirName); + if (RemoveTrailingSlash(source) == RemoveTrailingSlash(linkPath)) + return; + + EnsureManagedSymlink(source, linkPath); +} + +bool ApplyRuntimeLayout(const std::string& prefPath, const std::string& bundleResourcesRoot, const std::string& gameRoot) +{ + if (!HasRequiredGameData(gameRoot)) + return false; + + EnsureManagedSymlink(JoinPath(bundleResourcesRoot, "fsgame.ltx"), JoinPath(prefPath, "fsgame.ltx")); + EnsureManagedSymlink(JoinPath(bundleResourcesRoot, "gamedata"), JoinPath(prefPath, "gamedata")); + + LinkDirectoryIfPresent(prefPath, gameRoot, "levels"); + LinkDirectoryIfPresent(prefPath, gameRoot, "resources"); + LinkDirectoryIfPresent(prefPath, gameRoot, "localization"); + LinkDirectoryIfPresent(prefPath, gameRoot, "mp"); + LinkDirectoryIfPresent(prefPath, gameRoot, "patches"); + + return HasRuntimeLayout(prefPath); +} +} // namespace + +void ResolveMacOSGameDataPath(pcstr commandLine) +{ + if (HasCommandLineOption(commandLine, "-fsltx ")) + return; + + std::string bundleResourcesRoot; + if (!GetBundleResourcesRoot(bundleResourcesRoot)) + return; + + const GameInfo gameInfo = GetGameInfo(commandLine); + const std::string prefPath = GetPrefPath(gameInfo); + if (prefPath.empty()) + return; + + const bool forceSelection = HasCommandLineOption(commandLine, "-select_gamedata") || + HasCommandLineOption(commandLine, "-reset_gamedata_path"); + + const std::string savedRoot = GetSavedRoot(prefPath); + if (!forceSelection && HasRequiredGameData(savedRoot) && ApplyRuntimeLayout(prefPath, bundleResourcesRoot, savedRoot)) + return; + + const std::vector candidates = DiscoverCandidates(gameInfo, prefPath); + std::string selectedRoot; + + while (ChooseRootFromDialog(gameInfo, candidates, selectedRoot)) + { + if (!HasRequiredGameData(selectedRoot)) + { + ShowAppleScriptAlert( + "The selected directory does not contain levels, resources, and localization. " + "Please choose the root directory of a licensed game installation."); + continue; + } + + if (ApplyRuntimeLayout(prefPath, bundleResourcesRoot, selectedRoot)) + { + SaveRoot(prefPath, NormalizeExistingPath(selectedRoot)); + return; + } + + ShowAppleScriptAlert( + "OpenXRay could not prepare the selected directory in Application Support. " + "Check file permissions and try again."); + } + + if (HasRuntimeLayout(prefPath)) + return; + + if (!HasRequiredGameData(prefPath)) + { + SDL_ShowSimpleMessageBox( + SDL_MESSAGEBOX_WARNING, + "OpenXRay: game files are required", + "OpenXRay could not find required game files.\n" + "Choose a directory that contains levels, resources, and localization.", + nullptr); + } + else + { + SDL_ShowSimpleMessageBox( + SDL_MESSAGEBOX_WARNING, + "OpenXRay: setup is incomplete", + "OpenXRay could not prepare bundled engine resources in Application Support.", + nullptr); + } + + std::exit(EXIT_SUCCESS); +} +#endif diff --git a/src/xrEngine/macos/GameDataResolver.h b/src/xrEngine/macos/GameDataResolver.h new file mode 100644 index 00000000000..b3147939cd3 --- /dev/null +++ b/src/xrEngine/macos/GameDataResolver.h @@ -0,0 +1,5 @@ +#pragma once + +#if defined(XR_PLATFORM_APPLE) +void ResolveMacOSGameDataPath(pcstr commandLine); +#endif diff --git a/src/xrEngine/x_ray.cpp b/src/xrEngine/x_ray.cpp index 20d2e576e1b..6e0fd268fde 100644 --- a/src/xrEngine/x_ray.cpp +++ b/src/xrEngine/x_ray.cpp @@ -18,6 +18,10 @@ #include "LightAnimLibrary.h" #include "XR_IOConsole.h" +#if defined(XR_PLATFORM_APPLE) +#include "macos/GameDataResolver.h" +#endif + #if defined(XR_PLATFORM_WINDOWS) #include "AccessibilityShortcuts.hpp" #include "Text_Console.h" @@ -253,6 +257,10 @@ CApplication::CApplication(pcstr commandLine, GameModule* game, const std::array sscanf(strstr(commandLine, fsltx) + sz, "%[^ ] ", fsgame); } +#if defined(XR_PLATFORM_APPLE) + ResolveMacOSGameDataPath(commandLine); +#endif + Core.Initialize("OpenXRay", commandLine, true, *fsgame ? fsgame : nullptr); InitSettings(); diff --git a/src/xrGame/alife_object.cpp b/src/xrGame/alife_object.cpp index 8136c96a03e..971bf60b0bb 100644 --- a/src/xrGame/alife_object.cpp +++ b/src/xrGame/alife_object.cpp @@ -82,7 +82,7 @@ void CSE_ALifeObject::spawn_supplies(LPCSTR ini_string) n = _GetItemCount(V); if (n > 0) { - string64 tmp; + xr_string tmp; spawnCount = atoi(_GetItem(V, 0, tmp)); //count } @@ -114,7 +114,7 @@ void CSE_ALifeObject::spawn_supplies(LPCSTR ini_string) { pcstr ammo_class = pSettings->r_string(itmSection, "ammo_class"); pcstr ammoSec = ""; - string128 tmp; + xr_string tmp; for (int i = 0, n = _GetItemCount(ammo_class); i < n; ++i) { ammoSec = _GetItem(ammo_class, i, tmp); @@ -164,7 +164,7 @@ void CSE_ALifeObject::spawn_supplies(LPCSTR ini_string) if (V && xr_strlen(V)) { - string64 buf; + xr_string buf; j = atoi(_GetItem(V, 0, buf)); if (!j) j = 1;