From a55726b7ca55c40a2a9d7dc06e2eceac25a8e33c Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Fri, 11 Sep 2026 12:56:23 -0400 Subject: [PATCH 1/6] foundation: pin upstream and add validation core --- .gitignore | 69 ++++++ CMakeLists.txt | 108 +++++++++ CMakePresets.json | 38 ++++ UPSTREAM.lock | 6 + config/game_versions/gta4_x360_supported.json | 29 +++ docs/UPSTREAM.md | 54 +++++ include/liberty/content_provider.hpp | 49 +++++ scripts/check-prohibited-assets.py | 71 ++++++ scripts/ci-macos-reference.sh | 27 +++ scripts/fetch-upstream.sh | 85 ++++++++ scripts/verify-game.py | 205 ++++++++++++++++++ src/foundation/content_provider.cpp | 53 +++++ tests/content_provider_tests.cpp | 50 +++++ tests/test_prohibited_assets.py | 41 ++++ tests/test_verify_game.py | 75 +++++++ 15 files changed, 960 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 CMakePresets.json create mode 100644 UPSTREAM.lock create mode 100644 config/game_versions/gta4_x360_supported.json create mode 100644 docs/UPSTREAM.md create mode 100644 include/liberty/content_provider.hpp create mode 100755 scripts/check-prohibited-assets.py create mode 100755 scripts/ci-macos-reference.sh create mode 100755 scripts/fetch-upstream.sh create mode 100755 scripts/verify-game.py create mode 100644 src/foundation/content_provider.cpp create mode 100644 tests/content_provider_tests.cpp create mode 100644 tests/test_prohibited_assets.py create mode 100644 tests/test_verify_game.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b94a56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# Build systems +/build/ +/out/ +/cmake-build-*/ +CMakeUserPresets.json +compile_commands.json + +# Apple/Xcode +DerivedData/ +*.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ +*.xcuserstate +*.xccheckout +*.moved-aside +*.ipa +*.dSYM +*.dSYM.zip + +# Signing/provisioning material +*.mobileprovision +*.p12 +*.pfx +*.cer +*.key +*.pem +*.der + +# Private GTA IV payloads / extracted media +default.xex +*.xex +*.rpf +*.iso +*.xiso +*.stfs +*.live +*.con +aes_key.bin +game/ +Game/ +Game.staging/ +Game.previous/ +local_game_payload/ +tools/local_game_payload/ +private_game/ +game_payload/ + +# Upstream checkout/cache +.cache/ +third_party/LibertyRecomp/ +vendor/LibertyRecomp/ + +# Runtime caches +Cache/ +Caches/ +shader-cache/ +pipeline-cache/ + +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +venv/ + +# OS/editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..c79ba9b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,108 @@ +cmake_minimum_required(VERSION 3.25) + +project(LibertyRecompIOSFoundation VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +option(LIBERTY_BUILD_TESTS "Build portable Foundation tests" ON) +option(LIBERTY_IOS_SHELL_ONLY "Build the iOS platform shell without starting GTA IV" ON) +set(LIBERTY_IOS_CONTENT_MODE "imported" CACHE STRING "iOS game content mode: imported or embedded") +set_property(CACHE LIBERTY_IOS_CONTENT_MODE PROPERTY STRINGS imported embedded) +option(LIBERTY_IOS_GAMECENTER "Enable Game Center integration" OFF) +option(LIBERTY_IOS_DIAGNOSTICS "Enable iOS diagnostics" ON) +option(LIBERTY_IOS_ALLOW_UNPINNED_CONTENT "Development only: permit unknown XEX fingerprints" OFF) +set(LIBERTY_IOS_DEVELOPMENT_TEAM "" CACHE STRING "Apple Developer Team ID for device signing") +set(LIBERTY_IOS_BUNDLE_IDENTIFIER "com.libertyrecomp.ios" CACHE STRING "iOS bundle identifier") +set(LIBERTY_UPSTREAM_DIR "" CACHE PATH "Optional checkout of the pinned LibertyRecomp source") +option(LIBERTY_ENABLE_UPSTREAM_REFERENCE "Add the pinned upstream source as an EXCLUDE_FROM_ALL subdirectory" OFF) + +if(NOT LIBERTY_IOS_CONTENT_MODE STREQUAL "imported" AND NOT LIBERTY_IOS_CONTENT_MODE STREQUAL "embedded") + message(FATAL_ERROR "LIBERTY_IOS_CONTENT_MODE must be 'imported' or 'embedded'") +endif() + +file(READ "${CMAKE_SOURCE_DIR}/UPSTREAM.lock" LIBERTY_UPSTREAM_LOCK_JSON) +string(JSON LIBERTY_UPSTREAM_REPOSITORY GET "${LIBERTY_UPSTREAM_LOCK_JSON}" repository) +string(JSON LIBERTY_UPSTREAM_COMMIT GET "${LIBERTY_UPSTREAM_LOCK_JSON}" commit) + +execute_process(COMMAND git rev-parse --short=12 HEAD WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" OUTPUT_VARIABLE LIBERTY_PROJECT_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET RESULT_VARIABLE LIBERTY_GIT_RESULT) +if(NOT LIBERTY_GIT_RESULT EQUAL 0 OR LIBERTY_PROJECT_COMMIT STREQUAL "") + set(LIBERTY_PROJECT_COMMIT "unknown") +endif() +message(STATUS "LibertyRecomp-iOS commit: ${LIBERTY_PROJECT_COMMIT}") +message(STATUS "Pinned LibertyRecomp commit: ${LIBERTY_UPSTREAM_COMMIT}") + +add_library(liberty_foundation STATIC src/foundation/content_provider.cpp) +target_include_directories(liberty_foundation PUBLIC "${CMAKE_SOURCE_DIR}/include") +target_compile_features(liberty_foundation PUBLIC cxx_std_20) + +if(LIBERTY_BUILD_TESTS) + enable_testing() + add_executable(content_provider_tests tests/content_provider_tests.cpp) + target_link_libraries(content_provider_tests PRIVATE liberty_foundation) + add_test(NAME content_provider_tests COMMAND content_provider_tests) +endif() + +if(LIBERTY_ENABLE_UPSTREAM_REFERENCE) + if(NOT LIBERTY_UPSTREAM_DIR) + message(FATAL_ERROR "LIBERTY_ENABLE_UPSTREAM_REFERENCE=ON requires LIBERTY_UPSTREAM_DIR") + endif() + if(NOT EXISTS "${LIBERTY_UPSTREAM_DIR}/CMakeLists.txt") + message(FATAL_ERROR "Pinned LibertyRecomp source not found at ${LIBERTY_UPSTREAM_DIR}") + endif() + execute_process(COMMAND git rev-parse HEAD WORKING_DIRECTORY "${LIBERTY_UPSTREAM_DIR}" OUTPUT_VARIABLE LIBERTY_UPSTREAM_ACTUAL_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + if(NOT LIBERTY_UPSTREAM_ACTUAL_COMMIT STREQUAL LIBERTY_UPSTREAM_COMMIT) + message(FATAL_ERROR "Upstream checkout is not pinned correctly. Expected ${LIBERTY_UPSTREAM_COMMIT}, found ${LIBERTY_UPSTREAM_ACTUAL_COMMIT}") + endif() + add_subdirectory("${LIBERTY_UPSTREAM_DIR}" "${CMAKE_BINARY_DIR}/upstream" EXCLUDE_FROM_ALL) +endif() + +if(CMAKE_SYSTEM_NAME STREQUAL "iOS") + enable_language(OBJC) + enable_language(OBJCXX) + set(LIBERTY_GAME_MANIFEST "${CMAKE_SOURCE_DIR}/config/game_versions/gta4_x360_supported.json") + set_source_files_properties("${LIBERTY_GAME_MANIFEST}" PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") + add_executable(LibertyRecompIOS MACOSX_BUNDLE src/ios/main.mm "${LIBERTY_GAME_MANIFEST}") + target_link_libraries(LibertyRecompIOS PRIVATE liberty_foundation) + + find_library(UIKIT_FRAMEWORK UIKit REQUIRED) + find_library(FOUNDATION_FRAMEWORK Foundation REQUIRED) + find_library(METAL_FRAMEWORK Metal REQUIRED) + find_library(METALKIT_FRAMEWORK MetalKit REQUIRED) + find_library(AVFOUNDATION_FRAMEWORK AVFoundation REQUIRED) + find_library(GAMECONTROLLER_FRAMEWORK GameController REQUIRED) + find_library(UNIFORMTYPEIDENTIFIERS_FRAMEWORK UniformTypeIdentifiers REQUIRED) + target_link_libraries(LibertyRecompIOS PRIVATE "${UIKIT_FRAMEWORK}" "${FOUNDATION_FRAMEWORK}" "${METAL_FRAMEWORK}" "${METALKIT_FRAMEWORK}" "${AVFOUNDATION_FRAMEWORK}" "${GAMECONTROLLER_FRAMEWORK}" "${UNIFORMTYPEIDENTIFIERS_FRAMEWORK}") + + if(LIBERTY_IOS_GAMECENTER) + find_library(GAMEKIT_FRAMEWORK GameKit REQUIRED) + target_link_libraries(LibertyRecompIOS PRIVATE "${GAMEKIT_FRAMEWORK}") + target_compile_definitions(LibertyRecompIOS PRIVATE LIBERTY_IOS_GAMECENTER=1) + else() + target_compile_definitions(LibertyRecompIOS PRIVATE LIBERTY_IOS_GAMECENTER=0) + endif() + if(LIBERTY_IOS_DIAGNOSTICS) + target_compile_definitions(LibertyRecompIOS PRIVATE LIBERTY_IOS_DIAGNOSTICS=1) + else() + target_compile_definitions(LibertyRecompIOS PRIVATE LIBERTY_IOS_DIAGNOSTICS=0) + endif() + if(LIBERTY_IOS_SHELL_ONLY) + target_compile_definitions(LibertyRecompIOS PRIVATE LIBERTY_IOS_SHELL_ONLY=1) + else() + target_compile_definitions(LibertyRecompIOS PRIVATE LIBERTY_IOS_SHELL_ONLY=0) + endif() + if(LIBERTY_IOS_ALLOW_UNPINNED_CONTENT) + target_compile_definitions(LibertyRecompIOS PRIVATE LIBERTY_IOS_ALLOW_UNPINNED_CONTENT=1) + else() + target_compile_definitions(LibertyRecompIOS PRIVATE LIBERTY_IOS_ALLOW_UNPINNED_CONTENT=0) + endif() + + target_compile_definitions(LibertyRecompIOS PRIVATE LIBERTY_BUILD_COMMIT="${LIBERTY_PROJECT_COMMIT}" LIBERTY_UPSTREAM_COMMIT="${LIBERTY_UPSTREAM_COMMIT}" LIBERTY_IOS_CONTENT_MODE="${LIBERTY_IOS_CONTENT_MODE}") + set_target_properties(LibertyRecompIOS PROPERTIES OUTPUT_NAME "Liberty Recompiled" MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/src/ios/Info.plist.in" MACOSX_BUNDLE_GUI_IDENTIFIER "${LIBERTY_IOS_BUNDLE_IDENTIFIER}" MACOSX_BUNDLE_BUNDLE_NAME "Liberty Recompiled" MACOSX_BUNDLE_BUNDLE_VERSION "1" MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${LIBERTY_IOS_BUNDLE_IDENTIFIER}" XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "16.0" XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2" XCODE_ATTRIBUTE_ENABLE_BITCODE "NO" XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES" XCODE_ATTRIBUTE_ONLY_ACTIVE_ARCH "NO") + if(LIBERTY_IOS_DEVELOPMENT_TEAM) + set_target_properties(LibertyRecompIOS PROPERTIES XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${LIBERTY_IOS_DEVELOPMENT_TEAM}" XCODE_ATTRIBUTE_CODE_SIGN_STYLE "Automatic") + else() + set_target_properties(LibertyRecompIOS PROPERTIES XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "NO" XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "NO") + endif() +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..d809c47 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,38 @@ +{ + "version": 6, + "cmakeMinimumRequired": {"major": 3, "minor": 25, "patch": 0}, + "configurePresets": [ + { + "name": "host-debug", + "displayName": "Host Foundation Tests", + "generator": "Ninja", + "binaryDir": "${sourceDir}/out/build/host-debug", + "cacheVariables": {"CMAKE_BUILD_TYPE": "Debug", "LIBERTY_BUILD_TESTS": "ON"} + }, + { + "name": "ios-base", + "hidden": true, + "generator": "Xcode", + "binaryDir": "${sourceDir}/out/build/${presetName}", + "toolchainFile": "${sourceDir}/toolchains/ios.cmake", + "cacheVariables": {"LIBERTY_BUILD_TESTS": "OFF", "LIBERTY_IOS_SHELL_ONLY": "ON", "LIBERTY_IOS_CONTENT_MODE": "imported", "LIBERTY_IOS_GAMECENTER": "OFF", "LIBERTY_IOS_DIAGNOSTICS": "ON", "LIBERTY_IOS_ALLOW_UNPINNED_CONTENT": "OFF"} + }, + { + "name": "ios-ci", + "displayName": "iOS ARM64 CI Shell", + "inherits": "ios-base", + "cacheVariables": {"CMAKE_OSX_SYSROOT": "iphoneos", "CMAKE_OSX_ARCHITECTURES": "arm64", "LIBERTY_IOS_BUNDLE_IDENTIFIER": "com.libertyrecomp.foundation.ci"} + }, + {"name": "ios-device-debug", "displayName": "iOS ARM64 Device Debug", "inherits": "ios-base"}, + {"name": "ios-device-release", "displayName": "iOS ARM64 Device Release", "inherits": "ios-base"} + ], + "buildPresets": [ + {"name": "host-debug", "configurePreset": "host-debug"}, + {"name": "ios-ci", "configurePreset": "ios-ci", "configuration": "Debug"}, + {"name": "ios-device-debug", "configurePreset": "ios-device-debug", "configuration": "Debug"}, + {"name": "ios-device-release", "configurePreset": "ios-device-release", "configuration": "Release"} + ], + "testPresets": [ + {"name": "host-debug", "configurePreset": "host-debug", "output": {"outputOnFailure": true}} + ] +} diff --git a/UPSTREAM.lock b/UPSTREAM.lock new file mode 100644 index 0000000..ab03901 --- /dev/null +++ b/UPSTREAM.lock @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "repository": "https://github.com/matthewcodergamer/LibertyRecomp.git", + "commit": "38a6dbcc33b5040524a32966a3c9ffcbcf5d5f72", + "note": "Pinned checkpoint: renderer and gameplay state before performance rewrite" +} diff --git a/config/game_versions/gta4_x360_supported.json b/config/game_versions/gta4_x360_supported.json new file mode 100644 index 0000000..2231f61 --- /dev/null +++ b/config/game_versions/gta4_x360_supported.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "title": "Grand Theft Auto IV", + "platform": "Xbox 360", + "requiredFiles": [ + { + "path": "default.xex", + "role": "executable" + }, + { + "path": "common.rpf", + "role": "game-data" + }, + { + "path": "xbox360.rpf", + "role": "game-data" + }, + { + "path": "audio.rpf", + "role": "audio" + } + ], + "supportedRevisions": [], + "notes": [ + "No executable hash is invented here.", + "Add a revision only after hashing the exact legally owned Xbox 360 executable used by the static recompilation configuration.", + "The shipping build should reject unknown executable revisions by default." + ] +} diff --git a/docs/UPSTREAM.md b/docs/UPSTREAM.md new file mode 100644 index 0000000..a8c6e92 --- /dev/null +++ b/docs/UPSTREAM.md @@ -0,0 +1,54 @@ +# Upstream integration + +`LibertyRecomp-iOS` does not copy the LibertyRecomp source tree into this repository. Instead, it pins one exact upstream commit in [`UPSTREAM.lock`](../UPSTREAM.lock) and checks that revision out when an upstream reference build is needed. + +Current upstream: + +- Repository: `https://github.com/matthewcodergamer/LibertyRecomp.git` +- Commit: `38a6dbcc33b5040524a32966a3c9ffcbcf5d5f72` +- Commit message: `checkpoint: renderer and gameplay state before performance rewrite` + +This keeps the iOS project reproducible while preserving upstream history. + +## Fetching the pinned source + +```bash +./scripts/fetch-upstream.sh +``` + +The default checkout is `.cache/LibertyRecomp/`, which is ignored by Git. To initialize all upstream submodules as well: + +```bash +./scripts/fetch-upstream.sh --recursive +``` + +A custom destination may be supplied: + +```bash +./scripts/fetch-upstream.sh --destination /path/to/LibertyRecomp +``` + +The script verifies that the checked-out `HEAD` exactly equals the SHA in `UPSTREAM.lock`. + +## Updating the pin + +Do not silently follow upstream `main`. + +1. Choose an upstream commit deliberately. +2. Review upstream changes, especially ReXGlue, RAGE patches, renderer, iOS files, shader tooling, and build-system changes. +3. Update only the `commit` and descriptive `note` in `UPSTREAM.lock`. +4. Run: + ```bash + ./scripts/fetch-upstream.sh --destination .cache/LibertyRecomp + cmake --preset host-debug + cmake --build --preset host-debug + ctest --preset host-debug + ``` +5. Run the Apple CI jobs. +6. Only after the pin is proven should later runtime integration work be based on it. + +## Reference-build policy + +The native iOS shell in this repository is deliberately buildable without GTA IV data. The upstream reference source is fetched separately. A complete GTA IV reference run still requires a legally owned game copy and is not performed in public CI. + +Public CI may compile or configure only code that can be built without copyrighted game files. No `default.xex`, RPF archives, title updates, keys, or generated proprietary game payloads are uploaded as artifacts. diff --git a/include/liberty/content_provider.hpp b/include/liberty/content_provider.hpp new file mode 100644 index 0000000..6fc7725 --- /dev/null +++ b/include/liberty/content_provider.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +namespace liberty::content { + +struct ContentLayout { + std::filesystem::path game_root; + std::filesystem::path dlc_root; + std::filesystem::path cache_root; + std::filesystem::path config_root; + std::filesystem::path saves_root; +}; + +struct ValidationResult { + bool ok{false}; + std::vector missing_files; +}; + +class GameContentProvider { +public: + virtual ~GameContentProvider() = default; + [[nodiscard]] virtual ContentLayout layout() const = 0; + [[nodiscard]] ValidationResult validate_required_files() const; +}; + +class EmbeddedContentProvider final : public GameContentProvider { +public: + explicit EmbeddedContentProvider(std::filesystem::path root); + [[nodiscard]] ContentLayout layout() const override; +private: + std::filesystem::path root_; +}; + +class ImportedContentProvider final : public GameContentProvider { +public: + ImportedContentProvider(std::filesystem::path application_support_root, std::filesystem::path documents_root); + [[nodiscard]] ContentLayout layout() const override; + void ensure_layout() const; +private: + std::filesystem::path application_support_root_; + std::filesystem::path documents_root_; +}; + +[[nodiscard]] const std::vector& required_game_files(); + +} // namespace liberty::content diff --git a/scripts/check-prohibited-assets.py b/scripts/check-prohibited-assets.py new file mode 100755 index 0000000..3ae0f29 --- /dev/null +++ b/scripts/check-prohibited-assets.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Fail CI if tracked files look like private game payloads or signing secrets.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +FORBIDDEN_SUFFIXES = {".xex", ".rpf", ".iso", ".xiso", ".stfs", ".live", ".con", ".ipa", ".mobileprovision", ".p12", ".pfx", ".cer", ".der"} +FORBIDDEN_BASENAMES = {"aes_key.bin", "default.xex"} +FORBIDDEN_PATH_PARTS = {"local_game_payload", "private_game", "game_payload", "DerivedData"} +SECRET_MARKERS = ("-----BEGIN PRIVATE KEY-----", "-----BEGIN ENCRYPTED PRIVATE KEY-----", "-----BEGIN OPENSSH PRIVATE KEY-----") + + +def tracked_files(root: Path) -> list[Path]: + proc = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, stdout=subprocess.PIPE) + return [root / p.decode("utf-8") for p in proc.stdout.split(b"\0") if p] + + +def find_violations(root: Path, files: list[Path] | None = None) -> list[str]: + root = root.resolve() + files = tracked_files(root) if files is None else files + violations: list[str] = [] + marker_definition_paths = {Path("scripts/check-prohibited-assets.py"), Path("tests/test_prohibited_assets.py")} + + for file_path in files: + try: + rel = file_path.resolve().relative_to(root) + except ValueError: + violations.append(f"outside repository: {file_path}") + continue + + lower_name = rel.name.lower() + lower_suffix = rel.suffix.lower() + if lower_name in FORBIDDEN_BASENAMES or lower_suffix in FORBIDDEN_SUFFIXES: + violations.append(f"forbidden tracked payload/signing file: {rel.as_posix()}") + continue + if any(part in FORBIDDEN_PATH_PARTS for part in rel.parts): + violations.append(f"forbidden tracked private/build directory: {rel.as_posix()}") + continue + if rel in marker_definition_paths: + continue + try: + if file_path.stat().st_size <= 2 * 1024 * 1024: + text = file_path.read_text(encoding="utf-8", errors="ignore") + if any(marker in text for marker in SECRET_MARKERS): + violations.append(f"private-key material detected: {rel.as_posix()}") + except (OSError, UnicodeError): + pass + + return sorted(set(violations)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args(argv) + violations = find_violations(args.root) + if violations: + print("Prohibited tracked files detected:", file=sys.stderr) + for violation in violations: + print(f" - {violation}", file=sys.stderr) + return 1 + print("No prohibited game payloads or signing secrets are tracked.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci-macos-reference.sh b/scripts/ci-macos-reference.sh new file mode 100755 index 0000000..7c4c318 --- /dev/null +++ b/scripts/ci-macos-reference.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DESTINATION="${1:-${RUNNER_TEMP:-${ROOT_DIR}/.cache}/LibertyRecomp-reference}" + +"${SCRIPT_DIR}/fetch-upstream.sh" --destination "${DESTINATION}" + +python3 - "${ROOT_DIR}/UPSTREAM.lock" "${DESTINATION}" <<'PY' +import json, pathlib, subprocess, sys + +lock = json.loads(pathlib.Path(sys.argv[1]).read_text()) +upstream = pathlib.Path(sys.argv[2]) +actual = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip() +if actual != lock["commit"]: + raise SystemExit(f"upstream pin mismatch: {actual} != {lock['commit']}") + +required = ["CMakeLists.txt", "CMakePresets.json", "toolchains/ios.cmake", "LibertyRecomp/CMakeLists.txt", "glue/CMakeLists.txt"] +missing = [p for p in required if not (upstream / p).exists()] +if missing: + raise SystemExit("missing pinned upstream build files: " + ", ".join(missing)) + +print("Pinned macOS reference source verified at", actual) +print("A complete upstream macOS build is intentionally a separate device/developer gate") +print("because the upstream reference has a large nested dependency graph.") +PY diff --git a/scripts/fetch-upstream.sh b/scripts/fetch-upstream.sh new file mode 100755 index 0000000..67dee75 --- /dev/null +++ b/scripts/fetch-upstream.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +LOCK_FILE="${ROOT_DIR}/UPSTREAM.lock" +DESTINATION="${ROOT_DIR}/.cache/LibertyRecomp" +RECURSIVE=0 + +usage() { + cat <<'EOF' +Usage: scripts/fetch-upstream.sh [--destination PATH] [--recursive] + +Fetch the exact LibertyRecomp commit recorded in UPSTREAM.lock. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --destination) + [[ $# -ge 2 ]] || { echo "error: --destination requires a path" >&2; exit 2; } + DESTINATION="$2" + shift 2 + ;; + --recursive) + RECURSIVE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +[[ -f "${LOCK_FILE}" ]] || { echo "error: missing ${LOCK_FILE}" >&2; exit 2; } + +readarray -t LOCK_VALUES < <(python3 - "${LOCK_FILE}" <<'PY' +import json, sys +with open(sys.argv[1], "r", encoding="utf-8") as f: + data = json.load(f) +print(data["repository"]) +print(data["commit"]) +PY +) + +UPSTREAM_REPO="${LOCK_VALUES[0]}" +UPSTREAM_COMMIT="${LOCK_VALUES[1]}" + +mkdir -p "$(dirname "${DESTINATION}")" + +if [[ ! -d "${DESTINATION}/.git" ]]; then + rm -rf "${DESTINATION}" + git clone --filter=blob:none --no-checkout "${UPSTREAM_REPO}" "${DESTINATION}" +else + EXISTING_REMOTE="$(git -C "${DESTINATION}" remote get-url origin 2>/dev/null || true)" + if [[ "${EXISTING_REMOTE}" != "${UPSTREAM_REPO}" ]]; then + echo "error: ${DESTINATION} points at ${EXISTING_REMOTE}, expected ${UPSTREAM_REPO}" >&2 + exit 3 + fi +fi + +git -C "${DESTINATION}" fetch --depth=1 origin "${UPSTREAM_COMMIT}" +git -C "${DESTINATION}" checkout --detach --force FETCH_HEAD + +ACTUAL_COMMIT="$(git -C "${DESTINATION}" rev-parse HEAD)" +if [[ "${ACTUAL_COMMIT}" != "${UPSTREAM_COMMIT}" ]]; then + echo "error: checkout mismatch: expected ${UPSTREAM_COMMIT}, got ${ACTUAL_COMMIT}" >&2 + exit 4 +fi + +if [[ "${RECURSIVE}" -eq 1 ]]; then + git -C "${DESTINATION}" submodule sync --recursive + git -C "${DESTINATION}" submodule update --init --recursive --depth 1 +fi + +echo "LibertyRecomp upstream ready:" +echo " repository: ${UPSTREAM_REPO}" +echo " commit: ${ACTUAL_COMMIT}" +echo " path: ${DESTINATION}" diff --git a/scripts/verify-game.py b/scripts/verify-game.py new file mode 100755 index 0000000..3c5b4f4 --- /dev/null +++ b/scripts/verify-game.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Validate a private extracted GTA IV Xbox 360 game folder. + +This tool never copies, modifies, uploads, or redistributes game data. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import sys +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import Any + +EXIT_OK = 0 +EXIT_MISSING = 2 +EXIT_UNSUPPORTED = 3 +EXIT_STORAGE = 4 +EXIT_CONFIG = 5 + + +@dataclass +class VerificationResult: + ok: bool + supported: bool + status: str + game_root: str + executable_sha256: str | None + matched_revision_id: str | None + total_bytes: int + available_bytes: int | None + missing_files: list[str] + messages: list[str] + + +def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(chunk_size), b""): + digest.update(chunk) + return digest.hexdigest() + + +def folder_size(path: Path) -> int: + total = 0 + for current_root, _, filenames in os.walk(path): + base = Path(current_root) + for name in filenames: + file_path = base / name + try: + total += file_path.stat().st_size + except FileNotFoundError: + pass + return total + + +def load_manifest(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot read manifest {path}: {exc}") from exc + + if data.get("schemaVersion") != 1: + raise ValueError("unsupported game manifest schemaVersion") + if not isinstance(data.get("requiredFiles"), list): + raise ValueError("manifest requiredFiles must be a list") + if not isinstance(data.get("supportedRevisions"), list): + raise ValueError("manifest supportedRevisions must be a list") + return data + + +def verify_game(game_root: Path, manifest: dict[str, Any], *, destination: Path | None = None, allow_unpinned: bool = False) -> tuple[VerificationResult, int]: + game_root = game_root.expanduser().resolve() + messages: list[str] = [] + + if not game_root.is_dir(): + result = VerificationResult(False, False, "missing-game-root", str(game_root), None, None, 0, None, [], [f"Game root does not exist or is not a directory: {game_root}"]) + return result, EXIT_MISSING + + required_paths: list[str] = [] + for entry in manifest["requiredFiles"]: + if not isinstance(entry, dict) or not isinstance(entry.get("path"), str): + raise ValueError("each requiredFiles entry must contain a string path") + rel = entry["path"].replace("\\", "/").lstrip("/") + if ".." in Path(rel).parts: + raise ValueError(f"manifest contains unsafe relative path: {rel}") + required_paths.append(rel) + + missing = [rel for rel in required_paths if not (game_root / rel).is_file()] + if missing: + result = VerificationResult(False, False, "missing-required-files", str(game_root), None, None, folder_size(game_root), None, missing, ["Required game files are missing."]) + return result, EXIT_MISSING + + xex_path = game_root / "default.xex" + xex_hash = sha256_file(xex_path) + + matched_revision: dict[str, Any] | None = None + for revision in manifest["supportedRevisions"]: + if not isinstance(revision, dict): + raise ValueError("supportedRevisions entries must be objects") + expected = str(revision.get("xexSha256", "")).strip().lower() + if len(expected) == 64 and expected == xex_hash.lower(): + matched_revision = revision + break + + supported = matched_revision is not None + if supported: + messages.append(f"Supported executable revision: {matched_revision.get('id', matched_revision.get('name', 'unnamed'))}") + elif manifest["supportedRevisions"]: + messages.append("Executable SHA-256 does not match any supported static-recomp revision.") + else: + messages.append("No supported executable hash is configured yet; this repository refuses to invent one.") + + total = folder_size(game_root) + available: int | None = None + storage_ok = True + if destination is not None: + destination = destination.expanduser().resolve() + probe = destination if destination.exists() else destination.parent + while not probe.exists() and probe != probe.parent: + probe = probe.parent + usage = shutil.disk_usage(probe) + available = usage.free + required_free = total + (256 * 1024 * 1024) + storage_ok = available >= required_free + if not storage_ok: + messages.append(f"Insufficient free storage at {probe}: need at least {required_free} bytes, found {available}.") + + accepted_revision = supported or allow_unpinned + if not storage_ok: + code = EXIT_STORAGE + status = "insufficient-storage" + elif not accepted_revision: + code = EXIT_UNSUPPORTED + status = "unsupported-executable" + else: + code = EXIT_OK + status = "supported" if supported else "unverified-development-mode" + if allow_unpinned and not supported: + messages.append("Unpinned executable accepted only because --allow-unpinned was supplied.") + + result = VerificationResult( + ok=(code == EXIT_OK), + supported=supported, + status=status, + game_root=str(game_root), + executable_sha256=xex_hash, + matched_revision_id=(str(matched_revision.get("id")) if matched_revision is not None and matched_revision.get("id") is not None else None), + total_bytes=total, + available_bytes=available, + missing_files=[], + messages=messages, + ) + return result, code + + +def build_parser() -> argparse.ArgumentParser: + default_manifest = Path(__file__).resolve().parents[1] / "config" / "game_versions" / "gta4_x360_supported.json" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("game_root", type=Path, help="Extracted private Xbox 360 game folder") + parser.add_argument("--manifest", type=Path, default=default_manifest, help=f"Compatibility manifest (default: {default_manifest})") + parser.add_argument("--destination", type=Path, help="Optional destination volume used for import free-space preflight") + parser.add_argument("--allow-unpinned", action="store_true", help="Development-only: accept an unknown XEX after reporting its SHA-256") + parser.add_argument("--dry-run", action="store_true", help="Explicitly request validation only. This tool never copies files.") + parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + manifest = load_manifest(args.manifest) + result, code = verify_game(args.game_root, manifest, destination=args.destination, allow_unpinned=args.allow_unpinned) + except ValueError as exc: + if args.json: + print(json.dumps({"ok": False, "status": "manifest-error", "error": str(exc)})) + else: + print(f"Manifest error: {exc}", file=sys.stderr) + return EXIT_CONFIG + + if args.json: + print(json.dumps(asdict(result), indent=2, sort_keys=True)) + else: + print("LIBERTY RECOMPILED — GAME VERIFICATION") + print(f"Game root: {result.game_root}") + for rel in result.missing_files: + print(f"✗ {rel}") + if result.executable_sha256: + print(f"default.xex SHA-256: {result.executable_sha256}") + print(f"Content bytes: {result.total_bytes}") + if result.available_bytes is not None: + print(f"Available destination bytes: {result.available_bytes}") + for message in result.messages: + print(message) + print(f"Result: {result.status}") + + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/foundation/content_provider.cpp b/src/foundation/content_provider.cpp new file mode 100644 index 0000000..857865c --- /dev/null +++ b/src/foundation/content_provider.cpp @@ -0,0 +1,53 @@ +#include "liberty/content_provider.hpp" + +#include + +namespace liberty::content { + +const std::vector& required_game_files() { + static const std::vector files{"default.xex", "common.rpf", "xbox360.rpf", "audio.rpf"}; + return files; +} + +ValidationResult GameContentProvider::validate_required_files() const { + const auto content_layout = layout(); + ValidationResult result; + for (const auto& relative : required_game_files()) { + std::error_code error; + const auto candidate = content_layout.game_root / relative; + if (!std::filesystem::is_regular_file(candidate, error) || error) { + result.missing_files.push_back(relative); + } + } + result.ok = result.missing_files.empty(); + return result; +} + +EmbeddedContentProvider::EmbeddedContentProvider(std::filesystem::path root) : root_(std::move(root)) {} + +ContentLayout EmbeddedContentProvider::layout() const { + return ContentLayout{.game_root = root_, .dlc_root = root_ / "dlc", .cache_root = root_ / ".liberty-cache", .config_root = root_ / ".liberty-config", .saves_root = root_ / ".liberty-saves"}; +} + +ImportedContentProvider::ImportedContentProvider(std::filesystem::path application_support_root, std::filesystem::path documents_root) + : application_support_root_(std::move(application_support_root)), documents_root_(std::move(documents_root)) {} + +ContentLayout ImportedContentProvider::layout() const { + const auto base = application_support_root_ / "LibertyRecomp"; + return ContentLayout{.game_root = base / "Game", .dlc_root = base / "DLC", .cache_root = base / "Cache", .config_root = base / "Config", .saves_root = documents_root_ / "LibertyRecomp" / "saves"}; +} + +void ImportedContentProvider::ensure_layout() const { + const auto content_layout = layout(); + std::error_code error; + std::filesystem::create_directories(content_layout.dlc_root, error); + if (error) throw std::filesystem::filesystem_error("Failed to create DLC directory", content_layout.dlc_root, error); + std::filesystem::create_directories(content_layout.cache_root, error); + if (error) throw std::filesystem::filesystem_error("Failed to create cache directory", content_layout.cache_root, error); + std::filesystem::create_directories(content_layout.config_root, error); + if (error) throw std::filesystem::filesystem_error("Failed to create config directory", content_layout.config_root, error); + std::filesystem::create_directories(content_layout.saves_root, error); + if (error) throw std::filesystem::filesystem_error("Failed to create saves directory", content_layout.saves_root, error); +} + +} // namespace liberty::content diff --git a/tests/content_provider_tests.cpp b/tests/content_provider_tests.cpp new file mode 100644 index 0000000..c68e417 --- /dev/null +++ b/tests/content_provider_tests.cpp @@ -0,0 +1,50 @@ +#include "liberty/content_provider.hpp" + +#include +#include +#include +#include + +namespace fs = std::filesystem; +static int failures = 0; + +void expect(bool condition, const std::string& message) { + if (!condition) { + std::cerr << "FAIL: " << message << '\n'; + ++failures; + } +} + +int main() { + const auto base = fs::temp_directory_path() / "libertyrecomp-ios-content-provider-test"; + std::error_code ignored; + fs::remove_all(base, ignored); + fs::create_directories(base / "app-support"); + fs::create_directories(base / "documents"); + + liberty::content::ImportedContentProvider imported{base / "app-support", base / "documents"}; + imported.ensure_layout(); + const auto layout = imported.layout(); + expect(fs::is_directory(layout.dlc_root), "DLC directory should exist"); + expect(fs::is_directory(layout.cache_root), "Cache directory should exist"); + expect(fs::is_directory(layout.config_root), "Config directory should exist"); + expect(fs::is_directory(layout.saves_root), "Saves directory should exist"); + + auto result = imported.validate_required_files(); + expect(!result.ok, "Empty game directory must not validate"); + expect(result.missing_files.size() == 4, "All four required files should be missing"); + + fs::create_directories(layout.game_root); + for (const auto& name : liberty::content::required_game_files()) std::ofstream(layout.game_root / name) << "dummy"; + result = imported.validate_required_files(); + expect(result.ok, "Dummy required files should satisfy structural validation"); + expect(result.missing_files.empty(), "No required file should be reported missing"); + + fs::remove_all(base, ignored); + if (failures != 0) { + std::cerr << failures << " failure(s)\n"; + return 1; + } + std::cout << "content_provider_tests: PASS\n"; + return 0; +} diff --git a/tests/test_prohibited_assets.py b/tests/test_prohibited_assets.py new file mode 100644 index 0000000..bfcec02 --- /dev/null +++ b/tests/test_prohibited_assets.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("check_assets", ROOT / "scripts" / "check-prohibited-assets.py") +check_assets = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +SPEC.loader.exec_module(check_assets) + + +class ProhibitedAssetTests(unittest.TestCase): + def test_safe_text_document_is_allowed(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = root / "notes.md" + path.write_text("Documentation may mention default.xex without containing it.") + self.assertEqual(check_assets.find_violations(root, [path]), []) + + def test_rpf_file_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = root / "common.rpf" + path.write_bytes(b"not real game data") + violations = check_assets.find_violations(root, [path]) + self.assertTrue(any("common.rpf" in item for item in violations)) + + def test_private_key_marker_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = root / "secret.txt" + path.write_text("-----BEGIN PRIVATE KEY-----\nplaceholder") + violations = check_assets.find_violations(root, [path]) + self.assertTrue(any("private-key" in item for item in violations)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verify_game.py b/tests/test_verify_game.py new file mode 100644 index 0000000..e586bf1 --- /dev/null +++ b/tests/test_verify_game.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import tempfile +import unittest +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("verify_game", ROOT / "scripts" / "verify-game.py") +verify_game = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +sys.modules[SPEC.name] = verify_game +SPEC.loader.exec_module(verify_game) + + +class VerifyGameTests(unittest.TestCase): + def make_game(self, root: Path, xex_bytes: bytes = b"dummy-xex") -> str: + (root / "default.xex").write_bytes(xex_bytes) + (root / "common.rpf").write_bytes(b"common") + (root / "xbox360.rpf").write_bytes(b"xbox") + (root / "audio.rpf").write_bytes(b"audio") + return hashlib.sha256(xex_bytes).hexdigest() + + def manifest(self, sha: str | None = None) -> dict: + revisions = [] if sha is None else [{"id": "test-revision", "xexSha256": sha}] + return {"schemaVersion": 1, "requiredFiles": [{"path": "default.xex"}, {"path": "common.rpf"}, {"path": "xbox360.rpf"}, {"path": "audio.rpf"}], "supportedRevisions": revisions} + + def test_supported_revision_passes(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sha = self.make_game(root) + result, code = verify_game.verify_game(root, self.manifest(sha)) + self.assertEqual(code, verify_game.EXIT_OK) + self.assertTrue(result.ok) + self.assertTrue(result.supported) + self.assertEqual(result.matched_revision_id, "test-revision") + + def test_unknown_revision_is_rejected_by_default(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.make_game(root) + result, code = verify_game.verify_game(root, self.manifest()) + self.assertEqual(code, verify_game.EXIT_UNSUPPORTED) + self.assertFalse(result.ok) + self.assertFalse(result.supported) + + def test_unknown_revision_can_be_explicitly_allowed_for_development(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.make_game(root) + result, code = verify_game.verify_game(root, self.manifest(), allow_unpinned=True) + self.assertEqual(code, verify_game.EXIT_OK) + self.assertEqual(result.status, "unverified-development-mode") + + def test_missing_file_is_reported(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "default.xex").write_bytes(b"x") + result, code = verify_game.verify_game(root, self.manifest()) + self.assertEqual(code, verify_game.EXIT_MISSING) + self.assertIn("common.rpf", result.missing_files) + + def test_manifest_loader_rejects_wrong_schema(self): + with tempfile.TemporaryDirectory() as tmp: + manifest_path = Path(tmp) / "manifest.json" + manifest_path.write_text(json.dumps({"schemaVersion": 99, "requiredFiles": [], "supportedRevisions": []})) + with self.assertRaises(ValueError): + verify_game.load_manifest(manifest_path) + + +if __name__ == "__main__": + unittest.main() From ea10a37b9bde877bca6fb71fab5a17892efb7ab6 Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Fri, 11 Sep 2026 12:57:32 -0400 Subject: [PATCH 2/6] Add iOS foundation shell --- .github/workflows/apple.yml | 34 +++ .github/workflows/foundation.yml | 38 +++ README.md | 118 +++++++-- src/ios/Info.plist.in | 25 ++ src/ios/main.mm | 405 +++++++++++++++++++++++++++++++ toolchains/ios.cmake | 16 ++ 6 files changed, 622 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/apple.yml create mode 100644 .github/workflows/foundation.yml create mode 100644 src/ios/Info.plist.in create mode 100644 src/ios/main.mm create mode 100644 toolchains/ios.cmake diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml new file mode 100644 index 0000000..a2cceeb --- /dev/null +++ b/.github/workflows/apple.yml @@ -0,0 +1,34 @@ +name: Apple Foundation Build + +on: + push: + branches: [main, foundation-stages-0-5] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + ios-arm64-shell: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - name: Xcode version + run: xcodebuild -version + - name: Configure unsigned iOS ARM64 shell + run: cmake --preset ios-ci + - name: Compile unsigned iOS ARM64 shell + run: cmake --build --preset ios-ci --parallel 2 + - name: Confirm app bundle exists + run: | + APP="$(find out/build/ios-ci -type d -name 'Liberty Recompiled.app' -print -quit)" + test -n "$APP" + echo "Built: $APP" + + macos-reference-source: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - name: Verify pinned LibertyRecomp reference source + run: ./scripts/ci-macos-reference.sh "$RUNNER_TEMP/LibertyRecomp" diff --git a/.github/workflows/foundation.yml b/.github/workflows/foundation.yml new file mode 100644 index 0000000..7962d12 --- /dev/null +++ b/.github/workflows/foundation.yml @@ -0,0 +1,38 @@ +name: Foundation CI + +on: + push: + branches: [main, foundation-stages-0-5] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + portable-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Check prohibited private assets + run: python3 scripts/check-prohibited-assets.py + - name: Python unit tests + run: python3 -m unittest discover -s tests -p "test_*.py" -v + - name: Configure portable C++ foundation + run: cmake --preset host-debug + - name: Build portable C++ foundation + run: cmake --build --preset host-debug --parallel 2 + - name: Run portable C++ tests + run: ctest --preset host-debug + + upstream-pin: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fetch exact pinned upstream revision + run: ./scripts/fetch-upstream.sh --destination "$RUNNER_TEMP/LibertyRecomp" + - name: Verify pin and reference build files + run: ./scripts/ci-macos-reference.sh "$RUNNER_TEMP/LibertyRecomp" diff --git a/README.md b/README.md index f850768..016811f 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,118 @@ # LibertyRecomp iOS -Native iPhone/iPad target for the LibertyRecomp GTA IV Xbox 360 static recompilation project. +Native iPhone/iPad platform project for the LibertyRecomp GTA IV Xbox 360 static recompilation work. -## Goal +The shipping architecture is **native ARM64 + Metal**. This is not an Xenia frontend, Wine wrapper, x86/x64 translation layer, or runtime PowerPC JIT. -Build a native ARM64 iOS port based on static recompilation, using Metal for graphics and a mobile-first platform layer. The repository contains only code, tooling, tests, documentation, and build infrastructure. It must not contain Rockstar game assets, `default.xex`, RPF archives, or other copyrighted game data. +## Foundation status — Stages 0–5 -## Source project +The repository now contains the first real implementation layer: -This project is designed to build on the existing LibertyRecomp work in `matthewcodergamer/LibertyRecomp` rather than reinventing the CPU runtime, RAGE compatibility layer, shader tooling, VFS, saves, audio, input, and game-specific fixes. +- exact LibertyRecomp upstream pin in `UPSTREAM.lock` +- reproducible upstream fetch/verification tooling +- public CI that rejects accidentally committed game payloads and signing secrets +- portable game-content layout/validation code with tests +- GTA IV executable fingerprint manifest and verifier +- macOS reference-source gate +- CMake/Xcode ARM64 iOS toolchain +- unsigned iOS shell build for GitHub Actions +- native UIKit + Metal shell with diagnostics +- AVAudioSession/controller/thermal/lifecycle hooks +- iOS Files folder picker +- storage preflight +- staging-directory import and atomic promotion +- iCloud-backup exclusion for rebuildable/imported game content +- strict default rejection of unknown `default.xex` revisions -## Initial target +The compatibility manifest intentionally has **no invented GTA IV hash**. Until the exact legally owned Xbox 360 executable used by the static recompilation configuration is fingerprinted and added, imports report the SHA-256 and remain unsupported by default. -- Device baseline: iPhone 11 / Apple A13 +## Targets + +- Baseline device: iPhone 11 / Apple A13 - Architecture: ARM64 -- Minimum iOS target: iOS 16+ +- Minimum iOS: 16+ - Graphics: Metal -- Performance target: stable 30 FPS where practical, using dynamic resolution and adaptive quality -- Input: touch controls plus Apple-supported physical controllers -- Distribution artifact: signed `.ipa` +- Initial gameplay target: stable 30 FPS where practical +- Input target: touch + Apple-supported physical controllers +- Final artifact: signed `.ipa` - Game data: imported separately from a legally owned Xbox 360 copy -## Development rule +## What is never committed + +This repository contains code, tooling, tests, documentation, and build infrastructure only. Do not commit `default.xex`, RPF archives, Xbox disc/ISO/STFS payloads, Rockstar assets, locally generated proprietary game payloads, signing certificates/private keys, or provisioning profiles. CI enforces the obvious cases. + +## Portable tests + +```bash +python3 scripts/check-prohibited-assets.py +python3 -m unittest discover -s tests -p "test_*.py" -v +cmake --preset host-debug +cmake --build --preset host-debug +ctest --preset host-debug +``` + +These tests require no GTA IV data. + +## Fetch the pinned LibertyRecomp reference + +```bash +./scripts/fetch-upstream.sh +``` + +See [`docs/UPSTREAM.md`](docs/UPSTREAM.md) for the pin/update policy. + +## Validate a private extracted game folder + +```bash +python3 scripts/verify-game.py /path/to/your/extracted/GTAIV --dry-run +``` + +For machine-readable diagnostics: + +```bash +python3 scripts/verify-game.py /path/to/your/extracted/GTAIV --json +``` + +An unknown executable is rejected. `--allow-unpinned` exists only for developer investigation and does not make a revision officially supported. + +## Build the iOS Foundation shell + +A Mac with Xcode is required locally. Public GitHub Actions also cross-compiles this target without signing credentials. + +```bash +cmake --preset ios-device-debug \ + -DLIBERTY_IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID \ + -DLIBERTY_IOS_BUNDLE_IDENTIFIER=com.yourname.libertyrecomp +cmake --build --preset ios-device-debug +``` + +Without a development team, the project configures an unsigned compile-only app suitable for CI. + +### Important build options + +- `LIBERTY_IOS_SHELL_ONLY=ON` +- `LIBERTY_IOS_CONTENT_MODE=imported|embedded` +- `LIBERTY_IOS_GAMECENTER=OFF` by default +- `LIBERTY_IOS_DIAGNOSTICS=ON` +- `LIBERTY_IOS_ALLOW_UNPINNED_CONTENT=OFF` by default + +## Content layout + +```text +Application Support/LibertyRecomp/ + Game/ + DLC/ + Cache/ + Config/ + +Documents/LibertyRecomp/ + saves/ +``` + +The native importer copies a user-selected extracted game folder into `Game.staging/`, validates the supported executable revision, checks free storage, then promotes the completed staging directory to `Game/`. Interrupted imports do not become valid installations. + +## Current validation boundary -No fake fixes. When behavior diverges from the Xbox 360 version, instrument the first point of divergence, fix the underlying CPU/runtime/renderer/platform semantics, and add a regression test. +GitHub CI can test Python/C++ logic and cross-compile the unsigned ARM64 iOS shell. It cannot prove physical iPhone launch, actual GTA IV runtime execution, real Metal frame timing, iPhone 11 memory/thermal performance, or signed IPA installation. Those become physical-device gates as the runtime is integrated. -See `ROADMAP.md` for the full zero-to-IPA development plan and `PROMPTS.md` for staged implementation prompts. \ No newline at end of file +See [`ROADMAP.md`](ROADMAP.md) for the complete zero-to-IPA plan and [`PROMPTS.md`](PROMPTS.md) for staged implementation prompts. diff --git a/src/ios/Info.plist.in b/src/ios/Info.plist.in new file mode 100644 index 0000000..b1104fe --- /dev/null +++ b/src/ios/Info.plist.in @@ -0,0 +1,25 @@ + + + + + CFBundleDevelopmentRegionen + CFBundleDisplayNameLiberty Recompiled + CFBundleExecutable$(EXECUTABLE_NAME) + CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion6.0 + CFBundleNameLiberty Recompiled + CFBundlePackageTypeAPPL + CFBundleShortVersionString0.1.0 + CFBundleVersion1 + LSRequiresIPhoneOS + UIRequiresFullScreen + UILaunchScreen + UISupportedInterfaceOrientations + UIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad + UIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight + UIRequiredDeviceCapabilities + arm64metal + LSSupportsOpeningDocumentsInPlace + + diff --git a/src/ios/main.mm b/src/ios/main.mm new file mode 100644 index 0000000..eb72f4d --- /dev/null +++ b/src/ios/main.mm @@ -0,0 +1,405 @@ +#import +#import +#import +#import +#import +#import + +#include "liberty/content_provider.hpp" + +#include +#include +#include + +#ifndef LIBERTY_BUILD_COMMIT +#define LIBERTY_BUILD_COMMIT "unknown" +#endif +#ifndef LIBERTY_UPSTREAM_COMMIT +#define LIBERTY_UPSTREAM_COMMIT "unknown" +#endif +#ifndef LIBERTY_IOS_CONTENT_MODE +#define LIBERTY_IOS_CONTENT_MODE "imported" +#endif +#ifndef LIBERTY_IOS_ALLOW_UNPINNED_CONTENT +#define LIBERTY_IOS_ALLOW_UNPINNED_CONTENT 0 +#endif + +static NSURL *LibertyApplicationSupportURL(void) { + NSURL *root = [[NSFileManager defaultManager] URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask].firstObject; + return [root URLByAppendingPathComponent:@"LibertyRecomp" isDirectory:YES]; +} + +static NSURL *LibertyDocumentsURL(void) { + return [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject; +} + +static BOOL LibertyExcludeFromBackup(NSURL *url, NSError **error) { + NSNumber *yes = @YES; + return [url setResourceValue:yes forKey:NSURLIsExcludedFromBackupKey error:error]; +} + +static NSString *LibertySHA256(NSURL *fileURL, NSError **error) { + NSFileHandle *handle = [NSFileHandle fileHandleForReadingFromURL:fileURL error:error]; + if (!handle) return nil; + CC_SHA256_CTX context; + CC_SHA256_Init(&context); + @try { + while (true) { + NSData *chunk = [handle readDataOfLength:1024 * 1024]; + if (chunk.length == 0) break; + CC_SHA256_Update(&context, chunk.bytes, (CC_LONG)chunk.length); + } + } @catch (NSException *exception) { + if (error) *error = [NSError errorWithDomain:@"LibertyContent" code:20 userInfo:@{NSLocalizedDescriptionKey: exception.reason ?: @"Failed to hash executable"}]; + [handle closeFile]; + return nil; + } + [handle closeFile]; + unsigned char bytes[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256_Final(bytes, &context); + NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (int index = 0; index < CC_SHA256_DIGEST_LENGTH; ++index) [hex appendFormat:@"%02x", bytes[index]]; + return hex; +} + +static unsigned long long LibertyDirectorySize(NSURL *rootURL) { + NSArray *keys = @[NSURLIsRegularFileKey, NSURLFileSizeKey]; + NSDirectoryEnumerator *enumerator = [[NSFileManager defaultManager] enumeratorAtURL:rootURL includingPropertiesForKeys:keys options:NSDirectoryEnumerationSkipsHiddenFiles errorHandler:^BOOL(NSURL *url, NSError *error) { + NSLog(@"[Liberty] size scan skipped %@: %@", url.path, error); + return YES; + }]; + unsigned long long total = 0; + for (NSURL *url in enumerator) { + NSNumber *isRegular = nil; + NSNumber *size = nil; + [url getResourceValue:&isRegular forKey:NSURLIsRegularFileKey error:nil]; + if (isRegular.boolValue) { + [url getResourceValue:&size forKey:NSURLFileSizeKey error:nil]; + total += size.unsignedLongLongValue; + } + } + return total; +} + +static NSDictionary *LibertyCompatibilityManifest(NSError **error) { + NSURL *url = [[NSBundle mainBundle] URLForResource:@"gta4_x360_supported" withExtension:@"json"]; + if (!url) { + if (error) *error = [NSError errorWithDomain:@"LibertyContent" code:21 userInfo:@{NSLocalizedDescriptionKey: @"Compatibility manifest is missing from the app bundle."}]; + return nil; + } + NSData *data = [NSData dataWithContentsOfURL:url options:0 error:error]; + if (!data) return nil; + id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:error]; + if (![object isKindOfClass:[NSDictionary class]]) { + if (error) *error = [NSError errorWithDomain:@"LibertyContent" code:22 userInfo:@{NSLocalizedDescriptionKey: @"Compatibility manifest is not a JSON object."}]; + return nil; + } + return (NSDictionary *)object; +} + +static NSArray *LibertyRequiredFiles(NSDictionary *manifest) { + NSMutableArray *files = [NSMutableArray array]; + for (id entry in manifest[@"requiredFiles"]) { + if ([entry isKindOfClass:[NSDictionary class]]) { + id path = ((NSDictionary *)entry)[@"path"]; + if ([path isKindOfClass:[NSString class]]) [files addObject:path]; + } + } + return files; +} + +static NSDictionary *LibertyMatchedRevision(NSDictionary *manifest, NSString *sha256) { + for (id entry in manifest[@"supportedRevisions"]) { + if (![entry isKindOfClass:[NSDictionary class]]) continue; + NSString *expected = ((NSDictionary *)entry)[@"xexSha256"]; + if ([expected isKindOfClass:[NSString class]] && [expected caseInsensitiveCompare:sha256] == NSOrderedSame) return (NSDictionary *)entry; + } + return nil; +} + +@interface LibertyShellViewController : UIViewController +@end + +@implementation LibertyShellViewController { + MTKView *_metalView; + id _commandQueue; + UILabel *_statusLabel; + UILabel *_diagnosticsLabel; + UIButton *_importButton; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = UIColor.blackColor; + id device = MTLCreateSystemDefaultDevice(); + _commandQueue = [device newCommandQueue]; + _metalView = [[MTKView alloc] initWithFrame:CGRectZero device:device]; + _metalView.translatesAutoresizingMaskIntoConstraints = NO; + _metalView.delegate = self; + _metalView.paused = NO; + _metalView.enableSetNeedsDisplay = NO; + _metalView.preferredFramesPerSecond = 30; + _metalView.clearColor = MTLClearColorMake(0.035, 0.045, 0.055, 1.0); + [self.view addSubview:_metalView]; + + UIVisualEffectView *panel = [[UIVisualEffectView alloc] initWithEffect:[UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemMaterialDark]]; + panel.translatesAutoresizingMaskIntoConstraints = NO; + panel.layer.cornerRadius = 20.0; + panel.clipsToBounds = YES; + [self.view addSubview:panel]; + + UILabel *title = [[UILabel alloc] init]; + title.text = @"Liberty Recompiled"; + title.font = [UIFont systemFontOfSize:30 weight:UIFontWeightBold]; + title.textColor = UIColor.whiteColor; + UILabel *subtitle = [[UILabel alloc] init]; + subtitle.text = @"Native ARM64 / Metal foundation"; + subtitle.font = [UIFont systemFontOfSize:15 weight:UIFontWeightMedium]; + subtitle.textColor = UIColor.secondaryLabelColor; + _statusLabel = [[UILabel alloc] init]; + _statusLabel.numberOfLines = 0; + _statusLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold]; + _statusLabel.textColor = UIColor.whiteColor; + _diagnosticsLabel = [[UILabel alloc] init]; + _diagnosticsLabel.numberOfLines = 0; + _diagnosticsLabel.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular]; + _diagnosticsLabel.textColor = UIColor.secondaryLabelColor; + _importButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [_importButton setTitle:@"Import Owned GTA IV Folder" forState:UIControlStateNormal]; + _importButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + _importButton.configuration = [UIButtonConfiguration filledButtonConfiguration]; + [_importButton addTarget:self action:@selector(importPressed) forControlEvents:UIControlEventTouchUpInside]; + UILabel *legal = [[UILabel alloc] init]; + legal.numberOfLines = 0; + legal.text = @"No Rockstar game data is included. Import files only from a legally owned Xbox 360 copy."; + legal.font = [UIFont systemFontOfSize:11 weight:UIFontWeightRegular]; + legal.textColor = UIColor.tertiaryLabelColor; + + UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[title, subtitle, _statusLabel, _diagnosticsLabel, _importButton, legal]]; + stack.translatesAutoresizingMaskIntoConstraints = NO; + stack.axis = UILayoutConstraintAxisVertical; + stack.spacing = 10; + [panel.contentView addSubview:stack]; + + UILayoutGuide *safe = self.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [_metalView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [_metalView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [_metalView.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [_metalView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + [panel.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:24], + [panel.centerYAnchor constraintEqualToAnchor:safe.centerYAnchor], + [panel.widthAnchor constraintLessThanOrEqualToConstant:540], + [stack.leadingAnchor constraintEqualToAnchor:panel.contentView.leadingAnchor constant:22], + [stack.trailingAnchor constraintEqualToAnchor:panel.contentView.trailingAnchor constant:-22], + [stack.topAnchor constraintEqualToAnchor:panel.contentView.topAnchor constant:20], + [stack.bottomAnchor constraintEqualToAnchor:panel.contentView.bottomAnchor constant:-20] + ]]; + + [self configureAudioSession]; + [self prepareContentDirectories]; + [self refreshDiagnostics]; + NSNotificationCenter *nc = NSNotificationCenter.defaultCenter; + [nc addObserver:self selector:@selector(environmentChanged) name:NSProcessInfoThermalStateDidChangeNotification object:nil]; + [nc addObserver:self selector:@selector(environmentChanged) name:GCControllerDidConnectNotification object:nil]; + [nc addObserver:self selector:@selector(environmentChanged) name:GCControllerDidDisconnectNotification object:nil]; + [nc addObserver:self selector:@selector(appDidBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil]; + [nc addObserver:self selector:@selector(appWillResignActive) name:UIApplicationWillResignActiveNotification object:nil]; +} + +- (void)dealloc { [NSNotificationCenter.defaultCenter removeObserver:self]; } + +- (void)configureAudioSession { + AVAudioSession *session = AVAudioSession.sharedInstance; + NSError *error = nil; + if (![session setCategory:AVAudioSessionCategoryAmbient mode:AVAudioSessionModeDefault options:AVAudioSessionCategoryOptionMixWithOthers error:&error]) NSLog(@"[Liberty] AVAudioSession category failed: %@", error); + error = nil; + if (![session setActive:YES error:&error]) NSLog(@"[Liberty] AVAudioSession activation failed: %@", error); +} + +- (void)prepareContentDirectories { + NSURL *appSupport = [[NSFileManager defaultManager] URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask].firstObject; + NSURL *documents = LibertyDocumentsURL(); + try { + liberty::content::ImportedContentProvider provider(std::filesystem::path(appSupport.path.UTF8String), std::filesystem::path(documents.path.UTF8String)); + provider.ensure_layout(); + auto layout = provider.layout(); + NSArray *excluded = @[[NSString stringWithUTF8String:layout.dlc_root.string().c_str()], [NSString stringWithUTF8String:layout.cache_root.string().c_str()]]; + for (NSString *path in excluded) { + NSError *error = nil; + LibertyExcludeFromBackup([NSURL fileURLWithPath:path isDirectory:YES], &error); + if (error) NSLog(@"[Liberty] backup exclusion failed for %@: %@", path, error); + } + } catch (const std::exception& exception) { + NSLog(@"[Liberty] content directory setup failed: %s", exception.what()); + } + [self refreshContentStatus]; +} + +- (void)refreshContentStatus { + NSURL *appSupport = [[NSFileManager defaultManager] URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask].firstObject; + NSURL *documents = LibertyDocumentsURL(); + try { + liberty::content::ImportedContentProvider provider(std::filesystem::path(appSupport.path.UTF8String), std::filesystem::path(documents.path.UTF8String)); + _statusLabel.text = provider.validate_required_files().ok ? @"Game content: imported and structurally valid" : @"Game content: not installed"; + } catch (...) { + _statusLabel.text = @"Game content: unavailable"; + } +} + +- (NSString *)thermalStateText { + switch (NSProcessInfo.processInfo.thermalState) { + case NSProcessInfoThermalStateNominal: return @"nominal"; + case NSProcessInfoThermalStateFair: return @"fair"; + case NSProcessInfoThermalStateSerious: return @"serious"; + case NSProcessInfoThermalStateCritical: return @"critical"; + } + return @"unknown"; +} + +- (void)refreshDiagnostics { + NSString *gpuName = _metalView.device.name ?: @"No Metal device"; + _diagnosticsLabel.text = [NSString stringWithFormat:@"app: %s\nupstream: %.12s\niOS: %@\ndevice: %@\nGPU: %@\nCPU cores: %lu\nthermal: %@\ncontrollers: %lu\ncontent mode: %s", LIBERTY_BUILD_COMMIT, LIBERTY_UPSTREAM_COMMIT, UIDevice.currentDevice.systemVersion, UIDevice.currentDevice.model, gpuName, (unsigned long)NSProcessInfo.processInfo.activeProcessorCount, [self thermalStateText], (unsigned long)GCController.controllers.count, LIBERTY_IOS_CONTENT_MODE]; +} + +- (void)environmentChanged { dispatch_async(dispatch_get_main_queue(), ^{ [self refreshDiagnostics]; }); } +- (void)appDidBecomeActive { _metalView.paused = NO; [self configureAudioSession]; [self refreshDiagnostics]; } +- (void)appWillResignActive { _metalView.paused = YES; [AVAudioSession.sharedInstance setActive:NO error:nil]; } + +- (void)importPressed { + UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeFolder] asCopy:NO]; + picker.delegate = self; + picker.allowsMultipleSelection = NO; + [self presentViewController:picker animated:YES completion:nil]; +} + +- (BOOL)validateFolder:(NSURL *)source manifest:(NSDictionary *)manifest error:(NSError **)error sha256:(NSString **)shaOut matchedRevision:(NSDictionary **)revisionOut { + NSFileManager *fm = NSFileManager.defaultManager; + for (NSString *relative in LibertyRequiredFiles(manifest)) { + NSURL *candidate = [source URLByAppendingPathComponent:relative]; + BOOL isDirectory = NO; + if (![fm fileExistsAtPath:candidate.path isDirectory:&isDirectory] || isDirectory) { + if (error) *error = [NSError errorWithDomain:@"LibertyContent" code:30 userInfo:@{NSLocalizedDescriptionKey:[NSString stringWithFormat:@"Missing required file: %@", relative]}]; + return NO; + } + } + NSString *sha = LibertySHA256([source URLByAppendingPathComponent:@"default.xex"], error); + if (!sha) return NO; + NSDictionary *revision = LibertyMatchedRevision(manifest, sha); + if (!revision && !LIBERTY_IOS_ALLOW_UNPINNED_CONTENT) { + NSString *reason = [manifest[@"supportedRevisions"] count] == 0 ? @"No supported GTA IV executable fingerprint has been configured yet." : @"This default.xex revision is not supported by the current static recompilation build."; + if (error) *error = [NSError errorWithDomain:@"LibertyContent" code:31 userInfo:@{NSLocalizedDescriptionKey:reason, NSLocalizedFailureReasonErrorKey:[NSString stringWithFormat:@"SHA-256: %@", sha]}]; + if (shaOut) *shaOut = sha; + return NO; + } + if (shaOut) *shaOut = sha; + if (revisionOut) *revisionOut = revision; + return YES; +} + +- (BOOL)copyFolderAtomically:(NSURL *)source error:(NSError **)error { + NSFileManager *fm = NSFileManager.defaultManager; + NSURL *base = LibertyApplicationSupportURL(); + NSURL *staging = [base URLByAppendingPathComponent:@"Game.staging" isDirectory:YES]; + NSURL *game = [base URLByAppendingPathComponent:@"Game" isDirectory:YES]; + NSURL *previous = [base URLByAppendingPathComponent:@"Game.previous" isDirectory:YES]; + [fm createDirectoryAtURL:base withIntermediateDirectories:YES attributes:nil error:error]; + if (error && *error) return NO; + [fm removeItemAtURL:staging error:nil]; + [fm removeItemAtURL:previous error:nil]; + if (![fm createDirectoryAtURL:staging withIntermediateDirectories:YES attributes:nil error:error]) return NO; + NSArray *items = [fm contentsOfDirectoryAtURL:source includingPropertiesForKeys:nil options:NSDirectoryEnumerationSkipsHiddenFiles error:error]; + if (!items) { [fm removeItemAtURL:staging error:nil]; return NO; } + for (NSURL *item in items) { + NSURL *destination = [staging URLByAppendingPathComponent:item.lastPathComponent]; + if (![fm copyItemAtURL:item toURL:destination error:error]) { [fm removeItemAtURL:staging error:nil]; return NO; } + } + BOOL hadExisting = [fm fileExistsAtPath:game.path]; + if (hadExisting && ![fm moveItemAtURL:game toURL:previous error:error]) { [fm removeItemAtURL:staging error:nil]; return NO; } + if (![fm moveItemAtURL:staging toURL:game error:error]) { + if (hadExisting) [fm moveItemAtURL:previous toURL:game error:nil]; + return NO; + } + [fm removeItemAtURL:previous error:nil]; + NSError *backupError = nil; + LibertyExcludeFromBackup(game, &backupError); + if (backupError) NSLog(@"[Liberty] could not exclude Game from backup: %@", backupError); + return YES; +} + +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { + NSURL *source = urls.firstObject; + if (!source) return; + _importButton.enabled = NO; + _statusLabel.text = @"Checking selected GTA IV folder…"; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + BOOL scoped = [source startAccessingSecurityScopedResource]; + NSError *error = nil; + NSDictionary *manifest = LibertyCompatibilityManifest(&error); + NSString *sha = nil; + NSDictionary *revision = nil; + BOOL valid = manifest != nil && [self validateFolder:source manifest:manifest error:&error sha256:&sha matchedRevision:&revision]; + if (valid) { + unsigned long long sourceBytes = LibertyDirectorySize(source); + NSURL *base = LibertyApplicationSupportURL(); + [NSFileManager.defaultManager createDirectoryAtURL:base withIntermediateDirectories:YES attributes:nil error:&error]; + NSNumber *available = nil; + if (!error) [base getResourceValue:&available forKey:NSURLVolumeAvailableCapacityForImportantUsageKey error:&error]; + const unsigned long long reserve = 256ull * 1024ull * 1024ull; + if (!error && available && available.unsignedLongLongValue < sourceBytes + reserve) { + error = [NSError errorWithDomain:@"LibertyContent" code:32 userInfo:@{NSLocalizedDescriptionKey:@"Not enough free space to import the selected game folder safely."}]; + valid = NO; + } + } + if (valid) { + dispatch_async(dispatch_get_main_queue(), ^{ self->_statusLabel.text = @"Importing game data…"; }); + valid = [self copyFolderAtomically:source error:&error]; + } + if (scoped) [source stopAccessingSecurityScopedResource]; + dispatch_async(dispatch_get_main_queue(), ^{ + self->_importButton.enabled = YES; + if (valid) { + NSString *revisionName = revision[@"id"] ?: revision[@"name"] ?: @"development revision"; + self->_statusLabel.text = [NSString stringWithFormat:@"Game content imported (%@)", revisionName]; + [self refreshContentStatus]; + } else { + NSString *message = error.localizedDescription ?: @"Import failed."; + if (sha.length > 0 && error.code == 31) message = [message stringByAppendingFormat:@"\nExecutable SHA-256:\n%@", sha]; + self->_statusLabel.text = message; + } + }); + }); +} + +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller { _statusLabel.text = @"Import cancelled."; } +- (void)mtkView:(MTKView *)view drawableSizeWillChange:(CGSize)size {} +- (void)drawInMTKView:(MTKView *)view { + if (!_commandQueue || !view.currentDrawable || !view.currentRenderPassDescriptor) return; + id commandBuffer = [_commandQueue commandBuffer]; + commandBuffer.label = @"Liberty Foundation Frame"; + id encoder = [commandBuffer renderCommandEncoderWithDescriptor:view.currentRenderPassDescriptor]; + encoder.label = @"Foundation Clear"; + [encoder endEncoding]; + [commandBuffer presentDrawable:view.currentDrawable]; + [commandBuffer commit]; +} +@end + +@interface LibertyAppDelegate : UIResponder +@property(nonatomic, strong) UIWindow *window; +@end +@implementation LibertyAppDelegate +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; + self.window.rootViewController = [[LibertyShellViewController alloc] init]; + [self.window makeKeyAndVisible]; + NSLog(@"[Liberty] Foundation shell launched. app=%s upstream=%s", LIBERTY_BUILD_COMMIT, LIBERTY_UPSTREAM_COMMIT); + return YES; +} +- (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window { return UIInterfaceOrientationMaskLandscape; } +- (void)applicationDidReceiveMemoryWarning:(UIApplication *)application { NSLog(@"[Liberty] iOS memory warning received."); } +@end + +int main(int argc, char *argv[]) { + @autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass(LibertyAppDelegate.class)); } +} diff --git a/toolchains/ios.cmake b/toolchains/ios.cmake new file mode 100644 index 0000000..0b31799 --- /dev/null +++ b/toolchains/ios.cmake @@ -0,0 +1,16 @@ +# Native iOS ARM64 toolchain for the Foundation shell. +# A macOS host with Xcode is required. + +set(CMAKE_SYSTEM_NAME iOS) +set(CMAKE_SYSTEM_PROCESSOR arm64) + +set(CMAKE_OSX_DEPLOYMENT_TARGET "16.0" CACHE STRING "Minimum supported iOS version") +set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "iOS device architecture") + +if(NOT DEFINED CMAKE_OSX_SYSROOT) + set(CMAKE_OSX_SYSROOT "iphoneos" CACHE STRING "Apple SDK") +endif() + +if(NOT CMAKE_GENERATOR STREQUAL "Xcode") + message(WARNING "The iOS target is intended to use the Xcode generator.") +endif() From 8b857695981f8f73e41b8b8ab2fe1c84a2b175bc Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Fri, 11 Sep 2026 12:59:37 -0400 Subject: [PATCH 3/6] Fix macOS upstream fetch compatibility --- scripts/fetch-upstream.sh | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/scripts/fetch-upstream.sh b/scripts/fetch-upstream.sh index 67dee75..0ec809a 100755 --- a/scripts/fetch-upstream.sh +++ b/scripts/fetch-upstream.sh @@ -40,17 +40,23 @@ done [[ -f "${LOCK_FILE}" ]] || { echo "error: missing ${LOCK_FILE}" >&2; exit 2; } -readarray -t LOCK_VALUES < <(python3 - "${LOCK_FILE}" <<'PY' +# Keep this compatible with the Bash 3.2 that ships on many macOS/Xcode hosts; +# readarray/mapfile were added in later Bash versions. +UPSTREAM_REPO="$(python3 - "${LOCK_FILE}" <<'PY' import json, sys with open(sys.argv[1], "r", encoding="utf-8") as f: - data = json.load(f) -print(data["repository"]) -print(data["commit"]) + print(json.load(f)["repository"]) PY -) +)" +UPSTREAM_COMMIT="$(python3 - "${LOCK_FILE}" <<'PY' +import json, sys +with open(sys.argv[1], "r", encoding="utf-8") as f: + print(json.load(f)["commit"]) +PY +)" -UPSTREAM_REPO="${LOCK_VALUES[0]}" -UPSTREAM_COMMIT="${LOCK_VALUES[1]}" +[[ -n "${UPSTREAM_REPO}" ]] || { echo "error: upstream repository is empty in ${LOCK_FILE}" >&2; exit 2; } +[[ "${UPSTREAM_COMMIT}" =~ ^[0-9a-fA-F]{40}$ ]] || { echo "error: upstream commit in ${LOCK_FILE} is not a 40-character Git SHA" >&2; exit 2; } mkdir -p "$(dirname "${DESTINATION}")" From 03a46b941e03208a7dcc7f41819616ac300786ac Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Fri, 11 Sep 2026 12:59:54 -0400 Subject: [PATCH 4/6] Fix iOS shell CoreGraphics linkage --- CMakeLists.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c79ba9b..8af2222 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,12 +68,13 @@ if(CMAKE_SYSTEM_NAME STREQUAL "iOS") find_library(UIKIT_FRAMEWORK UIKit REQUIRED) find_library(FOUNDATION_FRAMEWORK Foundation REQUIRED) + find_library(COREGRAPHICS_FRAMEWORK CoreGraphics REQUIRED) find_library(METAL_FRAMEWORK Metal REQUIRED) find_library(METALKIT_FRAMEWORK MetalKit REQUIRED) find_library(AVFOUNDATION_FRAMEWORK AVFoundation REQUIRED) find_library(GAMECONTROLLER_FRAMEWORK GameController REQUIRED) find_library(UNIFORMTYPEIDENTIFIERS_FRAMEWORK UniformTypeIdentifiers REQUIRED) - target_link_libraries(LibertyRecompIOS PRIVATE "${UIKIT_FRAMEWORK}" "${FOUNDATION_FRAMEWORK}" "${METAL_FRAMEWORK}" "${METALKIT_FRAMEWORK}" "${AVFOUNDATION_FRAMEWORK}" "${GAMECONTROLLER_FRAMEWORK}" "${UNIFORMTYPEIDENTIFIERS_FRAMEWORK}") + target_link_libraries(LibertyRecompIOS PRIVATE "${UIKIT_FRAMEWORK}" "${FOUNDATION_FRAMEWORK}" "${COREGRAPHICS_FRAMEWORK}" "${METAL_FRAMEWORK}" "${METALKIT_FRAMEWORK}" "${AVFOUNDATION_FRAMEWORK}" "${GAMECONTROLLER_FRAMEWORK}" "${UNIFORMTYPEIDENTIFIERS_FRAMEWORK}") if(LIBERTY_IOS_GAMECENTER) find_library(GAMEKIT_FRAMEWORK GameKit REQUIRED) From e09d67be7ab3d61a38baffc95925d34c3319db0c Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Fri, 11 Sep 2026 13:03:11 -0400 Subject: [PATCH 5/6] CI: build Foundation on macOS and avoid duplicate branch runs --- .github/workflows/apple.yml | 19 +++++++++++++++++-- .github/workflows/foundation.yml | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index a2cceeb..4b64002 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -2,7 +2,7 @@ name: Apple Foundation Build on: push: - branches: [main, foundation-stages-0-5] + branches: [main] pull_request: workflow_dispatch: @@ -10,6 +10,21 @@ permissions: contents: read jobs: + macos-foundation-reference: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - name: Host architecture and Xcode version + run: | + uname -m + xcodebuild -version + - name: Configure macOS foundation reference + run: cmake --preset host-debug + - name: Compile macOS foundation reference + run: cmake --build --preset host-debug --parallel 2 + - name: Run macOS foundation tests + run: ctest --preset host-debug + ios-arm64-shell: runs-on: macos-14 steps: @@ -26,7 +41,7 @@ jobs: test -n "$APP" echo "Built: $APP" - macos-reference-source: + upstream-reference-source: runs-on: macos-14 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/foundation.yml b/.github/workflows/foundation.yml index 7962d12..c45d5c5 100644 --- a/.github/workflows/foundation.yml +++ b/.github/workflows/foundation.yml @@ -2,7 +2,7 @@ name: Foundation CI on: push: - branches: [main, foundation-stages-0-5] + branches: [main] pull_request: workflow_dispatch: From 453444a92a1ac271baf37c8ba2f91322231e728e Mon Sep 17 00:00:00 2001 From: matthewcodergamer Date: Fri, 11 Sep 2026 13:07:21 -0400 Subject: [PATCH 6/6] Stage 0: add focused issue templates --- .github/ISSUE_TEMPLATE/compatibility.yml | 65 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 2 + .github/ISSUE_TEMPLATE/cpu-runtime.yml | 58 ++++++++++++++++++++ .github/ISSUE_TEMPLATE/ios-platform.yml | 70 ++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/performance.yml | 61 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/renderer.yml | 62 +++++++++++++++++++++ 6 files changed, 318 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/compatibility.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/cpu-runtime.yml create mode 100644 .github/ISSUE_TEMPLATE/ios-platform.yml create mode 100644 .github/ISSUE_TEMPLATE/performance.yml create mode 100644 .github/ISSUE_TEMPLATE/renderer.yml diff --git a/.github/ISSUE_TEMPLATE/compatibility.yml b/.github/ISSUE_TEMPLATE/compatibility.yml new file mode 100644 index 0000000..968ecc7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility.yml @@ -0,0 +1,65 @@ +name: Compatibility bug +description: Report a reproducible GTA IV gameplay, mission, world, save, or content compatibility failure. +title: "[Compatibility] " +body: + - type: markdown + attributes: + value: | + Describe behavior without uploading Rockstar assets, saves containing proprietary payloads, executables, or archives. + - type: input + id: commit + attributes: + label: LibertyRecomp-iOS commit + placeholder: 40-character commit SHA + validations: + required: true + - type: input + id: revision + attributes: + label: Supported game revision ID / executable SHA-256 + description: Hashes/IDs are okay; do not upload the executable. + validations: + required: true + - type: input + id: location + attributes: + label: Mission / area / gameplay system + placeholder: Opening mission, Broker street streaming, save/load, vehicle physics, etc. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + description: Give the shortest deterministic sequence from a known state. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Xbox 360 behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: dropdown + id: severity + attributes: + label: Severity + options: + - Progression blocker + - Major gameplay break + - Partial / intermittent + - Cosmetic / minor + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Diagnostics / first known divergence + description: Guest PC, asset/resource ID, collision trace, renderer trace, save error, or other diagnostic evidence. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8005e32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/cpu-runtime.yml b/.github/ISSUE_TEMPLATE/cpu-runtime.yml new file mode 100644 index 0000000..5830a3c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/cpu-runtime.yml @@ -0,0 +1,58 @@ +name: CPU / runtime bug +description: Report a static-recomp, PowerPC semantic, guest-memory, or runtime divergence. +title: "[CPU/Runtime] " +body: + - type: markdown + attributes: + value: | + Use this for low-level runtime failures. Do not attach Rockstar game files, `default.xex`, RPF archives, keys, or signing material. + - type: input + id: ios_commit + attributes: + label: LibertyRecomp-iOS commit + description: Exact commit SHA that reproduced the problem. + placeholder: 40-character commit SHA + validations: + required: true + - type: input + id: upstream_commit + attributes: + label: Pinned LibertyRecomp commit + placeholder: 40-character upstream commit SHA + validations: + required: true + - type: input + id: revision + attributes: + label: Game revision ID / executable SHA-256 + description: Hashes/IDs are okay; do not upload the executable itself. + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + description: Smallest deterministic sequence that triggers the failure. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Xbox 360 behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: First divergence and diagnostics + description: Include guest PC/LR, instruction, thread, memory address, assertion, or trace if available. + render: text + - type: textarea + id: regression + attributes: + label: Regression-test idea + description: If the root cause is known, describe the smallest test that should permanently cover it. diff --git a/.github/ISSUE_TEMPLATE/ios-platform.yml b/.github/ISSUE_TEMPLATE/ios-platform.yml new file mode 100644 index 0000000..04e39bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ios-platform.yml @@ -0,0 +1,70 @@ +name: iOS platform bug +description: Report Apple-platform lifecycle, storage, audio-session, controller, signing, or shell issues. +title: "[iOS] " +body: + - type: markdown + attributes: + value: | + Use this for native iOS platform behavior. Do not include certificates, provisioning profiles, private keys, or game files. + - type: input + id: commit + attributes: + label: LibertyRecomp-iOS commit + placeholder: 40-character commit SHA + validations: + required: true + - type: input + id: device + attributes: + label: Device + placeholder: iPhone 11 / Apple A13 + validations: + required: true + - type: input + id: ios + attributes: + label: iOS version + placeholder: iOS 16.x / 17.x / 18.x + validations: + required: true + - type: dropdown + id: area + attributes: + label: Platform area + options: + - App launch / lifecycle + - Background / foreground + - Files / storage + - Audio session + - Controller discovery + - Thermal / memory pressure + - Orientation / safe area + - Signing / installation + - Diagnostics / logging + - Other + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs + description: Redact identifiers and secrets before posting. + render: text diff --git a/.github/ISSUE_TEMPLATE/performance.yml b/.github/ISSUE_TEMPLATE/performance.yml new file mode 100644 index 0000000..e800b35 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/performance.yml @@ -0,0 +1,61 @@ +name: Performance issue +description: Report measurable CPU, GPU, memory, I/O, shader, or thermal regressions. +title: "[Performance] " +body: + - type: markdown + attributes: + value: | + Performance reports need measurements. iPhone 11 / A13 is the baseline target; avoid conclusions from simulator timing. + - type: input + id: commit + attributes: + label: LibertyRecomp-iOS commit + placeholder: 40-character commit SHA + validations: + required: true + - type: input + id: device + attributes: + label: Physical device + placeholder: iPhone 11 / Apple A13 + validations: + required: true + - type: input + id: ios + attributes: + label: iOS version + validations: + required: true + - type: input + id: scene + attributes: + label: Scene / workload + description: Area, mission, menu, driving route, or synthetic test. + validations: + required: true + - type: textarea + id: settings + attributes: + label: Graphics/runtime settings + description: Resolution scale, frame cap, quality settings, controller state, mods, and other relevant settings. + validations: + required: true + - type: textarea + id: metrics + attributes: + label: Measurements + description: FPS/frame time, CPU time, GPU time, resident memory, streaming latency, shader stalls, thermal state, and duration where available. + render: text + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + validations: + required: true + - type: textarea + id: profile + attributes: + label: Profiling evidence + description: Summarize Instruments/Xcode GPU capture findings without uploading copyrighted game data. diff --git a/.github/ISSUE_TEMPLATE/renderer.yml b/.github/ISSUE_TEMPLATE/renderer.yml new file mode 100644 index 0000000..8bb4944 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/renderer.yml @@ -0,0 +1,62 @@ +name: Renderer bug +description: Report Metal/Xenos rendering, resource-conversion, shader, or presentation problems. +title: "[Renderer] " +body: + - type: markdown + attributes: + value: | + Keep reports focused on the first rendering divergence. Do not attach copyrighted game payloads. + - type: input + id: commit + attributes: + label: LibertyRecomp-iOS commit + placeholder: 40-character commit SHA + validations: + required: true + - type: input + id: device + attributes: + label: Device / macOS reference hardware + placeholder: iPhone 11 / Apple A13, or Mac model + validations: + required: true + - type: dropdown + id: stage + attributes: + label: Rendering stage + options: + - Metal shell clear/present + - Resource upload/conversion + - Shader translation + - Pipeline state + - GTA draw submission + - Depth/stencil/blending + - Texture/sampler + - Post-processing/presentation + - Unknown + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected result + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual result + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Diagnostics + description: Metal validation output, debug labels, draw/resource IDs, pipeline keys, or relevant logs. + render: text