add Skate 3 Title Update 3 support

This commit is contained in:
mchughalex 2026-06-10 03:52:29 +01:00
parent 281d4a2925
commit 97df13cb01
18 changed files with 3128 additions and 726 deletions

6
.gitignore vendored
View file

@ -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/

View file

@ -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="$<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
"$<TARGET_FILE_DIR:skate3>/game/data/webkit"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SKATE3_TITLE_UPDATE_DEFAULT_XEXP}"
"$<TARGET_FILE_DIR:skate3>/game/default.xexp"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SKATE3_TITLE_UPDATE_EAWEBKIT_XEXP}"
"$<TARGET_FILE_DIR:skate3>/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()

View file

@ -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()

View file

@ -1,5 +1,6 @@
add_custom_target(generate-skate3
COMMAND $<TARGET_FILE:rex::rexglue> 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 $<TARGET_FILE:rex::rexglue> 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

View file

@ -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("<H", self.data, descriptor + 3)[0]
table_block = u24le(self.data, descriptor + 5)
entries = []
for _ in range(table_block_count):
table_offset = self.block_to_offset(table_block)
for index in range(0x40):
entry_offset = table_offset + index * 0x40
if self.data[entry_offset] == 0:
break
flags = self.data[entry_offset + 40]
name_length = flags & 0x3F
name = self.data[entry_offset : entry_offset + name_length].decode("utf-8")
is_dir = bool(flags & 0x80)
start_block = u24le(self.data, entry_offset + 47)
parent_index = be16(self.data, entry_offset + 50)
length = be32(self.data, entry_offset + 52)
parent_path = "" if parent_index == 0xFFFF else entries[parent_index]["path"]
path = name if not parent_path else f"{parent_path.rstrip('/')}/{name}"
entries.append(
{
"path": path,
"is_dir": is_dir,
"start_block": start_block,
"length": length,
}
)
next_table_block = self.next_block(table_block)
if next_table_block == END_OF_CHAIN:
break
table_block = next_table_block
return entries
def copy_required_file(source_root, relative_path, output_root):
source = source_root / relative_path
if not source.is_file():
raise FileNotFoundError(f"required game file not found: {source}")
destination = output_root / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
def main():
parser = argparse.ArgumentParser(
description="Stage Skate 3 title update XEXP files for codegen."
)
parser.add_argument("--tu", required=True, type=Path)
parser.add_argument("--game-root", required=True, type=Path)
parser.add_argument("--out-root", required=True, type=Path)
args = parser.parse_args()
reader = StfsReader(args.tu)
entries = reader.list_entries()
files = {entry["path"]: entry for entry in entries if not entry["is_dir"]}
unexpected = sorted(set(files) - EXPECTED_FILES)
missing = sorted(EXPECTED_FILES - set(files))
if missing:
raise ValueError(f"title update is missing expected files: {', '.join(missing)}")
if unexpected:
raise ValueError(f"title update contains unexpected payload files: {', '.join(unexpected)}")
args.out_root.mkdir(parents=True, exist_ok=True)
copy_required_file(args.game_root, Path("default.xex"), args.out_root)
copy_required_file(args.game_root, Path("data/webkit/EAWebkit.xex"), args.out_root)
for relative_name in sorted(EXPECTED_FILES):
entry = files[relative_name]
destination = args.out_root / relative_name
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(reader.read_file(entry["start_block"], entry["length"]))
print(f"extracted {relative_name} ({entry['length']} bytes)")
return 0
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ name = "eawebkit"
[entrypoint]
file_path = "@SKATE3_GAME_DATA_ROOT_CONFIG@/data/webkit/EAWebkit.xex"
@SKATE3_PATCHED_EAWEBKIT_XEX_LINE@
out_directory_path = "@SKATE3_SOURCE_DIR_CONFIG@/generated/eawebkit"
includes = [
"@SKATE3_SOURCE_DIR_CONFIG@/config/eawebkit_functions.toml",

View file

@ -4,7 +4,8 @@ name = "skate3"
[entrypoint]
file_path = "@SKATE3_GAME_DATA_ROOT_CONFIG@/default.xex"
@SKATE3_PATCHED_DEFAULT_XEX_LINE@
out_directory_path = "@SKATE3_SOURCE_DIR_CONFIG@/generated"
includes = [
"@SKATE3_SOURCE_DIR_CONFIG@/config/skate3_functions.toml",
@SKATE3_FUNCTION_CONFIG_INCLUDES@
]

View file

@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
missing=()
require_cmd() {
local name="$1"
if ! command -v "$name" >/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\""

View file

@ -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 = [
"<!doctype html><meta charset='utf-8'><title>Texture dumps</title>",
"<style>body{font-family:sans-serif;margin:24px;background:#111;color:#eee}"
"section{margin:0 0 32px}img{max-width:360px;image-rendering:auto;background:#444}"
".row{display:flex;gap:16px;flex-wrap:wrap}.item{max-width:380px}"
"code{color:#b7f7ff}</style>",
"<h1>Texture dumps</h1>",
]
for raw_path, address, length, width, height, images in rows:
parts.append(
f"<section><h2><code>{html.escape(raw_path.name)}</code></h2>"
f"<p>address <code>0x{address:08X}</code>, length <code>0x{length:X}</code>, "
f"{width}x{height}</p><div class='row'>"
)
for image_path in images:
label = image_path.name.removeprefix(raw_path.stem + "_").removesuffix(".png")
parts.append(
f"<div class='item'><p>{html.escape(label)}</p>"
f"<img src='{html.escape(image_path.name)}'></div>"
)
parts.append("</div></section>")
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())

View file

@ -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("<I", 124)
header += struct.pack("<I", 0x0002100F)
header += struct.pack("<I", height)
header += struct.pack("<I", width)
header += struct.pack("<I", pitch_or_size)
header += struct.pack("<I", 0)
header += struct.pack("<I", 1)
header += bytes(44)
header += struct.pack("<I", 32)
header += struct.pack("<I", 0x4)
header += fourcc.encode("ascii")
header += struct.pack("<I", 0)
header += struct.pack("<I", 0)
header += struct.pack("<I", 0)
header += struct.pack("<I", 0)
header += struct.pack("<I", 0)
header += struct.pack("<I", 0x1000)
header += struct.pack("<I", 0)
header += struct.pack("<I", 0)
header += struct.pack("<I", 0)
header += struct.pack("<I", 0)
return bytes(header) + payload
def parse_rx2_header(data: bytes) -> 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 = [
"<!doctype html><meta charset='utf-8'><title>Skate 3 RX2 textures</title>",
"<style>body{font-family:sans-serif;background:#111;color:#eee;margin:24px}"
".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}"
".item{background:#1b1b1b;padding:8px;border-radius:4px;overflow:hidden}"
"img{width:100%;height:auto;max-height:240px;object-fit:contain;background:#333;image-rendering:auto}"
"code{font-size:11px;word-break:break-all;color:#cceeff}"
"a{color:#9bdcff}.nav{display:flex;gap:12px;align-items:center;margin:12px 0 20px}</style>",
f"<h1>Skate 3 RX2 Textures: Page {page_number} / {page_count}</h1>",
"<div class='nav'>",
"<a href='index.html'>Index</a>",
]
if page_number > 1:
rows.append(f"<a href='page-{page_number - 1:03d}.html'>Previous</a>")
if page_number < page_count:
rows.append(f"<a href='page-{page_number + 1:03d}.html'>Next</a>")
rows.append("</div><div class='grid'>")
for source, image, label in rendered:
rel_image = image.relative_to(output_dir)
rows.append(
f"<div class='item'><img loading='lazy' src='{html.escape(str(rel_image))}'>"
f"<p><code>{html.escape(label)}</code></p></div>"
)
rows.append("</div>")
(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 = [
"<!doctype html><meta charset='utf-8'><title>Skate 3 RX2 textures</title>",
"<style>body{font-family:sans-serif;background:#111;color:#eee;margin:24px}"
"a{color:#9bdcff}.pages{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}"
".page{background:#1b1b1b;padding:12px;border-radius:4px}</style>",
"<h1>Skate 3 RX2 Textures</h1>",
f"<p>{len(rendered)} textures rendered. Open one page at a time to avoid loading every PNG at once.</p>",
"<div class='pages'>",
]
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"<a class='page' href='{page_name}'>Page {index}<br>{start}-{end}</a>"
)
rows.append("</div>")
(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())

View file

@ -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[@]}" "$@"

View file

@ -2,15 +2,34 @@
#include <cstdint>
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);
}

View file

@ -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 <algorithm>
@ -509,13 +510,15 @@ std::vector<std::filesystem::path> 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<rex::PathConfig> 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<rex::PathConfig> 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,

View file

@ -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<NSWindow*>(
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();

View file

@ -0,0 +1,879 @@
#include "skate3_title_update_installer.h"
#include <algorithm>
#include <array>
#include <atomic>
#include <cctype>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <memory>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
#include <rex/cvar.h>
#include <rex/logging.h>
#include <rex/ui/overlay/acquire_wizard_overlay.h>
#include <rex/ui/windowed_app_context.h>
#include "third_party/rexglue-sdk/thirdparty/crypto/sha256.h"
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <commdlg.h>
#include <windows.h>
#include <winhttp.h>
#include <rex/ui/window_win.h>
#elif defined(__APPLE__)
#else
#include <gtk/gtk.h>
#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<TitleUpdatePayload, 2> 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<char>(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<uint8_t>& 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_t>(size));
file.read(reinterpret_cast<char*>(out.data()), static_cast<std::streamsize>(out.size()));
if (!file) {
error = "Failed to read " + path.string() + ".";
return false;
}
return true;
}
const TitleUpdatePayload* MatchPayload(const std::vector<uint8_t>& 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<uint8_t>& 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<const char*>(data.data()), static_cast<std::streamsize>(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<uint16_t>((p[0] << 8) | p[1]);
}
uint32_t Be32(const uint8_t* p) {
return (static_cast<uint32_t>(p[0]) << 24) | (static_cast<uint32_t>(p[1]) << 16) |
(static_cast<uint32_t>(p[2]) << 8) | static_cast<uint32_t>(p[3]);
}
uint16_t Le16(const uint8_t* p) {
return static_cast<uint16_t>(p[0] | (p[1] << 8));
}
uint32_t U24Le(const uint8_t* p) {
return static_cast<uint32_t>(p[0]) | (static_cast<uint32_t>(p[1]) << 8) |
(static_cast<uint32_t>(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<uint8_t>& data) {
if (data.size() < 4) {
return false;
}
const std::string_view magic(reinterpret_cast<const char*>(data.data()), 4);
return magic == "CON " || magic == "LIVE" || magic == "PIRS";
}
bool Open(std::vector<uint8_t> 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<Entry>& 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<const char*>(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<uint8_t>& 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<uint64_t>(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<uint64_t>(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<uint8_t> 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<int>(value.size()), nullptr, 0);
std::wstring wide(static_cast<size_t>(length), L'\0');
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), wide.data(),
length);
return wide;
}
bool DownloadToFile(const std::string& url, const std::filesystem::path& destination,
std::atomic<uint64_t>& copied_bytes, std::atomic<uint64_t>& total_bytes,
std::string& error) {
std::wstring wide_url = Widen(url);
URL_COMPONENTS components{};
components.dwStructSize = sizeof(components);
components.dwSchemeLength = static_cast<DWORD>(-1);
components.dwHostNameLength = static_cast<DWORD>(-1);
components.dwUrlPathLength = static_cast<DWORD>(-1);
components.dwExtraInfoLength = static_cast<DWORD>(-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<char> 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<DWORD>(available, static_cast<DWORD>(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<std::streamsize>(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<uint64_t>& copied_bytes, std::atomic<uint64_t>& 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<DWORD>(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<uint64_t>& copied_bytes,
std::atomic<uint64_t>& 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<uint8_t> 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<uint8_t> 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<StfsPackageReader::Entry> 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<uint8_t> 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<void(rex::PathConfig)> 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<uint64_t>& copied_bytes, std::atomic<uint64_t>& 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<uint64_t>& copied_bytes,
std::atomic<uint64_t>& 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<uint64_t> copied_bytes{0};
std::atomic<uint64_t> 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<InstallResult>();
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<rex::ui::Win32Window*>(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

View file

@ -0,0 +1,28 @@
#pragma once
#include <filesystem>
#include <functional>
#include <rex/rex_app.h>
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<void(rex::PathConfig)> 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

@ -1 +1 @@
Subproject commit 05049e0769ff49c928beaa6414d225715876c978
Subproject commit 4329f6d0494f8d0aa7e58320ec3841a08dde9b07