add Skate 3 Title Update 3 support
This commit is contained in:
parent
281d4a2925
commit
97df13cb01
18 changed files with 3128 additions and 726 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
101
CMakeLists.txt
101
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="$<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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
184
cmake/ExtractTitleUpdateXexp.py
Normal file
184
cmake/ExtractTitleUpdateXexp.py
Normal 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())
|
||||
1734
config/skate3_tu_functions.toml
Normal file
1734
config/skate3_tu_functions.toml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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@
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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\""
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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[@]}" "$@"
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
879
src/skate3_title_update_installer.cpp
Normal file
879
src/skate3_title_update_installer.cpp
Normal 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
|
||||
28
src/skate3_title_update_installer.h
Normal file
28
src/skate3_title_update_installer.h
Normal 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
|
||||
2
third_party/rexglue-sdk
vendored
2
third_party/rexglue-sdk
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 05049e0769ff49c928beaa6414d225715876c978
|
||||
Subproject commit 4329f6d0494f8d0aa7e58320ec3841a08dde9b07
|
||||
Loading…
Add table
Reference in a new issue