diff --git a/.gitignore b/.gitignore index 4459374..ca996da 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,6 @@ CMakeUserPresets.json /.vs/ /.vscode/ /.cache/ -/scripts/__pycache__/ -/scripts/extract_eb_big.py -/scripts/extract_embedded_rx2.py + +# Local developer scripts (texture decoding, extraction, bootstrap helpers) +/scripts/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 53a7a4f..aa53d6c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -66,6 +66,9 @@ set(REXSDK_DIR set(SKATE3_GAME_DATA_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/game" CACHE PATH "Path to an extracted Skate 3 retail game dump") +set(SKATE3_TITLE_UPDATE_PACKAGE + "" + CACHE FILEPATH "Optional Skate 3 title update STFS package used for codegen") if(NOT APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "AMD64|x86_64") if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") @@ -86,6 +89,79 @@ endif() file(TO_CMAKE_PATH "${CMAKE_CURRENT_SOURCE_DIR}" SKATE3_SOURCE_DIR_CONFIG) file(TO_CMAKE_PATH "${SKATE3_GAME_DATA_ROOT}" SKATE3_GAME_DATA_ROOT_CONFIG) +set(SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE "${SKATE3_TITLE_UPDATE_PACKAGE}") +if(SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE STREQUAL "") + set(SKATE3_LOCAL_TITLE_UPDATE_PACKAGE + "${CMAKE_CURRENT_SOURCE_DIR}/TU_12K2276_000000C000000.00000000000O3") + if(EXISTS "${SKATE3_LOCAL_TITLE_UPDATE_PACKAGE}") + set(SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE "${SKATE3_LOCAL_TITLE_UPDATE_PACKAGE}") + endif() +endif() + +set(SKATE3_PATCHED_DEFAULT_XEX_LINE "") +set(SKATE3_PATCHED_EAWEBKIT_XEX_LINE "") +set(SKATE3_CODEGEN_ARGS "") +set(SKATE3_FUNCTION_CONFIG_INCLUDES + " \"${SKATE3_SOURCE_DIR_CONFIG}/config/skate3_functions.toml\",") +find_package(Python3 REQUIRED COMPONENTS Interpreter) +if(NOT SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE STREQUAL "") + if(NOT EXISTS "${SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE}") + message(FATAL_ERROR + "SKATE3_TITLE_UPDATE_PACKAGE does not exist: ${SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE}") + endif() + + set(SKATE3_TITLE_UPDATE_CODEGEN_ROOT + "${CMAKE_CURRENT_BINARY_DIR}/title_update_codegen") + set(SKATE3_TITLE_UPDATE_DEFAULT_XEX + "${SKATE3_TITLE_UPDATE_CODEGEN_ROOT}/default.xex") + set(SKATE3_TITLE_UPDATE_DEFAULT_XEXP + "${SKATE3_TITLE_UPDATE_CODEGEN_ROOT}/default.xexp") + set(SKATE3_TITLE_UPDATE_EAWEBKIT_XEX + "${SKATE3_TITLE_UPDATE_CODEGEN_ROOT}/data/webkit/EAWebkit.xex") + set(SKATE3_TITLE_UPDATE_EAWEBKIT_XEXP + "${SKATE3_TITLE_UPDATE_CODEGEN_ROOT}/data/webkit/EAWebkit.xexp") + + add_custom_command( + OUTPUT + "${SKATE3_TITLE_UPDATE_DEFAULT_XEX}" + "${SKATE3_TITLE_UPDATE_DEFAULT_XEXP}" + "${SKATE3_TITLE_UPDATE_EAWEBKIT_XEX}" + "${SKATE3_TITLE_UPDATE_EAWEBKIT_XEXP}" + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ExtractTitleUpdateXexp.py" + --tu "${SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE}" + --game-root "${SKATE3_GAME_DATA_ROOT}" + --out-root "${SKATE3_TITLE_UPDATE_CODEGEN_ROOT}" + DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ExtractTitleUpdateXexp.py" + "${SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE}" + "${SKATE3_GAME_DATA_ROOT}/default.xex" + "${SKATE3_GAME_DATA_ROOT}/data/webkit/EAWebkit.xex" + COMMENT "Staging Skate 3 title update patches for codegen" + VERBATIM + ) + add_custom_target(skate3-title-update-codegen-inputs + DEPENDS + "${SKATE3_TITLE_UPDATE_DEFAULT_XEX}" + "${SKATE3_TITLE_UPDATE_DEFAULT_XEXP}" + "${SKATE3_TITLE_UPDATE_EAWEBKIT_XEX}" + "${SKATE3_TITLE_UPDATE_EAWEBKIT_XEXP}" + ) + + file(TO_CMAKE_PATH "${SKATE3_TITLE_UPDATE_DEFAULT_XEX}" + SKATE3_TITLE_UPDATE_DEFAULT_XEX_CONFIG) + file(TO_CMAKE_PATH "${SKATE3_TITLE_UPDATE_EAWEBKIT_XEX}" + SKATE3_TITLE_UPDATE_EAWEBKIT_XEX_CONFIG) + set(SKATE3_PATCHED_DEFAULT_XEX_LINE + "patched_file_path = \"${SKATE3_TITLE_UPDATE_DEFAULT_XEX_CONFIG}\"") + set(SKATE3_PATCHED_EAWEBKIT_XEX_LINE + "patched_file_path = \"${SKATE3_TITLE_UPDATE_EAWEBKIT_XEX_CONFIG}\"") + set(SKATE3_FUNCTION_CONFIG_INCLUDES + " \"${SKATE3_SOURCE_DIR_CONFIG}/config/skate3_tu_functions.toml\",") + message(STATUS + "Skate 3 title update codegen enabled: ${SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE}") +endif() + file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/manifests") configure_file( @@ -109,7 +185,9 @@ set(SKATE3_COMMON_SOURCES src/skate3_app_common.cpp src/skate3_fov.cpp src/skate3_iso_installer.cpp + src/skate3_title_update_installer.cpp src/skate3_user_settings.cpp + third_party/rexglue-sdk/thirdparty/crypto/sha256.cpp ) if(APPLE) list(APPEND SKATE3_COMMON_SOURCES src/skate3_iso_installer_macos.mm) @@ -138,6 +216,11 @@ target_include_directories(skate3 PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/include ) target_compile_definitions(skate3 PRIVATE SKATE3_BUILD_CONFIG="$") +if(NOT SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE STREQUAL "") + target_compile_definitions(skate3 PRIVATE SKATE3_HAS_TITLE_UPDATE=1) +else() + target_compile_definitions(skate3 PRIVATE SKATE3_HAS_TITLE_UPDATE=0) +endif() if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/generated/sources.cmake") include(generated/sources.cmake) @@ -167,7 +250,7 @@ if(APPLE) endif() if(WIN32) - target_link_libraries(skate3 PRIVATE comdlg32) + target_link_libraries(skate3 PRIVATE comdlg32 winhttp) endif() add_custom_target(skate3_stage_runtime_library @@ -180,6 +263,22 @@ add_custom_target(skate3_stage_runtime_library ) add_dependencies(skate3 skate3_stage_runtime_library) +if(NOT SKATE3_EFFECTIVE_TITLE_UPDATE_PACKAGE STREQUAL "") + add_custom_target(skate3_stage_title_update_patches + COMMAND ${CMAKE_COMMAND} -E make_directory + "$/game/data/webkit" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${SKATE3_TITLE_UPDATE_DEFAULT_XEXP}" + "$/game/default.xexp" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${SKATE3_TITLE_UPDATE_EAWEBKIT_XEXP}" + "$/game/data/webkit/EAWebkit.xexp" + DEPENDS skate3-title-update-codegen-inputs + VERBATIM + ) + add_dependencies(skate3 skate3_stage_title_update_patches) +endif() + if(MSVC) target_compile_options(skate3 PRIVATE /utf-8) else() diff --git a/cmake/ApplySkate3CodegenPatches.cmake b/cmake/ApplySkate3CodegenPatches.cmake index 6361fa9..aef82db 100644 --- a/cmake/ApplySkate3CodegenPatches.cmake +++ b/cmake/ApplySkate3CodegenPatches.cmake @@ -2,85 +2,87 @@ if(NOT DEFINED SKATE3_SOURCE_DIR) message(FATAL_ERROR "SKATE3_SOURCE_DIR is required") endif() -set(_skate3_recomp_39 "${SKATE3_SOURCE_DIR}/generated/skate3_recomp.39.cpp") -if(NOT EXISTS "${_skate3_recomp_39}") - message(FATAL_ERROR "Expected generated file not found: ${_skate3_recomp_39}") +file(GLOB _skate3_recomp_files + LIST_DIRECTORIES false + "${SKATE3_SOURCE_DIR}/generated/skate3_recomp.*.cpp") +if(NOT _skate3_recomp_files) + message(FATAL_ERROR "No generated Skate 3 recompilation files found") endif() -file(READ "${_skate3_recomp_39}" _contents) - -if(NOT _contents MATCHES "#include \"skate3_ultrawide_guest\\.h\"") - string(REPLACE - "#include \"skate3_init.h\"\n" - "#include \"skate3_init.h\"\n#include \"skate3_ultrawide_guest.h\"\n" - _contents - "${_contents}") -endif() - -set(_frustum_call -" // bl 0x827f2330 - ctx.lr = 0x827F2AE8; - sub_827F2330(ctx, base);") - -set(_frustum_patch -" // bl 0x827f2330 - ctx.lr = 0x827F2AE8; - Skate3UltrawideGameFrustumPatchScope skate3_ultrawide_game_frustum_patch_scope( - ctx, base, ctx.r4.u32); - sub_827F2330(ctx, base);") - -if(_contents MATCHES "Skate3UltrawideGameFrustumPatchScope") - message(STATUS "Skate 3 generated frustum patch already applied") -elseif(_contents MATCHES "sub_827F2330\\(ctx, base\\);") - string(REPLACE "${_frustum_call}" "${_frustum_patch}" _patched "${_contents}") - if(_patched STREQUAL _contents) - message(FATAL_ERROR "Failed to apply Skate 3 generated frustum patch; expected call-site context changed") +function(_skate3_add_include _contents_var _include) + set(_contents "${${_contents_var}}") + if(NOT _contents MATCHES "#include \"${_include}\"") + string(REPLACE + "#include \"skate3_init.h\"\n" + "#include \"skate3_init.h\"\n#include \"${_include}\"\n" + _contents + "${_contents}") endif() - set(_contents "${_patched}") -else() - message(FATAL_ERROR "Failed to apply Skate 3 generated frustum patch; call to sub_827F2330 not found") -endif() + set(${_contents_var} "${_contents}" PARENT_SCOPE) +endfunction() -file(WRITE "${_skate3_recomp_39}" "${_contents}") -message(STATUS "Applied Skate 3 generated frustum patch") - -set(_skate3_recomp_38 "${SKATE3_SOURCE_DIR}/generated/skate3_recomp.38.cpp") -if(NOT EXISTS "${_skate3_recomp_38}") - message(FATAL_ERROR "Expected generated file not found: ${_skate3_recomp_38}") -endif() - -file(READ "${_skate3_recomp_38}" _contents) - -if(NOT _contents MATCHES "#include \"skate3_fov\\.h\"") - string(REPLACE - "#include \"skate3_init.h\"\n" - "#include \"skate3_init.h\"\n#include \"skate3_fov.h\"\n" - _contents - "${_contents}") -endif() - -set(_projection_fov_site -"DEFINE_REX_FUNC(sub_827DE960) { - REX_FUNC_PROLOGUE(); - PPCRegister temp{}; - uint32_t ea{};") - -set(_projection_fov_patch -"DEFINE_REX_FUNC(sub_827DE960) { - REX_FUNC_PROLOGUE(); - PPCRegister temp{}; - uint32_t ea{}; - ctx.f1.f64 = double(Skate3MaybeOverrideProjectionFovRadians(float(ctx.f1.f64)));") - -if(_contents MATCHES "Skate3MaybeOverrideProjectionFovRadians") - message(STATUS "Skate 3 generated projection FOV patch already applied") -else() - string(REPLACE "${_projection_fov_site}" "${_projection_fov_patch}" _patched "${_contents}") - if(_patched STREQUAL _contents) - message(FATAL_ERROR "Failed to apply Skate 3 generated projection FOV patch; expected function prologue changed") +set(_frustum_patched FALSE) +foreach(_file IN LISTS _skate3_recomp_files) + file(READ "${_file}" _contents) + if(_contents MATCHES "Skate3UltrawideGameFrustumPatchScope") + set(_frustum_patched TRUE) + break() endif() - set(_contents "${_patched}") + string(FIND "${_contents}" "ctx.r6.u64 = REX_LOAD_U32(ctx.r4.u32 + 5260);" _frustum_anchor) + if(_frustum_anchor EQUAL -1) + continue() + endif() + + string(SUBSTRING "${_contents}" ${_frustum_anchor} 12000 _frustum_window) + string(REGEX MATCH + "\t// bl 0x[0-9a-fA-F]+\n\tctx\\.lr = 0x[0-9A-F]+;\n\tsub_[0-9A-F]+\\(ctx, base\\);" + _frustum_call + "${_frustum_window}") + if(_frustum_call STREQUAL "") + message(FATAL_ERROR "Failed to apply Skate 3 generated frustum patch; call near frustum anchor not found in ${_file}") + endif() + + string(REGEX REPLACE + "(\tctx\\.lr = 0x[0-9A-F]+;\n)" + "\\1\tSkate3UltrawideGameFrustumPatchScope skate3_ultrawide_game_frustum_patch_scope(\n\t\tctx, base, ctx.r4.u32);\n" + _frustum_patch + "${_frustum_call}") + string(REPLACE "${_frustum_call}" "${_frustum_patch}" _contents "${_contents}") + _skate3_add_include(_contents "skate3_ultrawide_guest.h") + file(WRITE "${_file}" "${_contents}") + set(_frustum_patched TRUE) + message(STATUS "Applied Skate 3 generated frustum patch in ${_file}") + break() +endforeach() +if(NOT _frustum_patched) + message(FATAL_ERROR "Failed to apply Skate 3 generated frustum patch; frustum anchor not found") endif() -file(WRITE "${_skate3_recomp_38}" "${_contents}") -message(STATUS "Applied Skate 3 generated projection FOV patch") +set(_fov_patched FALSE) +foreach(_file IN LISTS _skate3_recomp_files) + file(READ "${_file}" _contents) + if(_contents MATCHES "Skate3MaybeOverrideProjectionFovRadians") + set(_fov_patched TRUE) + break() + endif() + if(NOT _contents MATCHES "ctx\\.f27\\.f64 = ctx\\.f1\\.f64;") + continue() + endif() + if(NOT _contents MATCHES "ctx\\.f4\\.f64 = double\\(float\\(ctx\\.f1\\.f64 \\* ctx\\.f0\\.f64\\)\\);") + continue() + endif() + + set(_projection_fov_site "ctx.f27.f64 = ctx.f1.f64;") + set(_projection_fov_patch +"ctx.f1.f64 = double(Skate3MaybeOverrideProjectionFovRadians(float(ctx.f1.f64))); + ctx.f27.f64 = ctx.f1.f64;") + string(REPLACE "${_projection_fov_site}" "${_projection_fov_patch}" _contents "${_contents}") + _skate3_add_include(_contents "skate3_fov.h") + file(WRITE "${_file}" "${_contents}") + set(_fov_patched TRUE) + message(STATUS "Applied Skate 3 generated projection FOV patch in ${_file}") + break() +endforeach() +if(NOT _fov_patched) + message(FATAL_ERROR "Failed to apply Skate 3 generated projection FOV patch; projection FOV anchor not found") +endif() diff --git a/cmake/CodegenTargets.cmake b/cmake/CodegenTargets.cmake index 52182d6..9aa47b4 100644 --- a/cmake/CodegenTargets.cmake +++ b/cmake/CodegenTargets.cmake @@ -1,5 +1,6 @@ add_custom_target(generate-skate3 COMMAND $ codegen + ${SKATE3_CODEGEN_ARGS} "${CMAKE_CURRENT_BINARY_DIR}/manifests/skate3.toml" COMMAND "${CMAKE_COMMAND}" "-DSKATE3_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}" @@ -11,12 +12,18 @@ add_custom_target(generate-skate3 add_custom_target(generate-eawebkit COMMAND $ codegen + ${SKATE3_CODEGEN_ARGS} "${CMAKE_CURRENT_BINARY_DIR}/manifests/eawebkit.toml" WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" COMMENT "Generating recompiled code for EAWebkit.xex" VERBATIM ) +if(TARGET skate3-title-update-codegen-inputs) + add_dependencies(generate-skate3 skate3-title-update-codegen-inputs) + add_dependencies(generate-eawebkit skate3-title-update-codegen-inputs) +endif() + add_dependencies(generate-skate3 generate-eawebkit) add_custom_target(generate-all diff --git a/cmake/ExtractTitleUpdateXexp.py b/cmake/ExtractTitleUpdateXexp.py new file mode 100644 index 0000000..8f02de6 --- /dev/null +++ b/cmake/ExtractTitleUpdateXexp.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 + +import argparse +from pathlib import Path +import shutil +import struct +import sys + + +BLOCK_SIZE = 0x1000 +END_OF_CHAIN = 0xFFFFFF +EXPECTED_FILES = { + "default.xexp", + "data/webkit/EAWebkit.xexp", +} + + +def be16(data, offset): + return struct.unpack_from(">H", data, offset)[0] + + +def be32(data, offset): + return struct.unpack_from(">I", data, offset)[0] + + +def u24le(data, offset): + return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) + + +def round_up(value, alignment): + return (value + alignment - 1) & ~(alignment - 1) + + +class StfsReader: + def __init__(self, package_path): + self.package_path = package_path + self.data = package_path.read_bytes() + self.header_size = be32(self.data, 0x340) + self.metadata_offset = 0x344 + self.volume_descriptor_offset = self.metadata_offset + 0x35 + self.volume_type = be32(self.data, self.metadata_offset + 0x65) + if self.data[:4] not in (b"CON ", b"LIVE", b"PIRS"): + raise ValueError(f"{package_path} is not an STFS package") + if self.volume_type != 0: + raise ValueError(f"{package_path} is not an STFS volume") + flags = self.data[self.volume_descriptor_offset + 2] + self.blocks_per_hash_table = 1 if (flags & 1) else 2 + + def block_to_offset(self, block_index): + block = block_index + base = 170 + for _ in range(3): + block += ((block_index + base) // base) * self.blocks_per_hash_table + if block_index < base: + break + base *= 170 + return round_up(self.header_size, BLOCK_SIZE) + (block << 12) + + def hash_block_number(self, block_index, hash_level): + block_step0 = 170 + self.blocks_per_hash_table + block_step1 = 28900 + ((170 + 1) * self.blocks_per_hash_table) + if hash_level == 0: + if block_index < 170: + return 0 + block = (block_index // 170) * block_step0 + block += ((block_index // 28900) + 1) * self.blocks_per_hash_table + if block_index < 28900: + return block + return block + self.blocks_per_hash_table + if hash_level == 1: + if block_index < 28900: + return block_step0 + block = (block_index // 28900) * block_step1 + return block + self.blocks_per_hash_table + return block_step1 + + def hash_offset(self, block_index, hash_level): + return round_up(self.header_size, BLOCK_SIZE) + ( + self.hash_block_number(block_index, hash_level) << 12 + ) + + def next_block(self, block_index): + hash_offset = self.hash_offset(block_index, 0) + entry_offset = hash_offset + (block_index % 170) * 0x18 + return be32(self.data, entry_offset + 0x14) & 0xFFFFFF + + def read_file(self, start_block, size): + output = bytearray() + block_index = start_block + remaining = size + while remaining and block_index != END_OF_CHAIN: + chunk_size = min(BLOCK_SIZE, remaining) + offset = self.block_to_offset(block_index) + output.extend(self.data[offset : offset + chunk_size]) + remaining -= chunk_size + block_index = self.next_block(block_index) + if remaining: + raise ValueError(f"STFS chain ended with {remaining} bytes remaining") + return bytes(output) + + def list_entries(self): + descriptor = self.volume_descriptor_offset + table_block_count = struct.unpack_from("/dev/null 2>&1; then - missing+=("$name") - fi -} - -echo "Syncing submodule URLs..." -git submodule sync --recursive - -echo "Initializing submodules..." -jobs="$(nproc 2>/dev/null || echo 4)" -git submodule update --init --recursive --jobs "$jobs" - -require_cmd git -require_cmd cmake -require_cmd ninja -require_cmd pkg-config -require_cmd clang-20 -require_cmd clang++-20 -require_cmd ld.lld-20 - -if [[ ! -f third_party/rexglue-sdk/CMakeLists.txt ]]; then - echo "Missing third_party/rexglue-sdk/CMakeLists.txt after submodule init." >&2 - exit 1 -fi - -if ((${#missing[@]})); then - echo - echo "Missing required build tools: ${missing[*]}" >&2 - echo "On Ubuntu 24.04, install the core toolchain and Linux headers with:" >&2 - echo " sudo apt install git cmake ninja-build clang-20 lld-20 pkg-config build-essential libgtk-3-dev libx11-xcb-dev libvulkan-dev libasound2-dev libpulse-dev libpipewire-0.3-dev libudev-dev" >&2 - echo "If clang-20 is unavailable from Ubuntu apt, install it from https://apt.llvm.org/." >&2 - exit 1 -fi - -echo -echo "Bootstrap checks passed." -echo "Configure with:" -echo " cmake --preset linux-relwithdebinfo -DSKATE3_GAME_DATA_ROOT=\"\$PWD/game\"" diff --git a/scripts/decode_xenos_texture_dumps.py b/scripts/decode_xenos_texture_dumps.py deleted file mode 100755 index 5411492..0000000 --- a/scripts/decode_xenos_texture_dumps.py +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env python3 -"""Decode debug-dumped Xbox 360 tiled RGBA8 resolve textures to PNGs.""" - -from __future__ import annotations - -import argparse -import html -import re -from pathlib import Path - -try: - from PIL import Image -except ImportError as exc: - raise SystemExit("Pillow is required to write PNGs: python3 -m pip install --user pillow") from exc - - -DUMP_NAME_RE = re.compile(r"addr_([0-9a-fA-F]{8})_len_([0-9a-fA-F]+)_") - -# These are the render-target resolve ranges observed around the Import Skater -# preset-preview path. Pitch is in pixels, including Xbox 360 tile padding. -KNOWN_SURFACES_BY_RANGE = { - (0x04614000, 0x02D000): (288, 160, 288), - (0x0460E000, 0x006000): (72, 40, 96), - (0x0460A000, 0x001000): (32, 16, 32), - (0x0460B000, 0x001000): (32, 16, 32), - (0x04911000, 0x2D0000): (1152, 640, 1152), - (0x04641000, 0x2D0000): (1152, 640, 1152), - (0x04BE1000, 0x2D0000): (1152, 640, 1152), - (0x04EB1000, 0x2D0000): (1152, 640, 1152), - (0x04BE1000, 0x168000): (1152, 320, 1152), - (0x04D49000, 0x168000): (1152, 320, 1152), - (0x04EB1000, 0x168000): (1152, 320, 1152), - (0x05019000, 0x168000): (1152, 320, 1152), - (0x05258000, 0x398000): (1280, 720, 1280), - (0x03554000, 0x300000): (1024, 768, 1024), - (0x03954000, 0x300000): (1024, 768, 1024), - (0x03C54000, 0x100000): (512, 512, 512), -} - -KNOWN_SURFACES_BY_LENGTH = { - 0x02D000: (288, 160, 288), - 0x006000: (72, 40, 96), - 0x001000: (32, 16, 32), - 0x2D0000: (1152, 640, 1152), - 0x168000: (1152, 320, 1152), - 0x398000: (1280, 720, 1280), - 0x384000: (1280, 720, 1280), - 0x300000: (1024, 768, 1024), - 0x100000: (512, 512, 512), -} - - -def tiled_offset_2d_row(y: int, pitch: int, log2_bpp: int) -> int: - macro = ((y // 32) * (pitch // 32)) << (log2_bpp + 7) - micro = ((y & 6) << 2) << log2_bpp - return ( - macro - + ((micro & ~0xF) << 1) - + (micro & 0xF) - + ((y & 8) << (3 + log2_bpp)) - + ((y & 1) << 4) - ) - - -def tiled_offset_2d_column(x: int, y: int, log2_bpp: int, base_offset: int) -> int: - macro = (x // 32) << (log2_bpp + 7) - micro = (x & 7) << log2_bpp - offset = base_offset + (macro + ((micro & ~0xF) << 1) + (micro & 0xF)) - return ( - ((offset & ~0x1FF) << 3) - + ((offset & 0x1C0) << 2) - + (offset & 0x3F) - + ((y & 16) << 7) - + (((((y & 8) >> 2) + (x >> 3)) & 3) << 6) - ) - - -def parse_dump_name(path: Path) -> tuple[int, int] | None: - match = DUMP_NAME_RE.search(path.name) - if not match: - return None - return int(match.group(1), 16), int(match.group(2), 16) - - -def infer_surface(address: int, length: int) -> tuple[int, int, int] | None: - surface = KNOWN_SURFACES_BY_RANGE.get((address, length)) - if surface: - return surface - surface = KNOWN_SURFACES_BY_LENGTH.get(length) - if surface: - return surface - - pixels = length // 4 - for pitch in (1280, 1152, 1024, 768, 720, 640, 512, 384, 320, 288, 256, 128, 96, 64, 32): - if pixels % pitch == 0: - height = pixels // pitch - if height > 0: - return pitch, height, pitch - return None - - -def untile_rgba8(raw: bytes, width: int, height: int, pitch: int) -> bytearray: - output = bytearray(width * height * 4) - for y in range(height): - row_offset = tiled_offset_2d_row(y, pitch, 2) - for x in range(width): - input_offset = tiled_offset_2d_column(x, y, 2, row_offset) - input_offset = (input_offset >> 2) * 4 - if input_offset + 4 > len(raw): - continue - output_offset = (y * width + x) * 4 - output[output_offset : output_offset + 4] = raw[input_offset : input_offset + 4] - return output - - -def save_variants(untiled: bytearray, width: int, height: int, output_stem: Path) -> list[Path]: - rgba = Image.frombytes("RGBA", (width, height), bytes(untiled)) - - bgra_bytes = bytearray(len(untiled)) - for i in range(0, len(untiled), 4): - bgra_bytes[i + 0] = untiled[i + 2] - bgra_bytes[i + 1] = untiled[i + 1] - bgra_bytes[i + 2] = untiled[i + 0] - bgra_bytes[i + 3] = untiled[i + 3] - bgra = Image.frombytes("RGBA", (width, height), bytes(bgra_bytes)) - - alpha_rgb = bytearray(untiled) - for i in range(3, len(alpha_rgb), 4): - alpha_rgb[i] = 255 - rgba_opaque = Image.frombytes("RGBA", (width, height), bytes(alpha_rgb)) - - paths = [ - output_stem.with_name(output_stem.name + "_rgba.png"), - output_stem.with_name(output_stem.name + "_bgra.png"), - output_stem.with_name(output_stem.name + "_rgba_opaque.png"), - ] - rgba.save(paths[0]) - bgra.save(paths[1]) - rgba_opaque.save(paths[2]) - return paths - - -def write_index(rows: list[tuple[Path, int, int, int, int, list[Path]]], output_dir: Path) -> None: - index_path = output_dir / "index.html" - parts = [ - "Texture dumps", - "", - "

Texture dumps

", - ] - for raw_path, address, length, width, height, images in rows: - parts.append( - f"

{html.escape(raw_path.name)}

" - f"

address 0x{address:08X}, length 0x{length:X}, " - f"{width}x{height}

" - ) - for image_path in images: - label = image_path.name.removeprefix(raw_path.stem + "_").removesuffix(".png") - parts.append( - f"

{html.escape(label)}

" - f"
" - ) - parts.append("
") - index_path.write_text("\n".join(parts), encoding="utf-8") - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument( - "input_dir", - nargs="?", - default="/home/alex/Desktop/skate_fresh/logs/texture_dumps/raw", - help="Directory containing resolve_*.bin dumps", - ) - parser.add_argument( - "--output-dir", - default="/home/alex/Desktop/skate_fresh/logs/texture_dumps/rendered", - help="Directory to write decoded PNGs and index.html", - ) - args = parser.parse_args() - - input_dir = Path(args.input_dir) - output_dir = Path(args.output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - rows: list[tuple[Path, int, int, int, int, list[Path]]] = [] - for raw_path in sorted(input_dir.glob("resolve_*.bin")): - parsed = parse_dump_name(raw_path) - if not parsed: - continue - address, length = parsed - surface = infer_surface(address, length) - if not surface: - print(f"Skipping {raw_path.name}: unknown dimensions for length 0x{length:X}") - continue - width, height, pitch = surface - raw = raw_path.read_bytes() - untiled = untile_rgba8(raw, width, height, pitch) - output_stem = output_dir / raw_path.stem - images = save_variants(untiled, width, height, output_stem) - rows.append((raw_path, address, length, width, height, images)) - print(f"Decoded {raw_path.name}: {width}x{height}, pitch {pitch}") - - write_index(rows, output_dir) - print(f"Wrote {output_dir / 'index.html'}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/render_static_rx2_textures.py b/scripts/render_static_rx2_textures.py deleted file mode 100755 index 352a659..0000000 --- a/scripts/render_static_rx2_textures.py +++ /dev/null @@ -1,324 +0,0 @@ -#!/usr/bin/env python3 -"""Extract Skate 3 EB archives and render top-mip RX2 textures to PNG.""" - -from __future__ import annotations - -import argparse -import html -import math -import shutil -import struct -import subprocess -from pathlib import Path - -from PIL import Image - - -TEXTURE_FORMATS = { - 0x52: ("DXT1", 8), - 0x53: ("DXT3", 16), - 0x54: ("DXT5", 16), -} - - -def safe_output_stem(path: Path, root: Path | None = None) -> str: - try: - label = path.relative_to(root) if root else path - except ValueError: - label = path - return "__".join(part.replace("/", "_").replace("\\", "_") for part in label.with_suffix("").parts) - - -def tiled_offset_2d_row(y: int, width: int, log2_bpp: int) -> int: - macro = ((y // 32) * (width // 32)) << (log2_bpp + 7) - micro = ((y & 6) << 2) << log2_bpp - return ( - macro - + ((micro & ~0xF) << 1) - + (micro & 0xF) - + ((y & 8) << (3 + log2_bpp)) - + ((y & 1) << 4) - ) - - -def tiled_offset_2d_column(x: int, y: int, log2_bpp: int, base_offset: int) -> int: - macro = (x // 32) << (log2_bpp + 7) - micro = (x & 7) << log2_bpp - offset = base_offset + (macro + ((micro & ~0xF) << 1) + (micro & 0xF)) - return ( - ((offset & ~0x1FF) << 3) - + ((offset & 0x1C0) << 2) - + (offset & 0x3F) - + ((y & 16) << 7) - + (((((y & 8) >> 2) + (x >> 3)) & 3) << 6) - ) - - -def swap16(data: bytes) -> bytes: - output = bytearray(data) - for offset in range(0, len(output) - 1, 2): - output[offset], output[offset + 1] = output[offset + 1], output[offset] - return bytes(output) - - -def untile_blocks(data: bytes, blocks_w: int, blocks_h: int, bytes_per_block: int) -> bytes: - log2_bpp = 3 if bytes_per_block == 8 else 4 - pitch_blocks = max(32, math.ceil(blocks_w / 32) * 32) - output = bytearray(blocks_w * blocks_h * bytes_per_block) - for y in range(blocks_h): - row_offset = tiled_offset_2d_row(y, pitch_blocks, log2_bpp) - for x in range(blocks_w): - input_offset = tiled_offset_2d_column(x, y, log2_bpp, row_offset) - input_offset = (input_offset >> log2_bpp) * bytes_per_block - output_offset = (y * blocks_w + x) * bytes_per_block - if input_offset + bytes_per_block <= len(data): - output[output_offset : output_offset + bytes_per_block] = data[ - input_offset : input_offset + bytes_per_block - ] - return swap16(output) - - -def make_dds(width: int, height: int, fourcc: str, pitch_or_size: int, payload: bytes) -> bytes: - header = bytearray(b"DDS ") - header += struct.pack(" tuple[int, int, int, int] | None: - if not data.startswith(b"\x89RW4xb2") or len(data) < 0x238: - return None - data_offset = int.from_bytes(data[0x44:0x48], "big") - if data_offset <= 0 or data_offset >= len(data): - data_offset = 0x238 - fmt = data[0x167] - height = (int.from_bytes(data[0x168:0x16A], "big") + 1) * 8 - width = (int.from_bytes(data[0x16A:0x16C], "big") + 1) & 0x1FFF - if width <= 0 or height <= 0 or width > 8192 or height > 8192: - return None - return fmt, width, height, data_offset - - -def render_rx2( - path: Path, - output_dir: Path, - root: Path | None = None, - layout: str = "tiled", -) -> Path | None: - output_path = output_dir / "images" / (safe_output_stem(path, root) + ".png") - if output_path.exists(): - return output_path - - data = path.read_bytes() - parsed = parse_rx2_header(data) - if not parsed: - return None - fmt, width, height, data_offset = parsed - format_info = TEXTURE_FORMATS.get(fmt) - if not format_info: - return None - - fourcc, bytes_per_block = format_info - blocks_w = max(1, math.ceil(width / 4)) - blocks_h = max(1, math.ceil(height / 4)) - top_mip_size = blocks_w * blocks_h * bytes_per_block - payload = data[data_offset : data_offset + top_mip_size] - if len(payload) < top_mip_size: - return None - - if layout == "linear": - decoded = swap16(payload) - elif layout == "tiled": - decoded = untile_blocks(payload, blocks_w, blocks_h, bytes_per_block) - else: - raise ValueError(f"Unknown RX2 layout: {layout}") - - dds = make_dds(width, height, fourcc, top_mip_size, decoded) - output_path.parent.mkdir(parents=True, exist_ok=True) - Image.open(__import__("io").BytesIO(dds)).convert("RGBA").save(output_path) - return output_path - - -def display_source_name(source: Path, root: Path | None) -> str: - try: - return str(source.relative_to(root)) if root else str(source) - except ValueError: - return str(source) - - -def write_texture_page( - rendered: list[tuple[Path, Path, str]], - output_dir: Path, - page_number: int, - page_count: int, -) -> str: - page_name = f"page-{page_number:03d}.html" - rows = [ - "Skate 3 RX2 textures", - "", - f"

Skate 3 RX2 Textures: Page {page_number} / {page_count}

", - "
") - for source, image, label in rendered: - rel_image = image.relative_to(output_dir) - rows.append( - f"
" - f"

{html.escape(label)}

" - ) - rows.append("
") - (output_dir / page_name).write_text("\n".join(rows), encoding="utf-8") - return page_name - - -def write_index(rendered: list[tuple[Path, Path, str]], output_dir: Path, page_size: int) -> None: - page_size = max(1, page_size) - pages = [rendered[offset : offset + page_size] for offset in range(0, len(rendered), page_size)] - page_names = [ - write_texture_page(page, output_dir, page_number, len(pages)) - for page_number, page in enumerate(pages, start=1) - ] - - rows = [ - "Skate 3 RX2 textures", - "", - "

Skate 3 RX2 Textures

", - f"

{len(rendered)} textures rendered. Open one page at a time to avoid loading every PNG at once.

", - "
", - ] - for index, page_name in enumerate(page_names, start=1): - start = ((index - 1) * page_size) + 1 - end = min(index * page_size, len(rendered)) - rows.append( - f"Page {index}
{start}-{end}
" - ) - rows.append("
") - (output_dir / "index.html").write_text("\n".join(rows), encoding="utf-8") - - -def maybe_extract_big(args: argparse.Namespace) -> Path: - extracted_dir = Path(args.extracted_dir) - texture_dir = extracted_dir / "data/content/createacharacter/texture" - if texture_dir.exists() and list(texture_dir.glob("*.rx2")): - return extracted_dir - - if not args.big: - raise SystemExit("No extracted textures found and --big was not provided.") - quickbms = shutil.which(args.quickbms) - if not quickbms: - raise SystemExit(f"QuickBMS executable not found: {args.quickbms}") - script = Path(args.bms_script) - if not script.exists(): - raise SystemExit(f"BMS script not found: {script}") - - extracted_dir.mkdir(parents=True, exist_ok=True) - subprocess.run([quickbms, "-Y", str(script), str(args.big), str(extracted_dir)], check=True) - return extracted_dir - - -def read_paths_file(path: Path) -> list[Path]: - files: list[Path] = [] - for line in path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - files.append(Path(line)) - return files - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--big", type=Path, help="Optional createacharacter.big path to extract first") - parser.add_argument("--quickbms", default="/tmp/quickbms_skate/quickbms_linux/quickbms") - parser.add_argument("--bms-script", default="/tmp/quickbms_skate/fightnight.bms") - parser.add_argument("--extracted-dir", default="/tmp/skate_static_rx2") - parser.add_argument( - "--texture-dir", - type=Path, - help="Directory containing RX2 files to render. Defaults to createacharacter/texture.", - ) - parser.add_argument( - "--paths-file", - type=Path, - help="Text file containing explicit RX2 paths to render, one per line.", - ) - parser.add_argument("--recursive", action="store_true", help="Recursively scan --texture-dir") - parser.add_argument( - "--layout", - choices=("tiled", "linear"), - default="tiled", - help="RX2 memory layout. createacharacter textures are tiled; FE/menu textures are usually linear.", - ) - parser.add_argument("--output-dir", default="/tmp/skate_static_rx2_rendered") - parser.add_argument("--limit", type=int, default=500, help="Maximum textures to render") - parser.add_argument("--page-size", type=int, default=100, help="Textures per HTML page") - parser.add_argument("--verbose", action="store_true", help="Print every rendered texture") - args = parser.parse_args() - - extracted_dir = Path(args.extracted_dir) - if args.paths_file: - sources = read_paths_file(args.paths_file) - source_root = None - else: - if args.texture_dir: - texture_dir = args.texture_dir - source_root = texture_dir - else: - extracted_dir = maybe_extract_big(args) - texture_dir = extracted_dir / "data/content/createacharacter/texture" - source_root = texture_dir - globber = texture_dir.rglob if args.recursive else texture_dir.glob - sources = sorted(globber("*.rx2")) - - output_dir = Path(args.output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - rendered: list[tuple[Path, Path, str]] = [] - for source in sources: - image = render_rx2(source, output_dir, source_root, args.layout) - if image: - rendered.append((source, image, display_source_name(source, source_root))) - if args.verbose: - print(f"Rendered {source.name}") - if args.limit and len(rendered) >= args.limit: - break - - write_index(rendered, output_dir, args.page_size) - print(f"Wrote {len(rendered)} textures to {output_dir}") - print(f"Open {output_dir / 'index.html'}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/run-linux.sh b/scripts/run-linux.sh deleted file mode 100755 index 6c5d43b..0000000 --- a/scripts/run-linux.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -build_dir="$repo_root/out/build/linux-relwithdebinfo" -rexglue_lib_dir="$repo_root/third_party/rexglue-sdk/out/linux-amd64" -game_dir="${SKATE3_GAME_DATA_ROOT:-$repo_root/game}" -exe="$build_dir/skate3" - -if [[ ! -x "$exe" ]]; then - echo "Missing executable: $exe" >&2 - echo "Build it with:" >&2 - echo " cmake --build --preset linux-relwithdebinfo --parallel" >&2 - exit 1 -fi - -if [[ ! -d "$rexglue_lib_dir" ]]; then - echo "Missing rexglue runtime library directory: $rexglue_lib_dir" >&2 - echo "Build rexglue with:" >&2 - echo " cmake --build --preset linux-relwithdebinfo --target generate-all --parallel" >&2 - exit 1 -fi - -export LD_LIBRARY_PATH="$rexglue_lib_dir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - -has_game_root_arg=0 -has_input_backend_arg=0 -has_mnk_mode_arg=0 -for arg in "$@"; do - case "$arg" in - --game_data_root|--game_data_root=*) - has_game_root_arg=1 - ;; - --input_backend|--input_backend=*) - has_input_backend_arg=1 - ;; - --mnk_mode|--no-mnk_mode) - has_mnk_mode_arg=1 - ;; - esac -done - -default_args=() -if [[ "$has_game_root_arg" -eq 0 ]]; then - default_args+=(--game_data_root="$game_dir") -fi -if [[ "$has_input_backend_arg" -eq 0 ]]; then - default_args+=(--input_backend=sdl) -fi -if [[ "$has_mnk_mode_arg" -eq 0 && "${SKATE3_MNK:-0}" == "1" ]]; then - default_args+=(--mnk_mode) -fi - -exec "$exe" "${default_args[@]}" "$@" diff --git a/src/exception_compat.cpp b/src/exception_compat.cpp index 1aacae1..3186885 100644 --- a/src/exception_compat.cpp +++ b/src/exception_compat.cpp @@ -2,15 +2,34 @@ #include -extern "C" REX_FUNC(__imp__sub_82F44E40); +// The guest C runtime dispatches structured-exception guards through a +// trampoline pointer that real hardware installs during CRT startup. The +// recompiled runtime never installs it, so the generated guard function would +// return with r3 untouched (its argument: a non-zero stack buffer), which the +// 17 call sites interpret as "an exception occurred" and silently skip their +// guarded work. Force the setjmp-style "direct return" result (r3 = 0) when +// the trampoline is absent. +// +// Retail 3.0.0.0: guard at 0x82F44E40, trampoline at 0x83092CC0. +// Title update 3.0.3.0: guard at 0x82F6FAA0, trampoline at 0x830EF8C0. +#if SKATE3_HAS_TITLE_UPDATE +#define SKATE3_EXCEPTION_GUARD_FUNC sub_82F6FAA0 +#define SKATE3_EXCEPTION_GUARD_IMP __imp__sub_82F6FAA0 +constexpr uint32_t kExceptionTrampoline = 0x830EF8C0u; +#else +#define SKATE3_EXCEPTION_GUARD_FUNC sub_82F44E40 +#define SKATE3_EXCEPTION_GUARD_IMP __imp__sub_82F44E40 +constexpr uint32_t kExceptionTrampoline = 0x83092CC0u; +#endif -extern "C" REX_FUNC(sub_82F44E40) { - constexpr uint32_t kExceptionTrampoline = 0x83092CC0u; +extern "C" REX_FUNC(SKATE3_EXCEPTION_GUARD_IMP); + +extern "C" REX_FUNC(SKATE3_EXCEPTION_GUARD_FUNC) { const uint32_t trampoline = REX_LOAD_U32(kExceptionTrampoline); if (trampoline == 0u) { ctx.r3.u64 = 0; return; } - __imp__sub_82F44E40(ctx, base); + SKATE3_EXCEPTION_GUARD_IMP(ctx, base); } diff --git a/src/skate3_app_common.cpp b/src/skate3_app_common.cpp index 4c0308d..75e32ad 100644 --- a/src/skate3_app_common.cpp +++ b/src/skate3_app_common.cpp @@ -2,6 +2,7 @@ #include "skate3_fov.h" #include "skate3_iso_installer.h" +#include "skate3_title_update_installer.h" #include "skate3_user_settings.h" #include @@ -509,13 +510,15 @@ std::vector DiscoverDlcSourceDirectories( } // namespace -// sub_82EBAE4C: alternate entry into sub_82EBAE34 (37 vtable refs in .data). +#if !SKATE3_HAS_TITLE_UPDATE +// Retail sub_82EBAE4C: alternate entry into sub_82EBAE34 (37 vtable refs in .data). // Body: lwz r3, 0x164(r31); addi r1, r31, 0x140; b __restgprlr_19 static void Sub82EBAE4CImpl(PPCContext& ctx, uint8_t* base) { ctx.r3.u64 = REX_LOAD_U32(ctx.r31.u32 + 0x164); ctx.r1.s64 = ctx.r31.s64 + 0x140; __restgprlr_19(ctx, base); } +#endif Skate3BaseApp::~Skate3BaseApp() = default; @@ -612,8 +615,23 @@ std::optional Skate3BaseApp::OnFinalizePaths( #if defined(__APPLE__) if (const char* automated_iso = std::getenv("SKATE3_INSTALL_ISO"); automated_iso == nullptr || *automated_iso == '\0') { +#if SKATE3_HAS_TITLE_UPDATE + // Chain the title update wizard after the ISO install completes. + auto resume_after_title_update = + [this, resume = std::move(resume)](rex::PathConfig paths) mutable { + if (!skate3::IsTitleUpdateInstalled(paths.game_data_root)) { + skate3::ShowTitleUpdateInstallWizard(imgui_drawer(), std::move(paths), + std::move(resume)); + return; + } + resume(std::move(paths)); + }; + skate3::ShowRexglueIsoInstallWizard(imgui_drawer(), std::move(runtime_paths), + std::move(resume_after_title_update)); +#else skate3::ShowRexglueIsoInstallWizard(imgui_drawer(), std::move(runtime_paths), std::move(resume)); +#endif return std::nullopt; } #endif @@ -624,8 +642,35 @@ std::optional Skate3BaseApp::OnFinalizePaths( app_context().QuitFromUIThread(); return std::nullopt; } - return installed_paths; + runtime_paths = std::move(installed_paths); } + +#if SKATE3_HAS_TITLE_UPDATE + // This build executes Title Update 3 code; the game cannot boot without the + // TU payloads staged next to the installed game files. Existing installs + // from releases that predate TU support land here with the game present but + // the title update missing. + if (!skate3::IsTitleUpdateInstalled(runtime_paths.game_data_root)) { + REXLOG_INFO("Skate 3 Title Update 3 not staged at {}; launching title update installer", + runtime_paths.game_data_root.string()); +#if defined(__APPLE__) + if (const char* automated_tu = std::getenv("SKATE3_INSTALL_TU"); + automated_tu == nullptr || *automated_tu == '\0') { + skate3::ShowTitleUpdateInstallWizard(imgui_drawer(), std::move(runtime_paths), + std::move(resume)); + return std::nullopt; + } +#endif + rex::PathConfig tu_paths; + const bool tu_installed = skate3::RunTitleUpdateInstallWizardBlocking( + app_context(), window(), imgui_drawer(), runtime_paths, tu_paths); + if (!tu_installed) { + app_context().QuitFromUIThread(); + return std::nullopt; + } + runtime_paths = std::move(tu_paths); + } +#endif return runtime_paths; } @@ -676,8 +721,10 @@ void Skate3BaseApp::OnPostSetup() { InstallDlcPackages(); InstallRecipeOverlay(); - // Register multi-entry-function alternate entries. + // Register retail multi-entry-function alternate entries. +#if !SKATE3_HAS_TITLE_UPDATE runtime()->function_dispatcher()->SetFunction(0x82EBAE4C, &Sub82EBAE4CImpl); +#endif auto* dispatcher = runtime()->function_dispatcher(); if (dispatcher->InitializeFunctionTable(eawebkit_PPCImageConfig.code_base, diff --git a/src/skate3_iso_installer_macos.mm b/src/skate3_iso_installer_macos.mm index 214ead0..cf3b09d 100644 --- a/src/skate3_iso_installer_macos.mm +++ b/src/skate3_iso_installer_macos.mm @@ -8,6 +8,45 @@ namespace skate3 { +std::filesystem::path PickTitleUpdateFileMacOS() { + @autoreleasepool { + SDL_Window* sdl_window = SDL_GetKeyboardFocus(); + NSWindow* ns_window = nil; + + if (sdl_window) { + SDL_PropertiesID properties = SDL_GetWindowProperties(sdl_window); + ns_window = static_cast( + SDL_GetPointerProperty(properties, SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, nullptr)); + SDL_RaiseWindow(sdl_window); + } + + [NSApp activateIgnoringOtherApps:YES]; + if (ns_window) { + [ns_window makeKeyAndOrderFront:nil]; + } + + NSOpenPanel* panel = [NSOpenPanel openPanel]; + [panel setTitle:@"Select the Skate 3 Title Update 3 package"]; + [panel setPrompt:@"Select"]; + [panel setCanChooseFiles:YES]; + [panel setCanChooseDirectories:NO]; + [panel setAllowsMultipleSelection:NO]; + [panel setResolvesAliases:YES]; + [panel setAllowsOtherFileTypes:YES]; + + NSInteger response = [panel runModal]; + std::filesystem::path result; + if (response == NSModalResponseOK) { + NSURL* url = [[panel URLs] firstObject]; + if (url && [url isFileURL]) { + result = std::string([[url path] UTF8String]); + } + } + + return result; + } +} + std::filesystem::path PickIsoFileMacOS() { @autoreleasepool { SDL_Window* sdl_window = SDL_GetKeyboardFocus(); diff --git a/src/skate3_title_update_installer.cpp b/src/skate3_title_update_installer.cpp new file mode 100644 index 0000000..56b3969 --- /dev/null +++ b/src/skate3_title_update_installer.cpp @@ -0,0 +1,879 @@ +#include "skate3_title_update_installer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "third_party/rexglue-sdk/thirdparty/crypto/sha256.h" + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include + +#include + +#include +#elif defined(__APPLE__) +#else +#include +#endif + +REXCVAR_DEFINE_STRING(skate3_title_update_url, + "https://xboxunity.net/Resources/Lib/TitleUpdate.php?tuid=21774", + "Skate 3", + "Download URL for the Skate 3 Title Update 3 package (TU3)"); + +namespace skate3 { + +#if defined(__APPLE__) +std::filesystem::path PickTitleUpdateFileMacOS(); +#endif + +namespace { + +// The recompilation is generated against exactly these payloads +// (Skate 3 title 454108E6, base 3.0.0.0 -> 3.0.3.0, media ID 5C087C2C). +struct TitleUpdatePayload { + std::string_view container_path; // path inside the TU STFS package + std::string_view staged_path; // path relative to the game root + uint64_t size; + std::string_view sha256; // lowercase hex +}; + +constexpr std::array kPayloads = {{ + {"default.xexp", "default.xexp", 1701888, + "eb9ef9109dfa6d940df2e156e7eaeda4603d2b2319ca6451f324b1c27f2b1f4c"}, + {"data/webkit/EAWebkit.xexp", "data/webkit/EAWebkit.xexp", 4096, + "5d4a308d2a6c768fc27c8b62ccb6661171dc504f8c0011a3e116cf0074e09438"}, +}}; + +// Size of the known TU3 STFS container; used as the progress estimate when the +// HTTP response carries no Content-Length, and as a sanity cap for downloads. +constexpr uint64_t kContainerSize = 1773568; +constexpr uint64_t kMaxPackageSize = 256ull * 1024 * 1024; + +std::string ToLowerCopy(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +std::string Sha256OfData(const uint8_t* data, size_t size) { + sha256::SHA256 hasher; + return hasher(data, size); +} + +bool ReadWholeFile(const std::filesystem::path& path, std::vector& out, + std::string& error) { + std::error_code ec; + const auto size = std::filesystem::file_size(path, ec); + if (ec) { + error = "Unable to read " + path.string() + "."; + return false; + } + if (size > kMaxPackageSize) { + error = "The selected file is too large to be a Skate 3 title update."; + return false; + } + std::ifstream file(path, std::ios::binary); + if (!file) { + error = "Unable to open " + path.string() + "."; + return false; + } + out.resize(static_cast(size)); + file.read(reinterpret_cast(out.data()), static_cast(out.size())); + if (!file) { + error = "Failed to read " + path.string() + "."; + return false; + } + return true; +} + +const TitleUpdatePayload* MatchPayload(const std::vector& data) { + for (const auto& payload : kPayloads) { + if (data.size() == payload.size && Sha256OfData(data.data(), data.size()) == payload.sha256) { + return &payload; + } + } + return nullptr; +} + +bool StagePayload(const TitleUpdatePayload& payload, const std::vector& data, + const std::filesystem::path& game_root, std::string& error) { + const auto target = game_root / std::filesystem::path(std::string(payload.staged_path)); + std::error_code ec; + std::filesystem::create_directories(target.parent_path(), ec); + if (ec) { + error = "Unable to create " + target.parent_path().string() + "."; + return false; + } + std::ofstream out(target, std::ios::binary | std::ios::trunc); + if (!out) { + error = "Unable to create " + target.string() + "."; + return false; + } + out.write(reinterpret_cast(data.data()), static_cast(data.size())); + if (!out) { + error = "Failed to write " + target.string() + "."; + return false; + } + REXLOG_INFO("Staged Skate 3 title update payload {}", target.string()); + return true; +} + +// ---------------------------------------------------------------------------- +// Minimal read-only STFS (CON/LIVE/PIRS) package reader, enough to pull the +// xexp payloads out of a title update package. +// ---------------------------------------------------------------------------- + +uint16_t Be16(const uint8_t* p) { + return static_cast((p[0] << 8) | p[1]); +} + +uint32_t Be32(const uint8_t* p) { + return (static_cast(p[0]) << 24) | (static_cast(p[1]) << 16) | + (static_cast(p[2]) << 8) | static_cast(p[3]); +} + +uint16_t Le16(const uint8_t* p) { + return static_cast(p[0] | (p[1] << 8)); +} + +uint32_t U24Le(const uint8_t* p) { + return static_cast(p[0]) | (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16); +} + +class StfsPackageReader { + public: + static constexpr uint32_t kBlockSize = 0x1000; + static constexpr uint32_t kEndOfChain = 0xFFFFFF; + + struct Entry { + std::string path; + bool is_dir = false; + uint32_t start_block = 0; + uint32_t length = 0; + }; + + static bool LooksLikeStfs(const std::vector& data) { + if (data.size() < 4) { + return false; + } + const std::string_view magic(reinterpret_cast(data.data()), 4); + return magic == "CON " || magic == "LIVE" || magic == "PIRS"; + } + + bool Open(std::vector data, std::string& error) { + data_ = std::move(data); + if (data_.size() < 0x400 || !LooksLikeStfs(data_)) { + error = "The file is not an Xbox 360 content package."; + return false; + } + header_size_ = Be32(at(0x340, 4)); + metadata_offset_ = 0x344; + volume_descriptor_offset_ = metadata_offset_ + 0x35; + const uint32_t volume_type = Be32(at(metadata_offset_ + 0x65, 4)); + if (volume_type != 0) { + error = "The content package is not an STFS volume."; + return false; + } + const uint8_t flags = *at(volume_descriptor_offset_ + 2, 1); + blocks_per_hash_table_ = (flags & 1) ? 1 : 2; + return true; + } + + bool ListEntries(std::vector& entries, std::string& error) { + entries.clear(); + const uint8_t* descriptor = at(volume_descriptor_offset_, 8); + if (!descriptor) { + error = "The content package header is truncated."; + return false; + } + uint16_t table_block_count = Le16(descriptor + 3); + uint32_t table_block = U24Le(descriptor + 5); + + for (uint32_t table = 0; table < table_block_count; ++table) { + const uint64_t table_offset = BlockToOffset(table_block); + for (uint32_t index = 0; index < 0x40; ++index) { + const uint8_t* entry = at(table_offset + index * 0x40, 0x40); + if (!entry) { + error = "The content package file table is truncated."; + return false; + } + if (entry[0] == 0) { + break; + } + const uint8_t flags = entry[40]; + const uint32_t name_length = flags & 0x3F; + if (name_length == 0 || name_length > 40) { + error = "The content package contains an invalid file name."; + return false; + } + Entry parsed; + std::string name(reinterpret_cast(entry), name_length); + parsed.is_dir = (flags & 0x80) != 0; + parsed.start_block = U24Le(entry + 47); + const uint16_t parent_index = Be16(entry + 50); + parsed.length = Be32(entry + 52); + std::string parent_path; + if (parent_index != 0xFFFF) { + if (parent_index >= entries.size()) { + error = "The content package directory tree is invalid."; + return false; + } + parent_path = entries[parent_index].path; + } + parsed.path = parent_path.empty() ? name : parent_path + "/" + name; + entries.push_back(std::move(parsed)); + } + + const uint32_t next = NextBlock(table_block); + if (next == kEndOfChain) { + break; + } + table_block = next; + } + return true; + } + + bool ReadFile(const Entry& entry, std::vector& out, std::string& error) { + out.clear(); + out.reserve(entry.length); + uint32_t block_index = entry.start_block; + uint64_t remaining = entry.length; + while (remaining > 0 && block_index != kEndOfChain) { + const uint64_t chunk = std::min(kBlockSize, remaining); + const uint8_t* block = at(BlockToOffset(block_index), chunk); + if (!block) { + error = "The content package data is truncated."; + return false; + } + out.insert(out.end(), block, block + chunk); + remaining -= chunk; + block_index = NextBlock(block_index); + } + if (remaining > 0) { + error = "The content package block chain ended unexpectedly."; + return false; + } + return true; + } + + private: + const uint8_t* at(uint64_t offset, uint64_t size) const { + if (offset + size > data_.size()) { + return nullptr; + } + return data_.data() + offset; + } + + uint64_t RoundUp(uint64_t value, uint64_t alignment) const { + return (value + alignment - 1) & ~(alignment - 1); + } + + uint64_t BlockToOffset(uint32_t block_index) const { + uint64_t block = block_index; + uint64_t base = 170; + for (int i = 0; i < 3; ++i) { + block += ((block_index + base) / base) * blocks_per_hash_table_; + if (block_index < base) { + break; + } + base *= 170; + } + return RoundUp(header_size_, kBlockSize) + (block << 12); + } + + uint32_t HashBlockNumber(uint32_t block_index, int hash_level) const { + const uint32_t block_step0 = 170 + blocks_per_hash_table_; + const uint32_t block_step1 = 28900 + (170 + 1) * blocks_per_hash_table_; + if (hash_level == 0) { + if (block_index < 170) { + return 0; + } + uint32_t block = (block_index / 170) * block_step0; + block += ((block_index / 28900) + 1) * blocks_per_hash_table_; + if (block_index < 28900) { + return block; + } + return block + blocks_per_hash_table_; + } + if (hash_level == 1) { + if (block_index < 28900) { + return block_step0; + } + const uint32_t block = (block_index / 28900) * block_step1; + return block + blocks_per_hash_table_; + } + return block_step1; + } + + uint64_t HashOffset(uint32_t block_index, int hash_level) const { + return RoundUp(header_size_, kBlockSize) + + (static_cast(HashBlockNumber(block_index, hash_level)) << 12); + } + + uint32_t NextBlock(uint32_t block_index) const { + const uint64_t hash_offset = HashOffset(block_index, 0); + const uint8_t* hash_entry = at(hash_offset + (block_index % 170) * 0x18, 0x18); + if (!hash_entry) { + return kEndOfChain; + } + return Be32(hash_entry + 0x14) & 0xFFFFFF; + } + + std::vector data_; + uint32_t header_size_ = 0; + uint32_t metadata_offset_ = 0; + uint32_t volume_descriptor_offset_ = 0; + uint32_t blocks_per_hash_table_ = 2; +}; + +// ---------------------------------------------------------------------------- +// Download +// ---------------------------------------------------------------------------- + +#if defined(_WIN32) + +std::wstring Widen(const std::string& value) { + if (value.empty()) { + return {}; + } + const int length = + MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(value.size()), nullptr, 0); + std::wstring wide(static_cast(length), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(value.size()), wide.data(), + length); + return wide; +} + +bool DownloadToFile(const std::string& url, const std::filesystem::path& destination, + std::atomic& copied_bytes, std::atomic& total_bytes, + std::string& error) { + std::wstring wide_url = Widen(url); + URL_COMPONENTS components{}; + components.dwStructSize = sizeof(components); + components.dwSchemeLength = static_cast(-1); + components.dwHostNameLength = static_cast(-1); + components.dwUrlPathLength = static_cast(-1); + components.dwExtraInfoLength = static_cast(-1); + if (!WinHttpCrackUrl(wide_url.c_str(), 0, 0, &components)) { + error = "The configured title update URL is invalid."; + return false; + } + const std::wstring host(components.lpszHostName, components.dwHostNameLength); + std::wstring path(components.lpszUrlPath, components.dwUrlPathLength); + if (components.lpszExtraInfo && components.dwExtraInfoLength) { + path.append(components.lpszExtraInfo, components.dwExtraInfoLength); + } + const bool secure = components.nScheme == INTERNET_SCHEME_HTTPS; + + HINTERNET session = WinHttpOpen(L"skate3-recomp/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!session) { + error = "Unable to initialize the download (WinHttpOpen failed)."; + return false; + } + + // Many hosts (xboxunity.net included) publish both A and AAAA records. Unlike + // browsers, WinHTTP connects to addresses sequentially, so on a machine with + // broken IPv6 routing it stalls on the dead IPv6 connect for ~15s before + // falling back to IPv4. Enable "fast fallback" (Happy Eyeballs, RFC 6555) so + // WinHTTP races both families in parallel and uses whichever connects first. +#ifndef WINHTTP_OPTION_IPV6_FAST_FALLBACK +#define WINHTTP_OPTION_IPV6_FAST_FALLBACK 140 +#endif + BOOL fast_fallback = TRUE; + WinHttpSetOption(session, WINHTTP_OPTION_IPV6_FAST_FALLBACK, &fast_fallback, + sizeof(fast_fallback)); + // Backstop for OS versions without fast fallback: bound the connect attempt so + // a dead address family cannot hang the installer indefinitely. + WinHttpSetTimeouts(session, /*resolve=*/10000, /*connect=*/10000, /*send=*/30000, + /*receive=*/60000); + auto fail = [&](const char* what) { + error = std::string("Download failed (") + what + ", error " + + std::to_string(GetLastError()) + "). Check your internet connection."; + return false; + }; + + bool ok = false; + HINTERNET connection = nullptr; + HINTERNET request = nullptr; + std::ofstream out; + do { + connection = WinHttpConnect(session, host.c_str(), components.nPort, 0); + if (!connection) { + fail("connect"); + break; + } + request = WinHttpOpenRequest(connection, L"GET", path.c_str(), nullptr, WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, secure ? WINHTTP_FLAG_SECURE : 0); + if (!request) { + fail("open request"); + break; + } + if (!WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, + 0, 0) || + !WinHttpReceiveResponse(request, nullptr)) { + fail("request"); + break; + } + + DWORD status_code = 0; + DWORD status_size = sizeof(status_code); + WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &status_code, &status_size, + WINHTTP_NO_HEADER_INDEX); + if (status_code != 200) { + error = "The download server returned HTTP " + std::to_string(status_code) + "."; + break; + } + + DWORD content_length = 0; + DWORD length_size = sizeof(content_length); + if (WinHttpQueryHeaders(request, WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &content_length, &length_size, + WINHTTP_NO_HEADER_INDEX) && + content_length > 0) { + total_bytes.store(content_length, std::memory_order_relaxed); + } + + out.open(destination, std::ios::binary | std::ios::trunc); + if (!out) { + error = "Unable to create the download file at " + destination.string() + "."; + break; + } + + std::vector buffer(64 * 1024); + uint64_t received = 0; + bool read_failed = false; + for (;;) { + DWORD available = 0; + if (!WinHttpQueryDataAvailable(request, &available)) { + fail("read"); + read_failed = true; + break; + } + if (available == 0) { + break; + } + while (available > 0) { + const DWORD chunk = std::min(available, static_cast(buffer.size())); + DWORD read = 0; + if (!WinHttpReadData(request, buffer.data(), chunk, &read) || read == 0) { + fail("read"); + read_failed = true; + break; + } + out.write(buffer.data(), static_cast(read)); + received += read; + available -= read; + copied_bytes.fetch_add(read, std::memory_order_relaxed); + if (received > kMaxPackageSize) { + error = "The download is unexpectedly large; aborting."; + read_failed = true; + break; + } + } + if (read_failed) { + break; + } + } + if (read_failed) { + break; + } + out.flush(); + if (!out) { + error = "Failed to write the downloaded file."; + break; + } + ok = received > 0; + if (!ok) { + error = "The download was empty."; + } + } while (false); + + if (request) { + WinHttpCloseHandle(request); + } + if (connection) { + WinHttpCloseHandle(connection); + } + WinHttpCloseHandle(session); + return ok; +} + +#else + +bool DownloadToFile(const std::string& url, const std::filesystem::path& destination, + std::atomic& copied_bytes, std::atomic& total_bytes, + std::string& error) { + if (url.find('\'') != std::string::npos || url.find('\\') != std::string::npos) { + error = "The configured title update URL is invalid."; + return false; + } + const std::string destination_str = destination.string(); + if (destination_str.find('\'') != std::string::npos) { + error = "The temporary download path is not usable."; + return false; + } + const std::string command = "curl -fsSL --connect-timeout 30 --max-time 600 -o '" + + destination_str + "' '" + url + "'"; + REXLOG_INFO("Downloading Skate 3 title update via curl"); + const int status = std::system(command.c_str()); + if (status != 0) { + error = "The download failed (curl exit status " + std::to_string(status) + + "). Check your internet connection, or select the title update file manually."; + return false; + } + std::error_code ec; + const auto size = std::filesystem::file_size(destination, ec); + if (ec || size == 0) { + error = "The download produced no data."; + return false; + } + total_bytes.store(size, std::memory_order_relaxed); + copied_bytes.store(size, std::memory_order_relaxed); + return true; +} + +#endif // defined(_WIN32) + +// ---------------------------------------------------------------------------- +// File picker +// ---------------------------------------------------------------------------- + +#if defined(_WIN32) +std::filesystem::path PickTitleUpdateFile() { + wchar_t filename[MAX_PATH] = {}; + OPENFILENAMEW ofn{}; + ofn.lStructSize = sizeof(ofn); + ofn.hwndOwner = GetActiveWindow(); + ofn.lpstrFile = filename; + ofn.nMaxFile = static_cast(std::size(filename)); + ofn.lpstrFilter = L"Title update package (*.*)\0*.*\0"; + ofn.lpstrTitle = L"Select the Skate 3 Title Update 3 package"; + ofn.Flags = OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR | + OFN_DONTADDTORECENT; + if (!GetOpenFileNameW(&ofn)) { + return {}; + } + return filename; +} +#elif defined(__APPLE__) +std::filesystem::path PickTitleUpdateFile() { + return skate3::PickTitleUpdateFileMacOS(); +} +#else +std::filesystem::path PickTitleUpdateFile() { + GtkWidget* dialog = gtk_file_chooser_dialog_new( + "Select the Skate 3 Title Update 3 package", nullptr, GTK_FILE_CHOOSER_ACTION_OPEN, + "_Cancel", GTK_RESPONSE_CANCEL, "_Open", GTK_RESPONSE_ACCEPT, nullptr); + if (!dialog) { + return {}; + } + + GtkFileFilter* all_filter = gtk_file_filter_new(); + gtk_file_filter_set_name(all_filter, "All files"); + gtk_file_filter_add_pattern(all_filter, "*"); + gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), all_filter); + + std::filesystem::path result; + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + char* filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); + if (filename) { + result = filename; + g_free(filename); + } + } + + gtk_widget_destroy(dialog); + while (gtk_events_pending()) { + gtk_main_iteration_do(FALSE); + } + return result; +} +#endif + +bool DownloadAndStageTitleUpdate(const std::filesystem::path& game_root, + std::atomic& copied_bytes, + std::atomic& total_bytes, std::string& error) { + total_bytes.store(kContainerSize, std::memory_order_relaxed); + + std::error_code ec; + auto temp_dir = std::filesystem::temp_directory_path(ec); + if (ec) { + temp_dir = game_root; + } + const auto temp_file = temp_dir / "skate3_title_update_download.tmp"; + + const std::string url = REXCVAR_GET(skate3_title_update_url); + REXLOG_INFO("Downloading Skate 3 title update from {}", url); + const bool downloaded = DownloadToFile(url, temp_file, copied_bytes, total_bytes, error); + bool staged = false; + if (downloaded) { + staged = StageTitleUpdateFromFile(temp_file, game_root, error); + } + std::filesystem::remove(temp_file, ec); + return downloaded && staged; +} + +} // namespace + +bool IsTitleUpdateInstalled(const std::filesystem::path& game_root) { + for (const auto& payload : kPayloads) { + const auto staged = game_root / std::filesystem::path(std::string(payload.staged_path)); + std::error_code ec; + if (!std::filesystem::is_regular_file(staged, ec) || + std::filesystem::file_size(staged, ec) != payload.size || ec) { + return false; + } + std::vector data; + std::string error; + if (!ReadWholeFile(staged, data, error) || + Sha256OfData(data.data(), data.size()) != payload.sha256) { + return false; + } + } + return true; +} + +bool StageTitleUpdateFromFile(const std::filesystem::path& source, + const std::filesystem::path& game_root, std::string& error) { + std::vector data; + if (!ReadWholeFile(source, data, error)) { + return false; + } + + if (StfsPackageReader::LooksLikeStfs(data)) { + StfsPackageReader package; + if (!package.Open(std::move(data), error)) { + return false; + } + std::vector entries; + if (!package.ListEntries(entries, error)) { + return false; + } + size_t staged_count = 0; + for (const auto& payload : kPayloads) { + const auto wanted = ToLowerCopy(std::string(payload.container_path)); + const auto it = std::find_if(entries.begin(), entries.end(), [&](const auto& entry) { + return !entry.is_dir && ToLowerCopy(entry.path) == wanted; + }); + if (it == entries.end()) { + error = "The package does not contain " + std::string(payload.container_path) + + "; it is not the Skate 3 title update."; + return false; + } + std::vector file_data; + if (!package.ReadFile(*it, file_data, error)) { + return false; + } + if (file_data.size() != payload.size || + Sha256OfData(file_data.data(), file_data.size()) != payload.sha256) { + error = "The package contains a different version of " + + std::string(payload.container_path) + + "; this build requires Title Update 3 (3.0.3.0)."; + return false; + } + if (!StagePayload(payload, file_data, game_root, error)) { + return false; + } + ++staged_count; + } + return staged_count == kPayloads.size(); + } + + // Not an STFS container; accept a raw xexp payload that matches one of the + // expected files exactly. + if (const auto* payload = MatchPayload(data)) { + if (!StagePayload(*payload, data, game_root, error)) { + return false; + } + if (!IsTitleUpdateInstalled(game_root)) { + error = std::string(payload->staged_path) + + " was installed, but the other title update file is still missing. Provide the " + "full title update package."; + return false; + } + return true; + } + + error = + "The selected file is neither the Skate 3 title update package nor one of its " + "xexp payloads."; + return false; +} + +void ShowTitleUpdateInstallWizard(rex::ui::ImGuiDrawer* drawer, rex::PathConfig runtime_paths, + std::function complete) { + const auto game_root = runtime_paths.game_data_root; + + rex::ui::AcquireWizardDialog::Options options; + options.title = "Skate 3 Title Update"; + options.intro = + "This build of Skate 3 requires Title Update 3, a free update originally published on " + "Xbox Live. It is not part of the game disc."; + options.target_directory = game_root.string(); + options.initial_status = + "Download it automatically, or select a title update package you already have."; + options.fetch_button_label = "Download (1.7 MB)"; + options.pick_button_label = "Select file..."; + options.fetch_connecting_status = + "Connecting to the download server... (this can take a moment)"; + options.fetch_working_status = "Downloading Title Update 3..."; + options.install_working_status = "Installing the title update..."; + options.done_status = "Title Update 3 installed."; + options.done_button_label = "Start Game"; + + auto fetch = [game_root](std::atomic& copied_bytes, std::atomic& total_bytes, + std::string& error) { + if (!DownloadAndStageTitleUpdate(game_root, copied_bytes, total_bytes, error)) { + return false; + } + if (!IsTitleUpdateInstalled(game_root)) { + error = "The title update could not be verified after installation."; + return false; + } + return true; + }; + auto install = [game_root](const std::filesystem::path& source, + std::atomic& copied_bytes, + std::atomic& total_bytes, std::string& error) { + (void)copied_bytes; + (void)total_bytes; + if (!StageTitleUpdateFromFile(source, game_root, error)) { + return false; + } + if (!IsTitleUpdateInstalled(game_root)) { + error = "The title update could not be verified after installation."; + return false; + } + return true; + }; + + new rex::ui::AcquireWizardDialog( + drawer, std::move(options), std::move(fetch), []() { return PickTitleUpdateFile(); }, + std::move(install), + [runtime_paths = std::move(runtime_paths), complete = std::move(complete)]() mutable { + if (complete) { + complete(std::move(runtime_paths)); + } + }); +} + +bool RunTitleUpdateInstallWizardBlocking(rex::ui::WindowedAppContext& app_context, + rex::ui::Window* window, + rex::ui::ImGuiDrawer* drawer, + rex::PathConfig runtime_paths, + rex::PathConfig& installed_paths) { + if (const char* automated_tu = std::getenv("SKATE3_INSTALL_TU"); + automated_tu != nullptr && *automated_tu != '\0') { + std::string error; + bool ok = false; + REXLOG_INFO("Installing Skate 3 title update from SKATE3_INSTALL_TU={}", automated_tu); + if (std::string_view(automated_tu) == "download") { + std::atomic copied_bytes{0}; + std::atomic total_bytes{0}; + ok = DownloadAndStageTitleUpdate(runtime_paths.game_data_root, copied_bytes, total_bytes, + error); + } else { + ok = StageTitleUpdateFromFile(automated_tu, runtime_paths.game_data_root, error); + } + if (!ok || !IsTitleUpdateInstalled(runtime_paths.game_data_root)) { + REXLOG_ERROR("Automated title update installation failed: {}", error); + return false; + } + installed_paths = std::move(runtime_paths); + REXLOG_INFO("Automated title update installation completed successfully"); + return true; + } + + struct InstallResult { + bool done = false; + bool ok = false; + rex::PathConfig paths; + }; + auto result = std::make_shared(); + + ShowTitleUpdateInstallWizard(drawer, runtime_paths, + [result](rex::PathConfig runtime_paths) mutable { + result->paths = std::move(runtime_paths); + result->ok = true; + result->done = true; + }); + +#if defined(_WIN32) + HWND hwnd = nullptr; + if (auto* win32_window = dynamic_cast(window)) { + hwnd = win32_window->hwnd(); + } +#endif + + REXLOG_INFO("Entering Skate 3 title update installer pump"); + while (!result->done && !app_context.HasQuitFromUIThread()) { + app_context.ExecutePendingFunctionsFromUIThread(); + +#if defined(_WIN32) + MSG message; + while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + if (message.message == WM_QUIT) { + app_context.QuitFromUIThread(); + break; + } + TranslateMessage(&message); + DispatchMessageW(&message); + } + if (app_context.HasQuitFromUIThread()) { + break; + } + if (window) { + window->RequestPaint(); + } + if (hwnd) { + RedrawWindow(hwnd, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_NOERASE); + } +#else + if (window) { + window->RequestPaint(); + } +#if !defined(__APPLE__) + while (gtk_events_pending()) { + gtk_main_iteration_do(FALSE); + } +#endif +#endif + std::this_thread::sleep_for(std::chrono::milliseconds(16)); + } + + if (!result->ok) { + REXLOG_INFO("Leaving title update installer pump without installation"); + return false; + } + + installed_paths = std::move(result->paths); + REXLOG_INFO("Leaving title update installer pump after successful installation"); + return true; +} + +} // namespace skate3 diff --git a/src/skate3_title_update_installer.h b/src/skate3_title_update_installer.h new file mode 100644 index 0000000..cb1a67a --- /dev/null +++ b/src/skate3_title_update_installer.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include + +namespace skate3 { + +// True when both title update payloads (default.xexp and +// data/webkit/EAWebkit.xexp) are staged in game_root and match the pinned +// SHA-256 hashes the recompilation was generated from. +bool IsTitleUpdateInstalled(const std::filesystem::path& game_root); + +// Stages the title update payloads into game_root from a local source file: +// either the TU STFS package (CON/LIVE/PIRS container) or a raw .xexp payload. +bool StageTitleUpdateFromFile(const std::filesystem::path& source, + const std::filesystem::path& game_root, std::string& error); + +void ShowTitleUpdateInstallWizard(rex::ui::ImGuiDrawer* drawer, rex::PathConfig runtime_paths, + std::function complete); +bool RunTitleUpdateInstallWizardBlocking(rex::ui::WindowedAppContext& app_context, + rex::ui::Window* window, + rex::ui::ImGuiDrawer* drawer, + rex::PathConfig runtime_paths, + rex::PathConfig& installed_paths); + +} // namespace skate3 diff --git a/third_party/rexglue-sdk b/third_party/rexglue-sdk index 05049e0..4329f6d 160000 --- a/third_party/rexglue-sdk +++ b/third_party/rexglue-sdk @@ -1 +1 @@ -Subproject commit 05049e0769ff49c928beaa6414d225715876c978 +Subproject commit 4329f6d0494f8d0aa7e58320ec3841a08dde9b07